diff --git a/DEPENDENCIES b/DEPENDENCIES index 495274166..d1d693d35 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,9 +1,9 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 uwebsockets https://github.com/uNetworking/uWebSockets v20.79.0 -core https://github.com/sourcemeta/core 2060a4f64a51bd277c4da802321f20e06089c145 -blaze https://github.com/sourcemeta/blaze d3acf3e45a6a4ed9a8b97108b2c32ce9ad1d6da2 -jsonbinpack https://github.com/sourcemeta/jsonbinpack 183f333e0e032ebf0a397b5d66ed504c44a0a59f -jsonschema https://github.com/sourcemeta/jsonschema e97e6d539f2124a7e7f1558471359cf95217ce29 +core https://github.com/sourcemeta/core 6bbc7a8850da0ad72cd2b2badfc33033a4e48be1 +blaze https://github.com/sourcemeta/blaze 49ff4cd2893d52970154a7eea5a729671a9f630a +jsonbinpack https://github.com/sourcemeta/jsonbinpack aa6fb7ae6addfe7d7de1f7f49e8f1bbf9652f5ee +jsonschema https://github.com/sourcemeta/jsonschema v16.2.1 bootstrap https://github.com/twbs/bootstrap v5.3.3 bootstrap-icons https://github.com/twbs/icons v1.11.3 collections/sourcemeta/std/v0 https://github.com/sourcemeta/std v0.4.0 diff --git a/enterprise/index/enterprise_index.cc b/enterprise/index/enterprise_index.cc index 0d910601a..e4bfefc4d 100644 --- a/enterprise/index/enterprise_index.cc +++ b/enterprise/index/enterprise_index.cc @@ -31,8 +31,8 @@ auto load_custom_lint_rules( const sourcemeta::one::BuildDynamicCallback &callback) -> void { const auto default_dialect{ configuration.default_dialect.value_or(std::string{})}; - for (const auto &rule_path : configuration.lint.rules) { - auto rule_schema{sourcemeta::core::read_yaml_or_json(rule_path)}; + for (const auto &rule : configuration.lint.rules) { + auto rule_schema{sourcemeta::core::read_yaml_or_json(rule.path)}; try { custom_names.emplace(bundle.add( rule_schema, sourcemeta::blaze::schema_walker, @@ -40,22 +40,25 @@ auto load_custom_lint_rules( const auto identifier) -> std::optional { return resolver(identifier, callback); }, - sourcemeta::blaze::default_schema_compiler, default_dialect)); + sourcemeta::blaze::default_schema_compiler, default_dialect, + std::nullopt, + rule.top_level ? sourcemeta::blaze::SchemaRule::Scope::TopLevel + : sourcemeta::blaze::SchemaRule::Scope::All)); } catch ( const sourcemeta::blaze::SchemaRuleInvalidNamePatternError &error) { throw sourcemeta::core::FileError< sourcemeta::blaze::SchemaRuleInvalidNamePatternError>( - rule_path, error.identifier(), error.regex()); + rule.path, error.identifier(), error.regex()); } catch (const sourcemeta::blaze::SchemaRuleInvalidNameError &error) { throw sourcemeta::core::FileError< sourcemeta::blaze::SchemaRuleInvalidNameError>( - rule_path, error.identifier(), error.what()); + rule.path, error.identifier(), error.what()); } catch (const sourcemeta::blaze::SchemaRuleMissingNameError &) { throw sourcemeta::core::FileError< - sourcemeta::blaze::SchemaRuleMissingNameError>(rule_path); + sourcemeta::blaze::SchemaRuleMissingNameError>(rule.path); } catch (const sourcemeta::blaze::SchemaUnknownBaseDialectError &) { throw sourcemeta::core::FileError< - sourcemeta::blaze::SchemaUnknownBaseDialectError>(rule_path); + sourcemeta::blaze::SchemaUnknownBaseDialectError>(rule.path); } } } diff --git a/enterprise/scripts/sbom-vendorpull.js b/enterprise/scripts/sbom-vendorpull.js index 411adb157..19b5e3488 100755 --- a/enterprise/scripts/sbom-vendorpull.js +++ b/enterprise/scripts/sbom-vendorpull.js @@ -44,7 +44,19 @@ const IGNORED = new Set([ "jsonschema-draft1", "jsonschema-draft0", "openapi", - "spdx" + "spdx", + "iana-oauth/parameters.csv", + "iana-oauth/extensions-error.csv", + "iana-oauth/token-types.csv", + "iana-oauth/token-type-hint.csv", + "iana-oauth/token-endpoint-auth-method.csv", + "iana-oauth/pkce-code-challenge-method.csv", + "iana-oauth/authorization-server-metadata.csv", + "iana-oauth/protected-resource-metadata.csv", + "iana-oauth/client-metadata.csv", + "iana-oauth/uri.csv", + "iana-oauth/token-introspection-response.csv", + "iana-oauth/endpoint.csv" ]); const IGNORED_PREFIXES = [ diff --git a/test/unit/configuration/configuration_test.cc b/test/unit/configuration/configuration_test.cc index 1bbe36487..ac611b08f 100644 --- a/test/unit/configuration/configuration_test.cc +++ b/test/unit/configuration/configuration_test.cc @@ -276,13 +276,17 @@ TEST(valid_005) { EXPECT_COLLECTION(configuration, "example", resolve.size(), 0); EXPECT_COLLECTION(configuration, "example", lint.rules.size(), 2); EXPECT_COLLECTION( - configuration, "example", lint.rules.at(0), + configuration, "example", lint.rules.at(0).path, std::filesystem::weakly_canonical(std::filesystem::path{STUB_DIRECTORY} / "rules" / "my_rule.py")); + EXPECT_COLLECTION(configuration, "example", lint.rules.at(0).top_level, + false); EXPECT_COLLECTION( - configuration, "example", lint.rules.at(1), + configuration, "example", lint.rules.at(1).path, std::filesystem::weakly_canonical(std::filesystem::path{STUB_DIRECTORY} / "rules" / "another_rule.py")); + EXPECT_COLLECTION(configuration, "example", lint.rules.at(1).top_level, + false); EXPECT_COLLECTION(configuration, "example", ignore.size(), 0); EXPECT_COLLECTION(configuration, "example", extra.size(), 1); EXPECT_COLLECTION(configuration, "example", @@ -388,9 +392,11 @@ TEST(valid_007) { EXPECT_COLLECTION(configuration, "example", resolve.size(), 0); EXPECT_COLLECTION(configuration, "example", lint.rules.size(), 1); EXPECT_COLLECTION( - configuration, "example", lint.rules.at(0), + configuration, "example", lint.rules.at(0).path, std::filesystem::weakly_canonical(std::filesystem::path{STUB_DIRECTORY} / "rules" / "my_rule.py")); + EXPECT_COLLECTION(configuration, "example", lint.rules.at(0).top_level, + false); EXPECT_COLLECTION(configuration, "example", ignore.size(), 1); EXPECT_COLLECTION(configuration, "example", ignore.at(0), std::filesystem::weakly_canonical( @@ -438,9 +444,11 @@ TEST(valid_008) { "https://example.com/schemas"); EXPECT_COLLECTION(configuration, "example", lint.rules.size(), 1); EXPECT_COLLECTION( - configuration, "example", lint.rules.at(0), + configuration, "example", lint.rules.at(0).path, std::filesystem::weakly_canonical(std::filesystem::path{STUB_DIRECTORY} / "folder" / "rules" / "my_rule.py")); + EXPECT_COLLECTION(configuration, "example", lint.rules.at(0).top_level, + false); EXPECT_COLLECTION(configuration, "example", ignore.size(), 0); EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index 4f98c23bb..a89169bcf 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 2060a4f64a51bd277c4da802321f20e06089c145 +core https://github.com/sourcemeta/core 7581b8c977c794de48e7c2ae5fcf56a7410f8fe4 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/ports/javascript/describe.mjs b/vendor/blaze/ports/javascript/describe.mjs index 18d4d63dd..4c06fdaf8 100644 --- a/vendor/blaze/ports/javascript/describe.mjs +++ b/vendor/blaze/ports/javascript/describe.mjs @@ -135,6 +135,12 @@ function resolveTarget(instance, instanceLocation) { return current; } +function instanceParent(instance, instanceLocation) { + if (instanceLocation === '') return undefined; + const lastSlash = instanceLocation.lastIndexOf('/'); + return resolveTarget(instance, instanceLocation.slice(0, lastSlash)); +} + function extractKeyword(evaluatePath) { if (evaluatePath === '') return ''; const lastSlash = evaluatePath.lastIndexOf('/'); @@ -430,13 +436,20 @@ export function describe(valid, instruction, evaluatePath, if (keyword === 'contains') { return 'The constraints declared for this keyword were not satisfiable'; } - if (keyword === 'additionalProperties' || - keyword === 'unevaluatedProperties') { + // A `false` subschema declares no keyword of its own, so a property named + // after one of these must not be described as if it were that keyword. + // The instance location agrees only when it sits under an object for the + // property cases, or under an array for the item case + const parent = instanceParent(instance, instanceLocation); + if ((keyword === 'additionalProperties' || + keyword === 'unevaluatedProperties') && + parent !== undefined && parent !== null && typeof parent === 'object' && + !Array.isArray(parent)) { const property = lastInstanceToken(instanceLocation); return 'The object value was not expected to define the property ' + escapeString(property); } - if (keyword === 'unevaluatedItems') { + if (keyword === 'unevaluatedItems' && Array.isArray(parent)) { const tokenValue = lastInstanceToken(instanceLocation); return 'The array value was not expected to define the item at index ' + tokenValue; @@ -560,7 +573,11 @@ export function describe(valid, instruction, evaluatePath, 'positional subschemas'; } - if (keyword === 'title' || keyword === 'description') { + // Annotation values come from the schema as written, so they need not have + // the type their keyword expects. When they do not, the generic fallthrough + // below describes them instead + if ((keyword === 'title' || keyword === 'description') && + typeof annotation === 'string') { let message = 'The ' + keyword + ' of the'; if (instanceLocation === '') { message += ' instance'; @@ -621,7 +638,7 @@ export function describe(valid, instruction, evaluatePath, return message; } - if (keyword === 'examples') { + if (keyword === 'examples' && Array.isArray(annotation)) { let message = ''; if (instanceLocation === '') { message += 'Examples of the instance'; @@ -640,7 +657,7 @@ export function describe(valid, instruction, evaluatePath, return message; } - if (keyword === 'contentEncoding') { + if (keyword === 'contentEncoding' && typeof annotation === 'string') { let message = 'The content encoding of the'; if (instanceLocation === '') { message += ' instance'; @@ -651,7 +668,7 @@ export function describe(valid, instruction, evaluatePath, return message; } - if (keyword === 'contentMediaType') { + if (keyword === 'contentMediaType' && typeof annotation === 'string') { let message = 'The content media type of the'; if (instanceLocation === '') { message += ' instance'; diff --git a/vendor/blaze/ports/javascript/index.mjs b/vendor/blaze/ports/javascript/index.mjs index 46684e8c7..a3aeb11fa 100644 --- a/vendor/blaze/ports/javascript/index.mjs +++ b/vendor/blaze/ports/javascript/index.mjs @@ -647,6 +647,14 @@ class Blaze { if (match) entry.skip = true; } } + + checkpoint() { + return this.evaluated.length; + } + + rewind(checkpoint) { + this.evaluated.length = checkpoint; + } } function evaluateInstructionFast(instruction, instance, depth, template, evaluator) { @@ -1646,16 +1654,21 @@ function LogicalOr(instruction, instance, depth, template, evaluator) { let result = false; if (exhaustive) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { result = true; + } else if (evaluator.trackMode) { + evaluator.rewind(checkpoint); } } } else { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { result = true; break; } + if (evaluator.trackMode) evaluator.rewind(checkpoint); } } if (evaluator.callbackMode) evaluator.callbackPop(instruction, result); @@ -1687,6 +1700,7 @@ function LogicalXor(instruction, instance, depth, template, evaluator) { let hasMatched = false; if (children) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { if (hasMatched) { result = false; @@ -1694,6 +1708,8 @@ function LogicalXor(instruction, instance, depth, template, evaluator) { } else { hasMatched = true; } + } else if (evaluator.trackMode) { + evaluator.rewind(checkpoint); } } } @@ -1710,24 +1726,29 @@ function LogicalCondition(instruction, instance, depth, template, evaluator) { const children = instruction[6]; const childrenSize = children ? children.length : 0; - let conditionEnd = childrenSize; - if (thenStart > 0) conditionEnd = thenStart; - else if (elseStart > 0) conditionEnd = elseStart; - const target = resolveInstance(instance, instruction[2]); + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; let conditionResult = true; - for (let cursor = 0; cursor < conditionEnd; cursor++) { + for (let cursor = 0; cursor < thenStart; cursor++) { if (!evaluateInstruction(children[cursor], target, depth + 1, template, evaluator)) { conditionResult = false; break; } } + if (!conditionResult && evaluator.trackMode) evaluator.rewind(checkpoint); - const consequenceStart = conditionResult ? thenStart : elseStart; - const consequenceEnd = (conditionResult && elseStart > 0) ? elseStart : childrenSize; + let consequenceStart; + let consequenceEnd; + if (conditionResult) { + consequenceStart = thenStart; + consequenceEnd = elseStart > 0 ? elseStart : childrenSize; + } else { + consequenceStart = elseStart; + consequenceEnd = elseStart > 0 ? childrenSize : elseStart; + } - if (consequenceStart > 0) { + if (consequenceStart < consequenceEnd) { if (evaluator.trackMode || evaluator.callbackMode) { evaluator.popPath(instruction[1].length); } @@ -2147,15 +2168,16 @@ function LoopPropertiesExactlyTypeStrict(instruction, instance, depth, template, return false; } const value = instruction[5]; + const names = new Set(value[1]); let count = 0; for (const key in target) { count++; - if (effectiveTypeStrictReal(target[key]) !== value[0]) { + if (effectiveTypeStrictReal(target[key]) !== value[0] || !names.has(key)) { if (evaluator.callbackMode) evaluator.callbackPop(instruction, false); return false; } } - const __result = count === value[1].length; + const __result = count === names.size; if (evaluator.callbackMode) evaluator.callbackPop(instruction, __result); return __result; }; @@ -2465,9 +2487,11 @@ function LoopItemsIntegerBoundedSized(instruction, instance, depth, template, ev const minimum = value[0][0]; const maximum = value[0][1]; const minimumSize = value[1][0]; + const maximumSize = value[1][1]; const target = resolveInstance(instance, instruction[2]); if (evaluator.callbackMode) evaluator.callbackPush(instruction); - if (!Array.isArray(target) || target.length < minimumSize) { + if (!Array.isArray(target) || target.length < minimumSize || + (maximumSize !== null && maximumSize !== undefined && target.length > maximumSize)) { if (evaluator.callbackMode) evaluator.callbackPop(instruction, false); return false; } @@ -2494,11 +2518,10 @@ function LoopContains(instruction, instance, depth, template, evaluator) { const minimum = range[0]; const maximum = range[1]; const isExhaustive = range[2]; - if (minimum === 0 && target.length === 0) return true; if (evaluator.callbackMode) evaluator.callbackPush(instruction); const children = instruction[6]; - let result = false; + let result = minimum === 0; let matchCount = 0; for (let index = 0; index < target.length; index++) { if (evaluator.callbackMode) evaluator.pushInstanceToken(index); @@ -2813,11 +2836,15 @@ function LogicalOr_fast(instruction, instance, depth, template, evaluator) { let result = false; if (exhaustive) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) result = true; + else if (evaluator.trackMode) evaluator.rewind(checkpoint); } } else { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) return true; + if (evaluator.trackMode) evaluator.rewind(checkpoint); } } return result; @@ -2862,6 +2889,7 @@ function LogicalXor_fast(instruction, instance, depth, template, evaluator) { let hasMatched = false; if (children) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { if (hasMatched) { result = false; @@ -2869,6 +2897,8 @@ function LogicalXor_fast(instruction, instance, depth, template, evaluator) { } else { hasMatched = true; } + } else if (evaluator.trackMode) { + evaluator.rewind(checkpoint); } } } @@ -2965,21 +2995,27 @@ function LogicalCondition_fast(instruction, instance, depth, template, evaluator const elseStart = value[1]; const children = instruction[6]; const childrenSize = children ? children.length : 0; - let conditionEnd = childrenSize; - if (thenStart > 0) conditionEnd = thenStart; - else if (elseStart > 0) conditionEnd = elseStart; const relInstance = instruction[2]; const target = relInstance.length === 0 ? instance : resolveInstance(instance, relInstance); + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; let conditionResult = true; - for (let cursor = 0; cursor < conditionEnd; cursor++) { + for (let cursor = 0; cursor < thenStart; cursor++) { if (!evaluateInstruction(children[cursor], target, depth + 1, template, evaluator)) { conditionResult = false; break; } } - const consequenceStart = conditionResult ? thenStart : elseStart; - const consequenceEnd = (conditionResult && elseStart > 0) ? elseStart : childrenSize; - if (consequenceStart > 0) { + if (!conditionResult && evaluator.trackMode) evaluator.rewind(checkpoint); + let consequenceStart; + let consequenceEnd; + if (conditionResult) { + consequenceStart = thenStart; + consequenceEnd = elseStart > 0 ? elseStart : childrenSize; + } else { + consequenceStart = elseStart; + consequenceEnd = elseStart > 0 ? childrenSize : elseStart; + } + if (consequenceStart < consequenceEnd) { if (evaluator.trackMode) { evaluator.popPath(instruction[1].length); } @@ -3700,12 +3736,13 @@ function LoopPropertiesExactlyTypeStrict_fast(instruction, instance, depth, temp const target = resolveInstance(instance, instruction[2]); if (!isObject(target)) return false; const value = instruction[5]; + const names = new Set(value[1]); let count = 0; for (const key in target) { count++; - if (effectiveTypeStrictReal(target[key]) !== value[0]) return false; + if (effectiveTypeStrictReal(target[key]) !== value[0] || !names.has(key)) return false; } - return count === value[1].length; + return count === names.size; } function LoopPropertiesExactlyTypeStrictHash_fast(instruction, instance, depth, template, evaluator) { @@ -3881,8 +3918,10 @@ function LoopItemsIntegerBoundedSized_fast(instruction, instance, depth, templat const minimum = value[0][0]; const maximum = value[0][1]; const minimumSize = value[1][0]; + const maximumSize = value[1][1]; const target = resolveInstance(instance, instruction[2]); - if (!Array.isArray(target) || target.length < minimumSize) return false; + if (!Array.isArray(target) || target.length < minimumSize || + (maximumSize !== null && maximumSize !== undefined && target.length > maximumSize)) return false; for (let index = 0; index < target.length; index++) { const element = target[index]; const elementType = typeof element; diff --git a/vendor/blaze/ports/javascript/package-lock.json b/vendor/blaze/ports/javascript/package-lock.json index 4b5a3aa2c..95ea9a6cd 100644 --- a/vendor/blaze/ports/javascript/package-lock.json +++ b/vendor/blaze/ports/javascript/package-lock.json @@ -6,7 +6,14 @@ "packages": { "": { "name": "@sourcemeta/blaze", - "version": "0.0.0" + "version": "0.0.0", + "license": "LGPL-3.0-or-later", + "engines": { + "node": ">=21.7" + }, + "funding": { + "url": "https://github.com/sponsors/sourcemeta" + } } } } diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown index 0e4f53607..f35654e05 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown @@ -50,10 +50,13 @@ This keyword has no effect on any other keyword. The keywords in this section map the instance locations that their subschemas successfully evaluate to nodes, literals, and edges of a JSON-LD document. A -keyword takes effect at every instance location its subschema evaluates. When -several subschemas annotate the same instance location, their annotations are -merged, and a keyword that takes a single value MUST NOT be assigned two -different values for the same location. +keyword takes effect at every instance location its subschema evaluates, +including across references, so annotations declared in different schemas may +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. #### 3.2.1. `x-jsonld-id` @@ -64,7 +67,8 @@ This keyword declares the predicate IRI that connects the annotated instance location, as the object, to its enclosing node. It MUST NOT be applied to the document root, to an array element, or to a member of a location annotated with `x-jsonld-container`, as such locations have no enclosing node to attach -a predicate to. +a predicate to. Distinct values that meet at the same location merge, +asserting the location under each declared predicate. #### 3.2.2. `x-jsonld-type` @@ -72,9 +76,11 @@ The value of this keyword MUST be a string representing an absolute IRI [RFC3987], or an array of such strings. This keyword declares the `@type` of the node that the annotated instance -location materializes as. Duplicate entries are collapsed, and an empty array -declares no types. It MUST be applied to a location whose value is an object, -unless the location is promoted to an identified node with `x-jsonld-self`. +location materializes as. A single IRI is equivalent to an array containing +only that IRI, values that meet at the same location merge into the union of +the declared types, and an empty array declares no types. It MUST be applied +to a location whose value is an object, unless the location is promoted to an +identified node with `x-jsonld-self`. #### 3.2.3. `x-jsonld-reverse` @@ -85,8 +91,8 @@ This keyword declares a reverse predicate IRI, asserting the edge with the annotated instance location as the subject and the enclosing node as the object, emitted through the JSON-LD `@reverse` keyword. The annotated location MUST materialize as a node or as an array of nodes, as a literal cannot be the -subject of an edge. The placement requirements of `x-jsonld-id` also apply to -this keyword. +subject of an edge. The placement and merging behavior of `x-jsonld-id` also +apply to this keyword. #### 3.2.4. `x-jsonld-datatype` @@ -106,7 +112,9 @@ tag [RFC5646]. 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. +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. #### 3.2.6. `x-jsonld-direction` diff --git a/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h b/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h index 993b0c2c7..8a1776bbd 100644 --- a/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h +++ b/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h @@ -88,16 +88,26 @@ auto add(SchemaTransformer &bundle, const AlterSchemaMode mode) -> void; /// @ingroup alterschema /// -/// A linter rule driven by a JSON Schema. Every subschema in the document -/// under inspection is validated as a JSON instance against the provided -/// rule schema. When a subschema does not conform, the rule fires and -/// reports the validation errors. The rule name is extracted from the -/// `title` keyword of the rule schema, and the rule description from the -/// `description` keyword. The title must consist only of lowercase ASCII -/// letters, digits, underscores, or slashes. +/// A linter rule driven by a JSON Schema. By default, every subschema in +/// the document under inspection is validated as a JSON instance against +/// the provided rule schema, though a rule may be scoped to only run +/// against the document root. When a subschema does not conform, the rule +/// fires and reports the validation errors. The rule name is extracted +/// from the `title` keyword of the rule schema, and the rule description +/// from the `description` keyword. The title must consist only of +/// lowercase ASCII letters, digits, underscores, or slashes. class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final : public SchemaTransformRule { public: + /// The locations of the schema that the rule applies to + enum class Scope : std::uint8_t { + /// Every subschema of the document under inspection + All, + + /// Only the document root + TopLevel + }; + using mutates = std::false_type; using reframe_after_transform = std::false_type; SchemaRule(const sourcemeta::core::JSON &schema, @@ -105,7 +115,8 @@ class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final const sourcemeta::blaze::SchemaResolver &resolver, const Compiler &compiler, const std::string_view default_dialect = "", - const std::optional &tweaks = std::nullopt); + const std::optional &tweaks = std::nullopt, + const Scope scope = Scope::All); [[nodiscard]] auto condition(const sourcemeta::core::JSON &, const sourcemeta::core::JSON &, const sourcemeta::blaze::Vocabularies &, @@ -117,6 +128,7 @@ class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final private: Template template_; + Scope scope_; }; #if defined(_MSC_VER) diff --git a/vendor/blaze/src/alterschema/schema_rule.cc b/vendor/blaze/src/alterschema/schema_rule.cc index 74072ac61..1610e82b8 100644 --- a/vendor/blaze/src/alterschema/schema_rule.cc +++ b/vendor/blaze/src/alterschema/schema_rule.cc @@ -68,19 +68,24 @@ SchemaRule::SchemaRule(const sourcemeta::core::JSON &schema, const sourcemeta::blaze::SchemaResolver &resolver, const Compiler &compiler, const std::string_view default_dialect, - const std::optional &tweaks) + const std::optional &tweaks, const Scope scope) : SchemaTransformRule{extract_title(schema), extract_description(schema)}, template_{compile(schema, walker, resolver, compiler, Mode::Exhaustive, - default_dialect, "", "", tweaks)} {}; - -auto SchemaRule::condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &, - const sourcemeta::blaze::Vocabularies &, - const sourcemeta::blaze::SchemaFrame &, - const sourcemeta::blaze::SchemaFrame::Location &, - const sourcemeta::blaze::SchemaWalker &, - const sourcemeta::blaze::SchemaResolver &, - const bool) const -> SchemaTransformRule::Result { + default_dialect, "", "", tweaks)}, + scope_{scope} {}; + +auto SchemaRule::condition( + const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, + const sourcemeta::blaze::Vocabularies &, + const sourcemeta::blaze::SchemaFrame &, + const sourcemeta::blaze::SchemaFrame::Location &location, + const sourcemeta::blaze::SchemaWalker &, + const sourcemeta::blaze::SchemaResolver &, const bool) const + -> SchemaTransformRule::Result { + if (this->scope_ == Scope::TopLevel && !location.pointer.empty()) { + return false; + } + SimpleOutput output{schema}; Evaluator evaluator; const auto result{ diff --git a/vendor/blaze/src/canonicalizer/canonicalize.cc b/vendor/blaze/src/canonicalizer/canonicalize.cc index 95b425c48..e134c0db9 100644 --- a/vendor/blaze/src/canonicalizer/canonicalize.cc +++ b/vendor/blaze/src/canonicalizer/canonicalize.cc @@ -366,6 +366,7 @@ auto apply(const std::vector &rules, sourcemeta::core::JSON &schema, #include "rules/unevaluated_properties_to_additional_properties.h" #include "rules/unknown_keywords_prefix.h" #include "rules/unknown_local_ref.h" +#include "rules/unknown_type_names.h" #include "rules/unnecessary_allof_ref_wrapper_draft.h" #include "rules/unnecessary_extends_ref_wrapper.h" #include "rules/unsatisfiable_drop_validation.h" @@ -405,6 +406,7 @@ auto canonicalize(sourcemeta::core::JSON &schema, rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); + rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); diff --git a/vendor/blaze/src/canonicalizer/rules/unknown_type_names.h b/vendor/blaze/src/canonicalizer/rules/unknown_type_names.h new file mode 100644 index 000000000..1c6b05e65 --- /dev/null +++ b/vendor/blaze/src/canonicalizer/rules/unknown_type_names.h @@ -0,0 +1,66 @@ +class UnknownTypeNames final : public SchemaTransformRule { +public: + using reframe_after_transform = std::false_type; + UnknownTypeNames() : SchemaTransformRule{"unknown_type_names"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::blaze::Vocabularies &vocabularies, + const sourcemeta::blaze::SchemaFrame &, + const sourcemeta::blaze::SchemaFrame::Location &, + const sourcemeta::blaze::SchemaWalker &, + const sourcemeta::blaze::SchemaResolver &) const -> bool override { + ONLY_CONTINUE_IF(vocabularies.contains_any( + {Vocabularies::Known::JSON_Schema_2020_12_Validation, + Vocabularies::Known::JSON_Schema_2019_09_Validation, + Vocabularies::Known::JSON_Schema_Draft_7, + Vocabularies::Known::JSON_Schema_Draft_6, + Vocabularies::Known::JSON_Schema_Draft_4}) && + schema.is_object()); + + const auto *type{schema.try_at("type")}; + ONLY_CONTINUE_IF(type); + + // An unrecognised type name constrains nothing, so it is safe (and more + // canonical) to drop it: a scalar unknown name leaves no constraint at all, + // and an unknown name inside a union just does not contribute an + // alternative. `parse_schema_type` yields an empty set for a name it does + // not recognise + if (type->is_string()) { + return !parse_schema_type(*type).any(); + } + + if (type->is_array()) { + for (const auto &entry : type->as_array()) { + if (entry.is_string() && !parse_schema_type(entry).any()) { + return true; + } + } + } + + return false; + } + + auto transform(sourcemeta::core::JSON &schema) const -> void override { + if (schema.at("type").is_string()) { + schema.erase("type"); + return; + } + + auto recognised{sourcemeta::core::JSON::make_array()}; + for (const auto &entry : schema.at("type").as_array()) { + if (entry.is_string() && !parse_schema_type(entry).any()) { + continue; + } + + recognised.push_back(entry); + } + + if (recognised.empty()) { + schema.erase("type"); + } else { + schema.at("type").into(std::move(recognised)); + } + } +}; diff --git a/vendor/blaze/src/compiler/compile_helpers.h b/vendor/blaze/src/compiler/compile_helpers.h index 0844fa97c..2b0aaf982 100644 --- a/vendor/blaze/src/compiler/compile_helpers.h +++ b/vendor/blaze/src/compiler/compile_helpers.h @@ -11,6 +11,7 @@ #include // std::distance #include // std::optional #include // std::regex, std::regex_match, std::smatch +#include // std::out_of_range #include // std::declval, std::move namespace sourcemeta::blaze { @@ -159,14 +160,24 @@ inline auto rephrase(const Context &context, const InstructionIndex type, .extra_index = extra_index}; } +// Note that the size keywords that let the type level fuse their bound accept +// any integral number, including reals with no fractional part like `2.0`, so +// this must recognise those too or the fused bound would be silently dropped inline auto unsigned_integer_property(const sourcemeta::core::JSON &document, const sourcemeta::core::JSON::String &property) -> std::optional { - if (document.defines(property) && document.at(property).is_integer()) { - const auto value{document.at(property).to_integer()}; - assert(value >= 0); - return static_cast(value); + if (document.defines(property) && document.at(property).is_integral()) { + // A real or decimal may spell an integer too large to represent, and such a + // bound sits so far beyond any instance that we ignore it, just as a + // non-integral bound is ignored, rather than let the conversion raise + try { + const auto value{document.at(property).as_integer()}; + assert(value >= 0); + return static_cast(value); + } catch (const std::out_of_range &) { + return std::nullopt; + } } return std::nullopt; diff --git a/vendor/blaze/src/compiler/default_compiler_2019_09.h b/vendor/blaze/src/compiler/default_compiler_2019_09.h index 0a4d7017b..81fd43bc4 100644 --- a/vendor/blaze/src/compiler/default_compiler_2019_09.h +++ b/vendor/blaze/src/compiler/default_compiler_2019_09.h @@ -144,10 +144,10 @@ auto compiler_2019_09_applicator_contains_with_options( } } - if (maximum.has_value() && minimum > maximum.value()) { - return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, - schema_context, dynamic_context, ValueNone{})}; - } + // An unsatisfiable range is deliberately left for the general check below, + // which rejects every array yet leaves non-arrays alone. An unconditional + // failure here would instead reject non-arrays too, but `contains` does not + // apply to them if (minimum == 0 && !maximum.has_value() && !track_evaluation) { return {}; @@ -175,8 +175,12 @@ auto compiler_2019_09_applicator_contains_with_options( schema_context, relative_dynamic_context(), ValuePointer{})); } - if (children.empty()) { - // We still need to check the instance is not empty + // A trivial subschema matches every element, so the number of matches + // equals the array size and the constraint reduces to a size bound. The + // plain "at least one match" case has a cheap single-instruction form, but + // any higher minimum or any maximum needs the general range check, which + // handles an empty subschema correctly on its own + if (children.empty() && minimum == 1 && !maximum.has_value()) { return {make(sourcemeta::blaze::InstructionIndex::AssertionArraySizeGreater, context, schema_context, dynamic_context, ValueUnsignedInteger{0})}; @@ -293,8 +297,12 @@ auto compiler_2019_09_applicator_unevaluateditems( return {}; } - return {make(sourcemeta::blaze::InstructionIndex::Evaluate, context, - schema_context, dynamic_context, ValueNone{})}; + // An unconditional marker would record this instance as evaluated whatever + // its type, so against a non-array it would wrongly suppress a sibling + // `unevaluatedProperties`. An array-guarded marker only records arrays + return {make(sourcemeta::blaze::InstructionIndex::LoopItemsUnevaluated, + context, schema_context, dynamic_context, ValueNone{}, + Instructions{})}; } // TODO: Attempt to short-circuit evaluation tracking by looking at sibling @@ -391,8 +399,12 @@ auto compiler_2019_09_applicator_unevaluatedproperties( } if (children.empty()) { - return {make(sourcemeta::blaze::InstructionIndex::Evaluate, context, - schema_context, dynamic_context, ValueNone{})}; + // An unconditional marker would record this instance as evaluated whatever + // its type, so against a non-object it would wrongly suppress a sibling + // `unevaluatedItems`. An object-guarded marker only records objects + return {make(sourcemeta::blaze::InstructionIndex::LoopPropertiesEvaluate, + context, schema_context, dynamic_context, ValueNone{}, + Instructions{})}; } else if (!filter_strings.empty() || !filter_prefixes.empty() || !filter_regexes.empty()) { return {make( diff --git a/vendor/blaze/src/compiler/default_compiler_draft3.h b/vendor/blaze/src/compiler/default_compiler_draft3.h index 51d8df52d..b65c0620a 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft3.h +++ b/vendor/blaze/src/compiler/default_compiler_draft3.h @@ -232,7 +232,15 @@ auto compile_required_assertions(const Context &context, types.insert(std::get(property.second.front().value)); } - if (types.size() == 1) { + // `properties` only collapses into the fused form that also + // enforces these requirements when it is neither emitting + // annotations nor tracking evaluation, so this must mirror both of + // its gates. Note that we must ask that of `properties` rather than + // of this keyword, as evaluation tracking follows the keywords an + // `unevaluatedProperties` depends on, which `required` is not one of + if (types.size() == 1 && + !annotations_enabled(context, KEYWORD_PROPERTIES) && + !requires_evaluation(context, new_schema_context)) { // Handled in `properties` return {}; } @@ -614,8 +622,11 @@ auto compiler_draft3_applicator_properties_with_options( auto properties{compile_properties(context, schema_context, effective_dynamic_context, current)}; + // These fused forms collapse every property into a single instruction that + // does not mark the properties it matches as evaluated, so under evaluation + // tracking we must fall through to the form that emits the markers if (!emit_annotation && context.mode == Mode::FastValidation && - !required.empty() && + !track_evaluation && !required.empty() && schema_context.schema.defines("additionalProperties") && schema_context.schema.at("additionalProperties").is_boolean() && !schema_context.schema.at("additionalProperties").to_boolean() && @@ -856,15 +867,56 @@ auto compiler_draft3_applicator_properties_with_options( } } - if (fusion_possible && substeps.size() >= 2 && - std::ranges::any_of(substeps, [](const auto &step) -> auto { - return step.type == - InstructionIndex::AssertionObjectPropertiesSimple; - })) { - std::erase_if(substeps, [](const auto &step) -> auto { + // A fused check rejects a non-object and enforces the presence of the + // properties it marks as required, but only those, and only at its own + // instance location. This list is flattened and may also hold checks + // merged in from elsewhere, as every `allOf` branch contributes to it, + // so we may only drop what the fusion provably covers + std::vector< + std::pair>> + fusions; + if (fusion_possible && substeps.size() >= 2) { + for (const auto &step : substeps) { + if (step.type != InstructionIndex::AssertionObjectPropertiesSimple) { + continue; + } + + auto match{ + std::ranges::find_if(fusions, [&step](const auto &entry) -> bool { + return entry.first == step.relative_instance_location; + })}; + if (match == fusions.end()) { + fusions.emplace_back(step.relative_instance_location, + std::unordered_set{}); + match = std::prev(fusions.end()); + } + + for (const auto &entry : + std::get(step.value)) { + if (std::get<2>(entry)) { + match->second.insert(std::get<0>(entry)); + } + } + } + } + + if (!fusions.empty()) { + std::erase_if(substeps, [&fusions](const auto &step) -> auto { + const auto fusion{ + std::ranges::find_if(fusions, [&step](const auto &entry) -> bool { + return entry.first == step.relative_instance_location; + })}; + if (fusion == fusions.cend()) { + return false; + } + if (step.type == InstructionIndex::AssertionDefinesAllStrict || step.type == InstructionIndex::AssertionDefinesAll) { - return true; + const auto &value{std::get(step.value)}; + return std::ranges::all_of( + value, [&fusion](const auto &property) -> auto { + return fusion->second.contains(property.first); + }); } if ((step.type == InstructionIndex::AssertionTypeStrict || @@ -878,9 +930,23 @@ auto compiler_draft3_applicator_properties_with_options( }); } - if (fusion_possible && substeps.size() == 1 && + // Fusion re-applies the single check to every property of the enclosing + // loop with its instance location cleared, so a check whose meaning + // depends on its own instance location cannot be relocated this way. A + // jump into a separate target, or a check that navigates into or gates + // on a nested location such as the one a `properties` conditional + // produces, therefore falls back to unfused emission + const bool fusable_single{ + substeps.size() == 1 && substeps.front().type != InstructionIndex::ControlJump && - substeps.front().type != InstructionIndex::ControlDynamicAnchorJump) { + substeps.front().type != InstructionIndex::ControlDynamicAnchorJump && + substeps.front().type != InstructionIndex::ControlGroup && + substeps.front().type != InstructionIndex::ControlGroupWhenDefines && + substeps.front().type != + InstructionIndex::ControlGroupWhenDefinesDirect && + substeps.front().type != InstructionIndex::ControlGroupWhenType && + substeps.front().type != InstructionIndex::ControlEvaluate}; + if (fusion_possible && fusable_single) { const auto is_required{assume_object && required.contains(name)}; auto prop{make_property(name)}; auto fusion_child{substeps.front()}; @@ -1195,14 +1261,28 @@ auto compiler_draft3_applicator_additionalproperties_with_options( return {}; } - // When `additionalProperties: false` with only `properties` (no - // patternProperties), and `properties` is compiled as a loop - // (LoopPropertiesMatchClosed), that loop already handles rejecting unknown - // properties, so we don't need to emit anything for `additionalProperties` - if (context.mode == Mode::FastValidation && children.size() == 1 && + // The closed forms that the elisions below rely on are only emitted when + // `additionalProperties` is the boolean `false`. Testing the compiled shape + // alone is not enough, as other subschemas that reject everything, like an + // empty `enum`, also reduce to an unconditional failure without closing + // anything + const auto rejects_with_boolean_false{ + schema_context.schema.at(dynamic_context.keyword).is_boolean() && + !schema_context.schema.at(dynamic_context.keyword).to_boolean()}; + + // When `additionalProperties: false` sits with `properties` alone and + // `properties` compiles to its closed form, that form already rejects + // unknown properties, so `additionalProperties` need emit nothing. Both + // evaluation tracking and a defined `patternProperties` hold `properties` + // back to its non-closed form, in which case the closure must still be + // emitted here. An empty `patternProperties` contributes no property filters + // yet still counts as defined, so its mere presence, not the filters it + // yields, is what matters + if (context.mode == Mode::FastValidation && !track_evaluation && + rejects_with_boolean_false && children.size() == 1 && children.front().type == InstructionIndex::AssertionFail && - !filter_strings.empty() && filter_prefixes.empty() && - filter_regexes.empty() && + !filter_strings.empty() && + !schema_context.schema.defines("patternProperties") && properties_as_loop(context, schema_context, schema_context.schema.at("properties"))) { return {}; @@ -1229,9 +1309,12 @@ auto compiler_draft3_applicator_additionalproperties_with_options( } } + // Similarly, `patternProperties` only compiles to its closed form, which + // rejects unmatched properties on its own, when `additionalProperties` is + // the boolean `false` if (context.mode == Mode::FastValidation && filter_strings.empty() && filter_prefixes.empty() && filter_regexes.size() == 1 && - !track_evaluation && !children.empty() && + !track_evaluation && rejects_with_boolean_false && !children.empty() && children.front().type == InstructionIndex::AssertionFail) { return {}; } @@ -1251,11 +1334,11 @@ auto compiler_draft3_applicator_additionalproperties_with_options( std::move(filter_regexes)}, std::move(children))}; } else if (track_evaluation) { - if (children.empty()) { - return {make(sourcemeta::blaze::InstructionIndex::Evaluate, context, - schema_context, dynamic_context, ValueNone{})}; - } - + // An unconditional marker records this instance as evaluated whatever its + // type, so when this schema is applied to a non-object, such as a + // conditional branch evaluated against an array, it would wrongly mark that + // array evaluated and suppress a sibling `unevaluatedItems`. An + // object-guarded marker only records objects, and needs no children return {make(sourcemeta::blaze::InstructionIndex::LoopPropertiesEvaluate, context, schema_context, dynamic_context, ValueNone{}, std::move(children))}; @@ -1758,8 +1841,11 @@ auto compiler_draft3_validation_maxlength(const Context &context, return {}; } - // We'll handle it at the type level as an optimization + // We'll handle it at the type level as an optimization. Note that the type + // compiler bails out before it reads these bounds when it is compiling a + // property name, so there we must still emit them ourselves if (context.mode == Mode::FastValidation && + !schema_context.is_property_name && schema_context.schema.defines("type") && schema_context.schema.at("type").is_string() && schema_context.schema.at("type").to_string() == "string") { @@ -1792,8 +1878,11 @@ auto compiler_draft3_validation_minlength(const Context &context, return {}; } - // We'll handle it at the type level as an optimization + // We'll handle it at the type level as an optimization. Note that the type + // compiler bails out before it reads these bounds when it is compiling a + // property name, so there we must still emit them ourselves if (context.mode == Mode::FastValidation && + !schema_context.is_property_name && schema_context.schema.defines("type") && schema_context.schema.at("type").is_string() && schema_context.schema.at("type").to_string() == "string") { @@ -2092,6 +2181,12 @@ auto compiler_draft3_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxProperties")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, + ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeObjectUpper, @@ -2116,8 +2211,13 @@ auto compiler_draft3_validation_type(const Context &context, return {}; } + // A non-empty `required` rejects a non-object on its own, so the type + // assertion is redundant. An empty `required` asserts nothing, so the + // type check must stay if (!is_draft3 && context.mode == Mode::FastValidation && - schema_context.schema.defines("required")) { + schema_context.schema.defines("required") && + schema_context.schema.at("required").is_array() && + !schema_context.schema.at("required").empty()) { return {}; } @@ -2131,6 +2231,11 @@ auto compiler_draft3_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxItems")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return { make(sourcemeta::blaze::InstructionIndex::AssertionTypeArrayUpper, @@ -2195,6 +2300,11 @@ auto compiler_draft3_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxLength")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeStringUpper, @@ -2286,8 +2396,16 @@ auto compiler_draft3_validation_type(const Context &context, } if (!types.any()) { - return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, - schema_context, dynamic_context, ValueNone{})}; + // An empty array names no type at all, so no value can match it. A + // non-empty one that named only unrecognised types is an invalid but + // legitimate use of the keyword that we ignore, constraining nothing, + // exactly as the scalar form does for a single unrecognised name + if (value.empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + + return {}; } return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeStrictAny, diff --git a/vendor/blaze/src/compiler/default_compiler_draft6.h b/vendor/blaze/src/compiler/default_compiler_draft6.h index 25a1d754e..f4ee50323 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft6.h +++ b/vendor/blaze/src/compiler/default_compiler_draft6.h @@ -16,6 +16,40 @@ auto compiler_draft6_validation_type(const Context &context, const DynamicContext &dynamic_context, const Instructions ¤t) -> Instructions { + // Every property name is a string, so inside `propertyNames` this keyword + // is decidable at compile time. Note that the evaluator applies such a + // subschema to a null placeholder and carries the name out of band, so a + // type assertion emitted here would test that placeholder rather than the + // name. The failure is emitted per name, as these instructions become the + // children of the loop over the keys, so an object with no properties + // still passes + if (schema_context.is_property_name) { + const auto types{sourcemeta::blaze::parse_schema_type( + schema_context.schema.at(dynamic_context.keyword))}; + if (types.test(std::to_underlying(sourcemeta::core::JSON::Type::String))) { + return {}; + } + + // A set that names known types, none of them a string, can never match a + // property name, so every name fails + if (types.any()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, + schema_context, dynamic_context, ValueNone{})}; + } + + // No known type was named. An empty array names nothing at all, so no name + // can match it, whereas an unrecognised name is an invalid but legitimate + // use that constrains nothing and is ignored, matching how the forms below + // treat both outside `propertyNames` + if (schema_context.schema.at(dynamic_context.keyword).is_array() && + schema_context.schema.at(dynamic_context.keyword).empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, + schema_context, dynamic_context, ValueNone{})}; + } + + return {}; + } + if (schema_context.schema.at(dynamic_context.keyword).is_string()) { const auto &type{ schema_context.schema.at(dynamic_context.keyword).to_string()}; @@ -64,6 +98,11 @@ auto compiler_draft6_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxProperties")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeObjectUpper, @@ -92,8 +131,13 @@ auto compiler_draft6_validation_type(const Context &context, return {}; } + // A non-empty `required` rejects a non-object on its own, so the type + // assertion is redundant. An empty `required` asserts nothing, so the + // type check must stay if (context.mode == Mode::FastValidation && - schema_context.schema.defines("required")) { + schema_context.schema.defines("required") && + schema_context.schema.at("required").is_array() && + !schema_context.schema.at("required").empty()) { return {}; } @@ -101,15 +145,25 @@ auto compiler_draft6_validation_type(const Context &context, context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::Object)}; } else if (type == "array") { - if (context.mode == Mode::FastValidation && !current.empty() && + const auto minimum{ + unsigned_integer_property(schema_context.schema, "minItems", 0)}; + const auto maximum{ + unsigned_integer_property(schema_context.schema, "maxItems")}; + + // A preceding array loop at this location already rejects a non-array, + // so the type assertion is redundant. The array size bounds are only + // redundant when there are none to enforce, as the loops other than the + // sized one do not check the item count. Only a loop that truly rejects + // a non-array qualifies: some item loops instead pass a non-array + // untouched, so they are deliberately excluded below + if (context.mode == Mode::FastValidation && minimum == 0 && + !maximum.has_value() && !current.empty() && (current.back().type == sourcemeta::blaze::InstructionIndex:: LoopItemsPropertiesExactlyTypeStrictHash || current.back().type == sourcemeta::blaze::InstructionIndex:: LoopItemsPropertiesExactlyTypeStrictHash3 || - current.back().type == - sourcemeta::blaze::InstructionIndex::LoopItemsIntegerBounded || current.back().type == sourcemeta::blaze::InstructionIndex:: LoopItemsIntegerBoundedSized) && current.back().relative_instance_location == @@ -117,12 +171,12 @@ auto compiler_draft6_validation_type(const Context &context, return {}; } - const auto minimum{ - unsigned_integer_property(schema_context.schema, "minItems", 0)}; - const auto maximum{ - unsigned_integer_property(schema_context.schema, "maxItems")}; - if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return { make(sourcemeta::blaze::InstructionIndex::AssertionTypeArrayUpper, @@ -196,16 +250,17 @@ auto compiler_draft6_validation_type(const Context &context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::Integer)}; } else if (type == "string") { - if (schema_context.is_property_name) { - return {}; - } - const auto minimum{ unsigned_integer_property(schema_context.schema, "minLength", 0)}; const auto maximum{ unsigned_integer_property(schema_context.schema, "maxLength")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeStringUpper, @@ -275,10 +330,6 @@ auto compiler_draft6_validation_type(const Context &context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::Integer)}; } else if (type == "string") { - if (schema_context.is_property_name) { - return {}; - } - return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeStrict, context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::String)}; @@ -306,15 +357,23 @@ auto compiler_draft6_validation_type(const Context &context, } else if (type_string == "integer") { types.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); } else if (type_string == "string") { - if (schema_context.is_property_name) { - continue; - } - types.set(std::to_underlying(sourcemeta::core::JSON::Type::String)); } } - assert(types.any()); + if (types.none()) { + // An empty array names no type at all, so no value can match it. A + // non-empty one that named only unrecognised types is an invalid but + // legitimate use of the keyword that we ignore, constraining nothing, + // exactly as the scalar form does for a single unrecognised name + if (schema_context.schema.at(dynamic_context.keyword).empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + + return {}; + } + return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeAny, context, schema_context, dynamic_context, types)}; } diff --git a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h index aa5b0e955..8eb8cf5e8 100644 --- a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h +++ b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h @@ -22,7 +22,8 @@ namespace sourcemeta::blaze { struct SchemaUnevaluatedEntry { /// The absolute pointers of the static keyword dependencies std::set static_dependencies; - /// The absolute pointers of the static keyword dependencies + /// The absolute pointers of the dynamic keyword dependencies, along with the + /// references that lead to them std::set dynamic_dependencies; /// Whether the entry cannot be fully resolved, which means /// there might be unknown dynamic dependencies diff --git a/vendor/blaze/src/compiler/postprocess.h b/vendor/blaze/src/compiler/postprocess.h index 70504705a..8c69f88c1 100644 --- a/vendor/blaze/src/compiler/postprocess.h +++ b/vendor/blaze/src/compiler/postprocess.h @@ -7,6 +7,7 @@ #include #include #include // std::string +#include // std::tuple #include #include // std::unordered_set #include @@ -32,15 +33,18 @@ inline auto is_noop_without_children(const InstructionIndex type) noexcept case InstructionIndex::LogicalWhenArraySizeGreater: case InstructionIndex::LoopPropertiesMatch: case InstructionIndex::LoopProperties: - case InstructionIndex::LoopPropertiesEvaluate: case InstructionIndex::LoopPropertiesRegex: case InstructionIndex::LoopPropertiesStartsWith: case InstructionIndex::LoopPropertiesExcept: case InstructionIndex::LoopKeys: case InstructionIndex::LoopItems: case InstructionIndex::LoopItemsFrom: - case InstructionIndex::LoopItemsUnevaluated: - case InstructionIndex::LoopContains: + // Deliberately absent are the loops that still do something with no + // children: the one carrying a match count still asserts that the array + // size falls within that range, and the ones that mark their instance + // location as evaluated still carry that side effect for a later + // `unevaluatedProperties` or `unevaluatedItems` to consult. Dropping any of + // them would discard those semantics case InstructionIndex::ControlGroupWhenDefines: case InstructionIndex::ControlGroupWhenDefinesDirect: case InstructionIndex::ControlGroupWhenType: @@ -103,34 +107,59 @@ inline auto duplicate_metadata(Instruction &instruction, } } -inline auto rebase(Instruction &instruction, - std::vector &extra, - const sourcemeta::core::Pointer &schema_prefix, - const sourcemeta::core::Pointer &instance_prefix) -> void { +// Whether an instruction relays its children without pushing its own schema +// location onto the evaluation path, leaving them anchored on its own parent +inline auto is_pass_through_instruction(const InstructionIndex type) noexcept + -> bool { + switch (type) { + case InstructionIndex::ControlGroup: + case InstructionIndex::ControlGroupWhenDefines: + case InstructionIndex::ControlGroupWhenDefinesDirect: + case InstructionIndex::ControlGroupWhenType: + case InstructionIndex::ControlEvaluate: + return true; + default: + return false; + } +} + +// Prefix every instruction that anchors its schema location on the point an +// inlined target is spliced into. That is the top instruction itself, plus +// the instructions that evaluation reaches without a location of their own in +// between: the consequence children of a conditional, which re-anchor on the +// conditional's parent, and the children of pass-through instructions. +// Condition children and ordinary nested children stay relative to their +// parent instruction and must be left alone +inline auto rebase_anchored(Instruction &instruction, + std::vector &extra, + const sourcemeta::core::Pointer &schema_prefix) + -> void { extra[instruction.extra_index].relative_schema_location = schema_prefix.concat( extra[instruction.extra_index].relative_schema_location); - instruction.relative_instance_location = - instance_prefix.concat(instruction.relative_instance_location); if (instruction.type == InstructionIndex::LogicalCondition) { const auto &value{std::get(instruction.value)}; - const auto then_cursor{value.first}; - // TODO(C++23): Use std::views::enumerate when available in libc++ - for (std::size_t index = then_cursor; index < instruction.children.size(); + for (std::size_t index = value.first; index < instruction.children.size(); ++index) { - auto &child{instruction.children[index]}; - extra[child.extra_index].relative_schema_location = schema_prefix.concat( - extra[child.extra_index].relative_schema_location); - for (auto &grandchild : child.children) { - extra[grandchild.extra_index].relative_schema_location = - schema_prefix.concat( - extra[grandchild.extra_index].relative_schema_location); - } + rebase_anchored(instruction.children[index], extra, schema_prefix); + } + } else if (is_pass_through_instruction(instruction.type)) { + for (auto &child : instruction.children) { + rebase_anchored(child, extra, schema_prefix); } } } +inline auto rebase(Instruction &instruction, + std::vector &extra, + const sourcemeta::core::Pointer &schema_prefix, + const sourcemeta::core::Pointer &instance_prefix) -> void { + instruction.relative_instance_location = + instance_prefix.concat(instruction.relative_instance_location); + rebase_anchored(instruction, extra, schema_prefix); +} + inline auto collect_statistics(const Instructions &instructions, TargetStatistics &statistics) -> void { for (const auto &instruction : instructions) { @@ -156,8 +185,12 @@ transform_instruction(Instruction &instruction, Instructions &output, const std::vector &targets, const std::vector &statistics, TargetStatistics ¤t_stats, const Tweaks &tweaks, - const bool uses_dynamic_scopes) -> bool { - if (instruction.type == InstructionIndex::ControlJump) { + const bool uses_dynamic_scopes, const bool positional) + -> bool { + // A positional owner pairs each child with an entry of its own data by + // index, and inlining a jump expands one child into many, which would + // re-pair the rest + if (!positional && instruction.type == InstructionIndex::ControlJump) { const auto jump_target_index{ std::get(instruction.value)}; const auto &jump_target_stats{statistics[jump_target_index]}; @@ -380,13 +413,12 @@ inline auto postprocess(std::vector &targets, auto &target{targets[current_target_index]}; auto ¤t_stats{statistics[current_target_index]}; - std::vector worklist; - std::vector> stack; - stack.emplace_back(&target, 0); + std::vector> worklist; + std::vector> stack; + stack.emplace_back(&target, 0, nullptr); while (!stack.empty()) { - auto current{stack.back().first}; - auto index{stack.back().second}; + auto [current, index, owner] = stack.back(); stack.pop_back(); while (index < current->size() && (*current)[index].children.empty()) { @@ -394,38 +426,114 @@ inline auto postprocess(std::vector &targets, } if (index < current->size()) { - stack.emplace_back(current, index + 1); - stack.emplace_back(&(*current)[index].children, 0); + stack.emplace_back(current, index + 1, owner); + stack.emplace_back(&(*current)[index].children, 0, + &(*current)[index]); } else { - worklist.push_back(current); + worklist.emplace_back(current, owner); } } - for (auto *current : worklist) { + for (const auto &[current, owner] : worklist) { Instructions result; result.reserve(current->size()); - std::unordered_set fusion_covered_properties; - for (const auto &instruction : *current) { - if (instruction.type == - InstructionIndex::AssertionObjectPropertiesSimple) { - const auto &entries{ - std::get(instruction.value)}; - for (const auto &entry : entries) { - fusion_covered_properties.insert(std::get<0>(entry)); + // A conditional stores the indices where its branches begin, so when + // this pass drops or expands any child, those indices must be remapped + // onto the rewritten list + const bool remap{owner != nullptr && + owner->type == InstructionIndex::LogicalCondition}; + std::vector boundaries; + if (remap) { + boundaries.reserve(current->size() + 1); + } + + // The children of a conditional span its mutually exclusive + // condition, consequence, and alternative segments, and the children + // of a disjunction are alternative branches, so an instruction in one + // of them cannot subsume an instruction in another. Every other + // children list is a conjunction evaluated as a whole, where + // subsumption holds + // A fused simple-properties instruction pairs its entries with its + // children by index, so its children may neither be dropped, expanded, + // nor subsumed by each other, as each one applies to a different + // property despite them all sharing an empty instance location + const bool positional{ + owner != nullptr && + owner->type == InstructionIndex::AssertionObjectPropertiesSimple}; + + const bool disjoint{ + positional || (owner != nullptr && + (owner->type == InstructionIndex::LogicalCondition || + owner->type == InstructionIndex::LogicalOr || + owner->type == InstructionIndex::LogicalXor))}; + + // A fused simple-properties instruction only enforces that a property + // is present for the entries it marks as required, so only those may + // subsume a sibling presence assertion. Every fused instruction, on + // the other hand, rejects a non-object outright, so any of them can + // subsume a sibling object type assertion. None of that says anything + // about the rest of the instance, so a fused instruction may only + // subsume siblings that apply to the very same instance location + std::vector>> + fusions; + if (!disjoint) { + for (const auto &instruction : *current) { + if (instruction.type != + InstructionIndex::AssertionObjectPropertiesSimple) { + continue; + } + + auto match{std::ranges::find_if( + fusions, [&instruction](const auto &entry) -> bool { + return entry.first == instruction.relative_instance_location; + })}; + if (match == fusions.end()) { + fusions.emplace_back(instruction.relative_instance_location, + std::unordered_set{}); + match = std::prev(fusions.end()); + } + + for (const auto &entry : + std::get(instruction.value)) { + if (std::get<2>(entry)) { + match->second.insert(std::get<0>(entry)); + } } } } + // A positional owner pairs child N with entry N of its own data, and + // every entry past the last child only carries a presence requirement. + // Dropping a child therefore means moving its entry onto that tail, so + // the entries left in front stay paired with the children that survived + std::vector surviving; + std::vector dropped; + std::size_t child_index{0}; + for (auto &instruction : *current) { - if (!fusion_covered_properties.empty()) { + if (remap) { + boundaries.push_back(result.size()); + } + + const auto result_size{result.size()}; + const auto positional_index{child_index}; + child_index += 1; + + const auto fusion{std::ranges::find_if( + fusions, [&instruction](const auto &entry) -> bool { + return entry.first == instruction.relative_instance_location; + })}; + + if (fusion != fusions.cend()) { switch (instruction.type) { case InstructionIndex::AssertionDefinesAllStrict: case InstructionIndex::AssertionDefinesAll: { const auto &value{std::get(instruction.value)}; bool all_covered{true}; for (const auto &property : value) { - if (!fusion_covered_properties.contains(property.first)) { + if (!fusion->second.contains(property.first)) { all_covered = false; break; } @@ -439,7 +547,7 @@ inline auto postprocess(std::vector &targets, case InstructionIndex::AssertionDefinesStrict: case InstructionIndex::AssertionDefines: { const auto &value{std::get(instruction.value)}; - if (fusion_covered_properties.contains(value.first)) { + if (fusion->second.contains(value.first)) { changed = true; continue; } @@ -460,8 +568,59 @@ inline auto postprocess(std::vector &targets, if (transform_instruction(instruction, result, extra, targets, statistics, current_stats, tweaks, - uses_dynamic_scopes)) + uses_dynamic_scopes, positional)) changed = true; + + if (positional) { + if (result.size() == result_size) { + dropped.push_back(positional_index); + } else { + surviving.push_back(positional_index); + } + } + } + + if (positional && !dropped.empty()) { + auto &entries{std::get(owner->value)}; + ValueObjectProperties reordered; + for (const auto index : surviving) { + reordered.push_back(entries[index]); + } + + for (const auto index : dropped) { + reordered.push_back(entries[index]); + } + + for (auto index = child_index; index < entries.size(); index++) { + reordered.push_back(entries[index]); + } + + entries = std::move(reordered); + } + + if (remap) { + boundaries.push_back(result.size()); + auto &cursors{std::get(owner->value)}; + const auto then_start{boundaries[cursors.first]}; + if (then_start == 0) { + // The condition dropped to nothing, so it is always true: the then + // branch applies unconditionally and the else branch is dead. Keep + // only the then instructions and record an empty condition with no + // else, which the evaluator runs as the consequence + const auto else_start{cursors.second > 0 + ? boundaries[cursors.second] + : result.size()}; + result.erase(result.begin() + + static_cast(else_start), + result.end()); + cursors.first = 0; + cursors.second = 0; + } else { + cursors.first = then_start; + if (cursors.second > 0) { + cursors.second = boundaries[cursors.second]; + } + } } *current = std::move(result); diff --git a/vendor/blaze/src/compiler/unevaluated.cc b/vendor/blaze/src/compiler/unevaluated.cc index 89333ab0c..dfcd67f06 100644 --- a/vendor/blaze/src/compiler/unevaluated.cc +++ b/vendor/blaze/src/compiler/unevaluated.cc @@ -54,9 +54,32 @@ auto find_adjacent_dependencies( frame.dereference(entry, make_weak_pointer(property.first))}; if (reference.first == SchemaReferenceType::Static && reference.second.has_value()) { + // Recurse into a dedicated entry so that whether this reference's + // target contributes any dynamic dependency can be read directly, + // rather than inferred from whether it grew the shared deduplicated + // set, which misses every reference after the first to a common + // target + SchemaUnevaluatedEntry nested; find_adjacent_dependencies( current, schema, frame, walker, resolver, keywords, root, - reference.second.value().get(), is_static, result); + reference.second.value().get(), is_static, nested); + + // Whatever the target contributes gets recorded at the location of + // the target itself, which tells the applicators this reference sits + // under nothing about it. Record the reference as a dependency of + // its own too, so that they can still tell that reaching through it + // leads to one, and therefore that they cannot short-circuit. Only + // the dynamic dependencies are consulted that way, whereas the + // static ones name the keyword locations that evaluate, which a + // reference is not one of + if (!is_static && !nested.dynamic_dependencies.empty()) { + result.dynamic_dependencies.emplace( + entry.pointer.concat(make_weak_pointer(property.first))); + } + + result.unresolved = result.unresolved || nested.unresolved; + result.static_dependencies.merge(nested.static_dependencies); + result.dynamic_dependencies.merge(nested.dynamic_dependencies); } else if (reference.first == SchemaReferenceType::Dynamic) { result.unresolved = true; } diff --git a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h index 4c772494a..db3f0fb9c 100644 --- a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h +++ b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h @@ -69,7 +69,12 @@ struct SOURCEMETA_BLAZE_CONFIGURATION_EXPORT Configuration { std::vector ignore; struct Lint { - std::vector rules; + struct Rule { + std::filesystem::path path; + bool top_level{false}; + }; + + std::vector rules; }; Lint lint; diff --git a/vendor/blaze/src/configuration/json.cc b/vendor/blaze/src/configuration/json.cc index 532af7ecc..c995697ba 100644 --- a/vendor/blaze/src/configuration/json.cc +++ b/vendor/blaze/src/configuration/json.cc @@ -105,8 +105,16 @@ auto Configuration::to_json() const -> sourcemeta::core::JSON { auto lint_object{sourcemeta::core::JSON::make_object()}; auto rules_array{sourcemeta::core::JSON::make_array()}; for (const auto &rule : this->lint.rules) { - rules_array.push_back( - sourcemeta::core::JSON{relative_display_path(rule, this->base_path)}); + if (rule.top_level) { + auto rule_object{sourcemeta::core::JSON::make_object()}; + rule_object.assign("path", sourcemeta::core::JSON{relative_display_path( + rule.path, this->base_path)}); + rule_object.assign("topLevel", sourcemeta::core::JSON{true}); + rules_array.push_back(std::move(rule_object)); + } else { + rules_array.push_back(sourcemeta::core::JSON{ + relative_display_path(rule.path, this->base_path)}); + } } lint_object.assign("rules", std::move(rules_array)); diff --git a/vendor/blaze/src/configuration/parse.cc b/vendor/blaze/src/configuration/parse.cc index 70b879b88..370272fb4 100644 --- a/vendor/blaze/src/configuration/parse.cc +++ b/vendor/blaze/src/configuration/parse.cc @@ -200,15 +200,33 @@ auto Configuration::from_json(const sourcemeta::core::JSON &value, std::size_t index{0}; for (const auto &element : lint_value.at("rules").as_array()) { CONFIGURATION_ENSURE( - element.is_string(), - "The values in the lint rules array must be strings", + element.is_string() || element.is_object(), + "The values in the lint rules array must be strings or objects", sourcemeta::core::Pointer({"lint", "rules", index})); - const std::filesystem::path path{element.to_string()}; + bool top_level{false}; + if (element.is_object()) { + CONFIGURATION_ENSURE( + element.defines("path") && element.at("path").is_string(), + "The lint rule path property must be a string", + sourcemeta::core::Pointer({"lint", "rules", index, "path"})); + CONFIGURATION_ENSURE( + !element.defines("topLevel") || + element.at("topLevel").is_boolean(), + "The lint rule topLevel property must be a boolean", + sourcemeta::core::Pointer({"lint", "rules", index, "topLevel"})); + top_level = element.defines("topLevel") && + element.at("topLevel").to_boolean(); + } + + const std::filesystem::path path{element.is_string() + ? element.to_string() + : element.at("path").to_string()}; result.lint.rules.push_back( - path.is_absolute() - ? sourcemeta::core::weakly_canonical(path) - : sourcemeta::core::weakly_canonical(base_path / path)); + {.path = path.is_absolute() + ? sourcemeta::core::weakly_canonical(path) + : sourcemeta::core::weakly_canonical(base_path / path), + .top_level = top_level}); index += 1; } } diff --git a/vendor/blaze/src/evaluator/evaluator_describe.cc b/vendor/blaze/src/evaluator/evaluator_describe.cc index 384c0b644..bfba8214f 100644 --- a/vendor/blaze/src/evaluator/evaluator_describe.cc +++ b/vendor/blaze/src/evaluator/evaluator_describe.cc @@ -329,20 +329,23 @@ auto describe(const bool valid, const Instruction &step, return "The constraints declared for this keyword were not satisfiable"; } - if (keyword == "additionalProperties" || - keyword == "unevaluatedProperties") { + // A `false` subschema declares no keyword of its own, so its evaluation + // path ends at whatever declared it, which under `properties` and its + // relatives is a name the author chose. A name that happens to read like + // one of the keywords below must not be taken for the keyword itself, so + // each of these only applies when the instance location agrees with it + if ((keyword == "additionalProperties" || + keyword == "unevaluatedProperties") && + !instance_location.empty() && instance_location.back().is_property()) { std::ostringstream message; - assert(!instance_location.empty()); - assert(instance_location.back().is_property()); message << "The object value was not expected to define the property " << escape_string(instance_location.back().to_property()); return message.str(); } - if (keyword == "unevaluatedItems") { + if (keyword == "unevaluatedItems" && !instance_location.empty() && + instance_location.back().is_index()) { std::ostringstream message; - assert(!instance_location.empty()); - assert(instance_location.back().is_index()); message << "The array value was not expected to define the item at index " << instance_location.back().to_index(); return message.str(); @@ -524,8 +527,10 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "title" || keyword == "description") { - assert(annotation.is_string()); + // Note that these annotation values come from the schema as they are + // written, and nothing forces them to have the type their keyword expects + if ((keyword == "title" || keyword == "description") && + annotation.is_string()) { std::ostringstream message; message << "The " << keyword << " of the"; if (instance_location.empty()) { @@ -613,8 +618,7 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "examples") { - assert(annotation.is_array()); + if (keyword == "examples" && annotation.is_array()) { std::ostringstream message; if (instance_location.empty()) { message << "Examples of the instance"; @@ -639,8 +643,7 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "contentEncoding") { - assert(annotation.is_string()); + if (keyword == "contentEncoding" && annotation.is_string()) { std::ostringstream message; message << "The content encoding of the"; if (instance_location.empty()) { @@ -655,8 +658,7 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "contentMediaType") { - assert(annotation.is_string()); + if (keyword == "contentMediaType" && annotation.is_string()) { std::ostringstream message; message << "The content media type of the"; if (instance_location.empty()) { @@ -720,6 +722,10 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-jsonld-id" && annotation.is_null()) { + return "The nested JSON-LD predicates were expected to be removed"; + } + if (keyword == "x-jsonld-reverse" && annotation.is_string()) { std::ostringstream message; message << "The reverse JSON-LD predicate was " @@ -727,6 +733,11 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-jsonld-reverse" && annotation.is_null()) { + return "The nested reverse JSON-LD predicates were expected to be " + "removed"; + } + if (keyword == "x-jsonld-type" && annotation.is_string()) { std::ostringstream message; message << "The JSON-LD type was " @@ -734,6 +745,10 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-jsonld-type" && annotation.is_null()) { + return "The nested JSON-LD types were expected to be removed"; + } + if (keyword == "x-jsonld-type" && annotation.is_array()) { if (annotation.empty()) { return "No JSON-LD types were assigned"; @@ -761,6 +776,10 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-jsonld-datatype" && annotation.is_null()) { + return "The JSON-LD datatype was expected to be removed"; + } + if (keyword == "x-jsonld-language" && annotation.is_string()) { std::ostringstream message; message << "The natural language was " @@ -768,6 +787,10 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-jsonld-language" && annotation.is_null()) { + return "The natural language was expected to be removed"; + } + if (keyword == "x-jsonld-direction" && annotation.is_string()) { std::ostringstream message; message << "The base direction was " @@ -775,8 +798,13 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "x-jsonld-json" && annotation.is_boolean()) { - if (annotation.to_boolean()) { + if (keyword == "x-jsonld-direction" && annotation.is_null()) { + return "The base direction was expected to be removed"; + } + + if (keyword == "x-jsonld-json" && + (annotation.is_boolean() || annotation.is_null())) { + if (annotation.is_boolean() && annotation.to_boolean()) { return "The value was expected to be treated as an opaque JSON " "literal"; } @@ -785,8 +813,9 @@ auto describe(const bool valid, const Instruction &step, "literal"; } - if (keyword == "x-jsonld-graph" && annotation.is_boolean()) { - if (annotation.to_boolean()) { + if (keyword == "x-jsonld-graph" && + (annotation.is_boolean() || annotation.is_null())) { + if (annotation.is_boolean() && annotation.to_boolean()) { return "The value was expected to be wrapped in a JSON-LD named graph"; } @@ -801,6 +830,10 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-jsonld-container" && annotation.is_null()) { + return "The JSON-LD container was expected to be removed"; + } + if (keyword == "x-jsonld-self" && annotation.is_string()) { std::ostringstream message; message << "The JSON-LD identifier template was " @@ -808,6 +841,20 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-jsonld-self" && annotation.is_null()) { + return "The JSON-LD identifier was expected to be removed"; + } + + if (keyword == "x-jsonld-override" && annotation.is_boolean()) { + if (annotation.to_boolean()) { + return "The sibling JSON-LD annotations were expected to override " + "the nested annotations they shadow"; + } + + return "The sibling JSON-LD annotations were not expected to override " + "any other annotations"; + } + std::ostringstream message; message << "The unrecognized keyword " << escape_string(keyword) << " was collected as the annotation "; @@ -896,12 +943,21 @@ auto describe(const bool valid, const Instruction &step, if (step.type == sourcemeta::blaze::InstructionIndex::LoopPropertiesEvaluate) { - assert(keyword == "additionalProperties"); + assert(keyword == "additionalProperties" || + keyword == "unevaluatedProperties"); std::ostringstream message; if (step.children.size() == 1 && step.children.front().type == InstructionIndex::AssertionFail) { - message << "The object value was not expected to define additional " - "properties"; + if (keyword == "unevaluatedProperties") { + message << "The object value was not expected to define unevaluated " + "properties"; + } else { + message << "The object value was not expected to define additional " + "properties"; + } + } else if (keyword == "unevaluatedProperties") { + message << "The object properties not covered by other object " + "keywords were expected to validate against this subschema"; } else { message << "The object properties not covered by other adjacent object " "keywords were expected to validate against this subschema"; diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h index 837a4ba57..3e0aecd9c 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h @@ -243,6 +243,20 @@ class SOURCEMETA_BLAZE_EVALUATOR_EXPORT Evaluator { } } + // A subschema that ends up failing must not contribute any of the marks it + // made along the way, so a disjunction records the length here before each + // branch and truncates back to it when the branch does not hold. Marks are + // only ever appended, never inserted earlier, so everything a branch adds + // sits past the recorded length and nothing from outside it can be lost + [[nodiscard]] auto checkpoint() const -> std::size_t { + return this->evaluated_.size(); + } + + auto rewind(const std::size_t checkpoint) -> void { + assert(checkpoint <= this->evaluated_.size()); + this->evaluated_.resize(checkpoint); + } + #if defined(_MSC_VER) #pragma warning(disable : 4251 4275) #endif diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h index 9ef01708b..14a3837eb 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h @@ -418,25 +418,46 @@ INSTRUCTION_HANDLER(AssertionDefinesExactlyStrictHash3) { assert(value.first.size() == 3); const auto &object{target.as_object()}; - result = - object.size() == 3 && ((value.first.at(0).first == object.at(0).hash && - value.first.at(1).first == object.at(1).hash && - value.first.at(2).first == object.at(2).hash) || - (value.first.at(0).first == object.at(0).hash && - value.first.at(1).first == object.at(2).hash && - value.first.at(2).first == object.at(1).hash) || - (value.first.at(0).first == object.at(1).hash && - value.first.at(1).first == object.at(0).hash && - value.first.at(2).first == object.at(2).hash) || - (value.first.at(0).first == object.at(1).hash && - value.first.at(1).first == object.at(2).hash && - value.first.at(2).first == object.at(0).hash) || - (value.first.at(0).first == object.at(2).hash && - value.first.at(1).first == object.at(0).hash && - value.first.at(2).first == object.at(1).hash) || - (value.first.at(0).first == object.at(2).hash && - value.first.at(1).first == object.at(1).hash && - value.first.at(2).first == object.at(0).hash)); + // A perfect hash captures the key bytes but not its length, so each + // pairing confirms the size alongside the hash while keeping the unrolled + // permutation check over the three keys + result = object.size() == 3 && + ((value.first.at(0).first == object.at(0).hash && + value.first.at(0).second.size() == object.at(0).first.size() && + value.first.at(1).first == object.at(1).hash && + value.first.at(1).second.size() == object.at(1).first.size() && + value.first.at(2).first == object.at(2).hash && + value.first.at(2).second.size() == object.at(2).first.size()) || + (value.first.at(0).first == object.at(0).hash && + value.first.at(0).second.size() == object.at(0).first.size() && + value.first.at(1).first == object.at(2).hash && + value.first.at(1).second.size() == object.at(2).first.size() && + value.first.at(2).first == object.at(1).hash && + value.first.at(2).second.size() == object.at(1).first.size()) || + (value.first.at(0).first == object.at(1).hash && + value.first.at(0).second.size() == object.at(1).first.size() && + value.first.at(1).first == object.at(0).hash && + value.first.at(1).second.size() == object.at(0).first.size() && + value.first.at(2).first == object.at(2).hash && + value.first.at(2).second.size() == object.at(2).first.size()) || + (value.first.at(0).first == object.at(1).hash && + value.first.at(0).second.size() == object.at(1).first.size() && + value.first.at(1).first == object.at(2).hash && + value.first.at(1).second.size() == object.at(2).first.size() && + value.first.at(2).first == object.at(0).hash && + value.first.at(2).second.size() == object.at(0).first.size()) || + (value.first.at(0).first == object.at(2).hash && + value.first.at(0).second.size() == object.at(2).first.size() && + value.first.at(1).first == object.at(0).hash && + value.first.at(1).second.size() == object.at(0).first.size() && + value.first.at(2).first == object.at(1).hash && + value.first.at(2).second.size() == object.at(1).first.size()) || + (value.first.at(0).first == object.at(2).hash && + value.first.at(0).second.size() == object.at(2).first.size() && + value.first.at(1).first == object.at(1).hash && + value.first.at(1).second.size() == object.at(1).first.size() && + value.first.at(2).first == object.at(0).hash && + value.first.at(2).second.size() == object.at(0).first.size())); } EVALUATE_END(AssertionDefinesExactlyStrictHash3); @@ -1042,9 +1063,13 @@ INSTRUCTION_HANDLER(AssertionObjectPropertiesSimple) { for (std::size_t schema_index = 0; schema_index < schema_size; schema_index++) { const auto &schema_hash{std::get<1>(value[schema_index])}; + // A perfect hash captures the key bytes but not its length, so its size + // is confirmed rather than trusting the hash match alone if (schema_hash == instance_hash && - (property_hasher.is_perfect(instance_hash) || - instance_entry.first == std::get<0>(value[schema_index]))) { + (property_hasher.is_perfect(instance_hash) + ? instance_entry.first.size() == + std::get<0>(value[schema_index]).size() + : instance_entry.first == std::get<0>(value[schema_index]))) { seen |= (static_cast(1) << schema_index); if (schema_index < instruction.children.size()) { const auto &child{instruction.children[schema_index]}; @@ -1189,16 +1214,37 @@ INSTRUCTION_HANDLER(LogicalOr) { // This boolean value controls whether we should be exhaustive if (value) { for (const auto &child : instruction.children) { + // A branch that does not hold contributes nothing, including any + // evaluation it marked before failing. Only a schema that tracks + // evaluation can ever observe those marks + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + if (EVALUATE_RECURSE(child, target)) { result = true; + } else { + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } } } } else { for (const auto &child : instruction.children) { + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + if (EVALUATE_RECURSE(child, target)) { result = true; break; } + + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } } } @@ -1283,6 +1329,13 @@ INSTRUCTION_HANDLER(LogicalXor) { resolve_instance(instance, instruction.relative_instance_location)}; const auto value{assume_value_copy(instruction.value)}; for (const auto &child : instruction.children) { + // A branch that does not hold contributes nothing, including any + // evaluation it marked before failing + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + if (EVALUATE_RECURSE(child, target)) { if (has_matched) [[unlikely]] { result = false; @@ -1293,6 +1346,10 @@ INSTRUCTION_HANDLER(LogicalXor) { } else { has_matched = true; } + } else { + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } } } @@ -1310,27 +1367,46 @@ INSTRUCTION_HANDLER(LogicalCondition) { assert(children_size >= value.second); SOURCEMETA_ASSUME(children_size >= value.second); - auto condition_end{children_size}; - if (value.first > 0) { - condition_end = value.first; - } else if (value.second > 0) { - condition_end = value.second; - } - + // The condition is the leading segment [0, then start). It may be empty, + // for example when a `$ref` condition inlines and its jump is dropped, in + // which case it trivially passes and the then branch applies const auto &target{ resolve_instance(instance, instruction.relative_instance_location)}; - for (std::size_t cursor = 0; cursor < condition_end; cursor++) { + + // A condition that holds does contribute the evaluation it marked, but one + // that does not hold contributes nothing at all + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + + for (std::size_t cursor = 0; cursor < value.first; cursor++) { if (!EVALUATE_RECURSE(instruction.children[cursor], target)) { result = false; break; } } - const auto consequence_start{result ? value.first : value.second}; - const auto consequence_end{(result && value.second > 0) ? value.second - : children_size}; + if (!result) { + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } + } + + // On a passing condition the then branch runs, otherwise the else branch, + // which is absent when it starts at zero + std::size_t consequence_start; + std::size_t consequence_end; + if (result) { + consequence_start = value.first; + consequence_end = value.second > 0 ? value.second : children_size; + } else { + consequence_start = value.second; + consequence_end = value.second > 0 ? children_size : value.second; + } + result = true; - if (consequence_start > 0) { + if (consequence_start < consequence_end) { if constexpr (Track || HasCallback) { if (track) { context.evaluator->evaluate_path.pop_back( @@ -1735,7 +1811,8 @@ INSTRUCTION_HANDLER(LoopProperties) { INSTRUCTION_HANDLER(LoopPropertiesEvaluate) { EVALUATE_BEGIN_NON_STRING(LoopPropertiesEvaluate, target.is_object()); - assert(!instruction.children.empty()); + // The subschema may compile to no children, in which case every property + // trivially matches and only the evaluation marking below takes effect result = true; for (const auto &entry : target.as_object()) { if constexpr (HasCallback) { @@ -2004,7 +2081,11 @@ INSTRUCTION_HANDLER(LoopPropertiesExactlyTypeStrict) { assert(!value.second.empty()); result = true; for (const auto &entry : object) { - if (effective_type_strict_real(entry.second) != value.first) + // The property count already matches the required set, so confirming + // that every property is one of the required names is what makes the + // object exactly the required set, and the value must be of the type + if (!value.second.contains(entry.first, entry.hash) || + effective_type_strict_real(entry.second) != value.first) [[unlikely]] { result = false; break; @@ -2042,7 +2123,10 @@ INSTRUCTION_HANDLER(LoopPropertiesExactlyTypeStrictHash) { EVALUATE_END(LoopPropertiesExactlyTypeStrictHash); } - if (entry.hash != value.second.first[index].first) { + // A hash match alone does not prove the property is required, as two + // distinct names can share a hash, so the name itself is confirmed + if (entry.hash != value.second.first[index].first || + entry.first != value.second.first[index].second) { break; } @@ -2055,10 +2139,16 @@ INSTRUCTION_HANDLER(LoopPropertiesExactlyTypeStrictHash) { // Continue where we left std::advance(iterator, index); for (; iterator != object.cend(); ++iterator) { + if (effective_type_strict_real(iterator->second) != value.first) { + result = false; + break; + } + // NOLINTNEXTLINE(modernize-use-ranges) if (std::ranges::none_of(value.second.first, [&iterator](const auto &entry) -> bool { - return entry.first == iterator->hash; + return entry.first == iterator->hash && + entry.second == iterator->first; })) { result = false; break; @@ -2261,7 +2351,8 @@ INSTRUCTION_HANDLER(LoopItemsFrom) { INSTRUCTION_HANDLER(LoopItemsUnevaluated) { EVALUATE_BEGIN_NON_STRING(LoopItemsUnevaluated, target.is_array()); - assert(!instruction.children.empty()); + // The subschema may compile to no children, in which case every item + // trivially matches and only the evaluation marking below takes effect result = true; if (!context.evaluator->is_evaluated(&target)) { @@ -2373,7 +2464,9 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } - // Unroll, for performance reasons, for small collections + // Unroll, for performance reasons, for small collections. A perfect hash + // captures the key bytes but not its length, so every match confirms the + // size alongside the hash if (hashes_size == 3) { for (const auto &entry : object) { if (effective_type_strict_real(entry.second) != value.first) @@ -2381,9 +2474,15 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); - } else if (entry.hash != value.second.first[0].first && - entry.hash != value.second.first[1].first && - entry.hash != value.second.first[2].first) { + } else if (!((entry.hash == value.second.first[0].first && + entry.first.size() == + value.second.first[0].second.size()) || + (entry.hash == value.second.first[1].first && + entry.first.size() == + value.second.first[1].second.size()) || + (entry.hash == value.second.first[2].first && + entry.first.size() == + value.second.first[2].second.size()))) { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } @@ -2395,8 +2494,12 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); - } else if (entry.hash != value.second.first[0].first && - entry.hash != value.second.first[1].first) { + } else if (!((entry.hash == value.second.first[0].first && + entry.first.size() == + value.second.first[0].second.size()) || + (entry.hash == value.second.first[1].first && + entry.first.size() == + value.second.first[1].second.size()))) { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } @@ -2404,7 +2507,9 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { } else if (hashes_size == 1) { const auto &entry{*object.cbegin()}; if (effective_type_strict_real(entry.second) != value.first || - entry.hash != value.second.first[0].first) [[unlikely]] { + entry.hash != value.second.first[0].first || + entry.first.size() != value.second.first[0].second.size()) + [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } @@ -2416,13 +2521,16 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); - } else if (entry.hash == value.second.first[index].first) { + } else if (entry.hash == value.second.first[index].first && + entry.first.size() == + value.second.first[index].second.size()) { index += 1; continue; } else if (!std::ranges::any_of( value.second.first, [&entry](const auto &hash_entry) -> bool { - return hash_entry.first == entry.hash; + return hash_entry.first == entry.hash && + hash_entry.second.size() == entry.first.size(); })) { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); @@ -2471,24 +2579,45 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash3) { EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash3); } + // A perfect hash captures the key bytes but not its length, so each + // pairing confirms the size alongside the hash while keeping the unrolled + // permutation check over the three keys if ((value_1.hash == value.second.first[0].first && + value_1.first.size() == value.second.first[0].second.size() && value_2.hash == value.second.first[1].first && - value_3.hash == value.second.first[2].first) || + value_2.first.size() == value.second.first[1].second.size() && + value_3.hash == value.second.first[2].first && + value_3.first.size() == value.second.first[2].second.size()) || (value_1.hash == value.second.first[0].first && + value_1.first.size() == value.second.first[0].second.size() && value_2.hash == value.second.first[2].first && - value_3.hash == value.second.first[1].first) || + value_2.first.size() == value.second.first[2].second.size() && + value_3.hash == value.second.first[1].first && + value_3.first.size() == value.second.first[1].second.size()) || (value_1.hash == value.second.first[1].first && + value_1.first.size() == value.second.first[1].second.size() && value_2.hash == value.second.first[0].first && - value_3.hash == value.second.first[2].first) || + value_2.first.size() == value.second.first[0].second.size() && + value_3.hash == value.second.first[2].first && + value_3.first.size() == value.second.first[2].second.size()) || (value_1.hash == value.second.first[1].first && + value_1.first.size() == value.second.first[1].second.size() && value_2.hash == value.second.first[2].first && - value_3.hash == value.second.first[0].first) || + value_2.first.size() == value.second.first[2].second.size() && + value_3.hash == value.second.first[0].first && + value_3.first.size() == value.second.first[0].second.size()) || (value_1.hash == value.second.first[2].first && + value_1.first.size() == value.second.first[2].second.size() && value_2.hash == value.second.first[0].first && - value_3.hash == value.second.first[1].first) || + value_2.first.size() == value.second.first[0].second.size() && + value_3.hash == value.second.first[1].first && + value_3.first.size() == value.second.first[1].second.size()) || (value_1.hash == value.second.first[2].first && + value_1.first.size() == value.second.first[2].second.size() && value_2.hash == value.second.first[1].first && - value_3.hash == value.second.first[0].first)) { + value_2.first.size() == value.second.first[1].second.size() && + value_3.hash == value.second.first[0].first && + value_3.first.size() == value.second.first[0].second.size())) { continue; } else { EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash3); @@ -2543,8 +2672,11 @@ INSTRUCTION_HANDLER(LoopItemsIntegerBoundedSized) { assume_value(instruction.value)}; const auto &integer_bounds{value.first}; const auto minimum_size{std::get<0>(value.second)}; + const auto &maximum_size{std::get<1>(value.second)}; EVALUATE_BEGIN_NON_STRING(LoopItemsIntegerBoundedSized, true); - if (!target.is_array() || target.array_size() < minimum_size) { + if (!target.is_array() || target.array_size() < minimum_size || + (maximum_size.has_value() && + target.array_size() > maximum_size.value())) { EVALUATE_END(LoopItemsIntegerBoundedSized); } @@ -2584,11 +2716,13 @@ INSTRUCTION_HANDLER(LoopItemsIntegerBoundedSized) { INSTRUCTION_HANDLER(LoopContains) { EVALUATE_BEGIN_NON_STRING(LoopContains, target.is_array()); - assert(!instruction.children.empty()); + // The subschema may compile to no children, in which case every array + // element trivially matches and the range check alone decides the result const auto &value{assume_value(instruction.value)}; const auto &[minimum, maximum, is_exhaustive] = value; - assert(!maximum.has_value() || maximum.value() >= minimum); - result = minimum == 0 && target.empty(); + // Note that the range may be unsatisfiable, as `minContains` and + // `maxContains` are free to invert it, in which case no array matches + result = minimum == 0; auto match_count{ std::numeric_limits>::min()}; diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h index 964d85be3..f76ddcabf 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h @@ -30,8 +30,11 @@ class SOURCEMETA_BLAZE_EVALUATOR_EXPORT StringSet { [[nodiscard]] inline auto contains(const string_type &value, const hash_type hash) const -> bool { if (this->hasher.is_perfect(hash)) { + // A perfect hash captures the key bytes but not its length, so two keys + // that only differ in trailing length hash the same and the size is + // confirmed too for (const auto &entry : this->data) { - if (entry.second == hash) { + if (entry.second == hash && entry.first.size() == value.size()) { return true; } } diff --git a/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h b/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h index 071cef4a6..d441aa7ec 100644 --- a/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h +++ b/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h @@ -11,12 +11,12 @@ #include +#include // std::size_t +#include // std::uint8_t #include // std::reference_wrapper // TODO(C++23): Consider std::flat_map/std::flat_set when available in libc++ -#include // std::map -#include // std::ostream #include // std::string -#include // std::pair +#include // std::move #include // std::vector namespace sourcemeta::blaze { @@ -131,16 +131,27 @@ class SOURCEMETA_BLAZE_OUTPUT_EXPORT SimpleOutput { #if defined(_MSC_VER) #pragma warning(disable : 4251) #endif + /// How much of a branching instruction fails as a unit when one of its + /// subinstructions fails + enum class MaskKind : std::uint8_t { None, Disjunction, Element, Subschema }; + + /// Classify an instruction according to how it absorbs failures + static auto mask_kind(const Instruction &step) noexcept -> MaskKind; + + /// An in-flight branching keyword, along with the number of annotations + /// collected before it started and the error traces it buffers + struct MaskEntry { + sourcemeta::core::WeakPointer evaluate_path; + sourcemeta::core::WeakPointer instance_location; + MaskKind kind; + std::size_t annotations_mark; + std::vector buffered_traces; + }; + const sourcemeta::core::JSON &instance_; const sourcemeta::core::WeakPointer base_; container_type output; - std::vector< - std::pair> - mask; - std::map< - std::pair, - std::vector> - masked_traces; + std::vector mask; std::vector annotations_; #if defined(_MSC_VER) #pragma warning(default : 4251) diff --git a/vendor/blaze/src/output/output_simple.cc b/vendor/blaze/src/output/output_simple.cc index 4bf975fca..30e51c2ec 100644 --- a/vendor/blaze/src/output/output_simple.cc +++ b/vendor/blaze/src/output/output_simple.cc @@ -2,9 +2,10 @@ #include -#include // std::any_of, std::sort +#include // std::min, std::remove_if #include // assert -#include // std::back_inserter, std::make_move_iterator +#include // std::make_move_iterator +#include // std::views::reverse #include // std::move namespace sourcemeta::blaze { @@ -13,6 +14,22 @@ SimpleOutput::SimpleOutput(const sourcemeta::core::JSON &instance, sourcemeta::core::WeakPointer base) : instance_{instance}, base_{std::move(base)} {} +auto SimpleOutput::mask_kind(const Instruction &step) noexcept -> MaskKind { + switch (step.type) { + case InstructionIndex::LogicalOr: + case InstructionIndex::LogicalXor: + return MaskKind::Disjunction; + case InstructionIndex::LoopContains: + return MaskKind::Element; + case InstructionIndex::LogicalCondition: + case InstructionIndex::LogicalNot: + case InstructionIndex::LogicalNotEvaluate: + return MaskKind::Subschema; + default: + return MaskKind::None; + } +} + auto SimpleOutput::begin() const -> const_iterator { return this->output.begin(); } @@ -37,22 +54,21 @@ auto SimpleOutput::operator()( return; } - assert(evaluate_path.back().is_property()); - // Fast path: passing non-annotation instructions that are not // closing a mask entry can be skipped entirely if (result && !is_annotation(step.type)) { if (type == EvaluationType::Pre) { - const auto &keyword{evaluate_path.back().to_property()}; - if (keyword == "anyOf" || keyword == "oneOf" || keyword == "not" || - keyword == "if" || keyword == "contains") { - this->mask.emplace_back(evaluate_path, instance_location); + const auto kind{mask_kind(step)}; + if (kind != MaskKind::None) { + this->mask.push_back({.evaluate_path = evaluate_path, + .instance_location = instance_location, + .kind = kind, + .annotations_mark = this->annotations_.size(), + .buffered_traces = {}}); } } else if (type == EvaluationType::Post && !this->mask.empty() && - this->mask.back().first == evaluate_path && - this->mask.back().second == instance_location) { - const auto mask_key{std::make_pair(evaluate_path, instance_location)}; - this->masked_traces.erase(mask_key); + this->mask.back().evaluate_path == evaluate_path && + this->mask.back().instance_location == instance_location) { this->mask.pop_back(); } @@ -76,34 +92,34 @@ auto SimpleOutput::operator()( return; } - const auto &keyword{evaluate_path.back().to_property()}; - if (type == EvaluationType::Pre) { assert(result); // To ease the output - if (keyword == "anyOf" || keyword == "oneOf" || keyword == "not" || - keyword == "if" || keyword == "contains") { - this->mask.emplace_back(evaluate_path, instance_location); + const auto kind{mask_kind(step)}; + if (kind != MaskKind::None) { + this->mask.push_back({.evaluate_path = evaluate_path, + .instance_location = instance_location, + .kind = kind, + .annotations_mark = this->annotations_.size(), + .buffered_traces = {}}); } } else if (type == EvaluationType::Post) { - const auto mask_key{std::make_pair(evaluate_path, instance_location)}; - const auto mask_it{std::ranges::find(this->mask, mask_key)}; + const auto mask_it{std::ranges::find_if( + this->mask, [&](const MaskEntry &mask_entry) -> bool { + return mask_entry.evaluate_path == evaluate_path && + mask_entry.instance_location == instance_location; + })}; if (mask_it != this->mask.end()) { // Present unexpected traces only when needed - if (!result && keyword != "not" && keyword != "if") { - auto buffered{this->masked_traces.find(mask_key)}; - if (buffered != this->masked_traces.end()) { + if (!result && mask_it->kind != MaskKind::Subschema) { #ifdef __cpp_lib_containers_ranges - this->output.append_range(std::move(buffered->second)); + this->output.append_range(std::move(mask_it->buffered_traces)); #else - this->output.insert(this->output.end(), - std::make_move_iterator(buffered->second.begin()), - std::make_move_iterator(buffered->second.end())); + this->output.insert( + this->output.end(), + std::make_move_iterator(mask_it->buffered_traces.begin()), + std::make_move_iterator(mask_it->buffered_traces.end())); #endif - this->masked_traces.erase(buffered); - } - } else { - this->masked_traces.erase(mask_key); } this->mask.erase(mask_it); @@ -115,19 +131,70 @@ auto SimpleOutput::operator()( } if (type == EvaluationType::Post && !this->annotations_.empty()) { - std::erase_if( - this->annotations_, [&](const AnnotationEntry &entry) -> bool { - return entry.evaluate_path.starts_with_initial(evaluate_path) && - entry.instance_location == instance_location; - }); + // A failure inside a branching keyword discards every annotation that + // the failing unit collected, including the ones at instance locations + // below the unit's own instance location. The unit is the disjunction + // branch for `anyOf` and `oneOf`, the application of the subschema to + // the array element for `contains`, and the entire subschema for `if` + // and negations. A failure outside of every branching keyword cannot + // be absorbed, so the overall result is bound to be false, in which + // case no annotations may be reported at all + const MaskEntry *unit{nullptr}; + for (const auto &mask_entry : std::views::reverse(this->mask)) { + if (evaluate_path.starts_with(mask_entry.evaluate_path) && + instance_location.starts_with(mask_entry.instance_location)) { + unit = &mask_entry; + break; + } + } + + if (unit) { + auto evaluate_prefix_size{unit->evaluate_path.size()}; + auto instance_prefix_size{unit->instance_location.size()}; + switch (unit->kind) { + case MaskKind::Disjunction: + evaluate_prefix_size = + std::min(evaluate_prefix_size + 1, evaluate_path.size()); + break; + case MaskKind::Element: + instance_prefix_size = + std::min(instance_prefix_size + 1, instance_location.size()); + break; + default: + break; + } + + // Every annotation the failing unit collected, including the ones + // that the equality check below would match, sits at or beyond the + // mark the unit's branching keyword recorded, so older entries do + // not need to be scanned at all + assert(unit->annotations_mark <= this->annotations_.size()); + const auto from{this->annotations_.begin() + + static_cast(unit->annotations_mark)}; + this->annotations_.erase( + std::remove_if( + from, this->annotations_.end(), + [&](const AnnotationEntry &entry) -> bool { + return (entry.evaluate_path.starts_with_initial( + evaluate_path) && + entry.instance_location == instance_location) || + (entry.evaluate_path.shares_prefix( + evaluate_path, evaluate_prefix_size) && + entry.instance_location.shares_prefix( + instance_location, instance_prefix_size)); + }), + this->annotations_.end()); + } else { + this->annotations_.clear(); + } } - if (keyword == "if") { + if (step.type == InstructionIndex::LogicalCondition) { return; } else { - for (const auto &mask_entry : this->mask) { - if (evaluate_path.starts_with(mask_entry.first)) { - this->masked_traces[mask_entry].push_back( + for (auto &mask_entry : this->mask) { + if (evaluate_path.starts_with(mask_entry.evaluate_path)) { + mask_entry.buffered_traces.push_back( {.message = describe(result, step, evaluate_path, instance_location, this->instance_, annotation), .instance_location = instance_location, diff --git a/vendor/core/CMakeLists.txt b/vendor/core/CMakeLists.txt index 72e264109..1be12a1f3 100644 --- a/vendor/core/CMakeLists.txt +++ b/vendor/core/CMakeLists.txt @@ -37,6 +37,7 @@ option(SOURCEMETA_CORE_MCP "Build the Sourcemeta Core MCP library" ON) 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_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) @@ -221,6 +222,10 @@ if(SOURCEMETA_CORE_JOSE) add_subdirectory(src/core/jose) endif() +if(SOURCEMETA_CORE_OAUTH) + add_subdirectory(src/core/oauth) +endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(src/core/semver) endif() @@ -389,6 +394,10 @@ if(SOURCEMETA_CORE_TESTS) add_subdirectory(test/jose) endif() + if(SOURCEMETA_CORE_OAUTH) + add_subdirectory(test/oauth) + endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(test/semver) endif() diff --git a/vendor/core/DEPENDENCIES b/vendor/core/DEPENDENCIES index 4ec44d501..fff17ebb9 100644 --- a/vendor/core/DEPENDENCIES +++ b/vendor/core/DEPENDENCIES @@ -1,4 +1,4 @@ -vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 +vendorpull https://github.com/sourcemeta/vendorpull 89f348a97842e05aeab45d338d41fb02031fad62 jsontestsuite https://github.com/nst/JSONTestSuite d64aefb55228d9584d3e5b2433f720ea8fd00c82 yaml-test-suite https://github.com/yaml/yaml-test-suite data-2022-01-17 cmark-gfm https://github.com/github/cmark-gfm 587a12bb54d95ac37241377e6ddc93ea0e45439b @@ -13,3 +13,15 @@ jose-cookbook https://github.com/ietf-jose/cookbook 13692b68bfc18b99557a5b1ed311 w3c-json-ld https://github.com/w3c/json-ld-api 8654ac22b6cf4f441d2fee915ae634d36b5a8067 aws-sig-v4-test-suite https://github.com/saibotsivad/aws-sig-v4-test-suite 38179d1173c9547a964e7f92e9308e73795534ff jsonpath-compliance-test-suite https://github.com/jsonpath-standard/jsonpath-compliance-test-suite 7be7c1fc28057c91e8eefaf197060fba7ed43acd +iana-oauth/parameters.csv https://www.iana.org/assignments/oauth-parameters/parameters.csv acfe19a091a15279587e761a399704e886447eec906b19a41528cb37dc2afc35 +iana-oauth/extensions-error.csv https://www.iana.org/assignments/oauth-parameters/extensions-error.csv b49d3e1c9904667551170d12d0943ae9613751f42581f6438a4714c774d64838 +iana-oauth/token-types.csv https://www.iana.org/assignments/oauth-parameters/token-types.csv 0f62163952af053013bf5ee5158c4a09dee4ca50b0fff8e5abff87a1d9f2d163 +iana-oauth/token-type-hint.csv https://www.iana.org/assignments/oauth-parameters/token-type-hint.csv a078e9cbb7ecd64b4920bf4d163e966738594c46243823f8c39d08e47e1a03c0 +iana-oauth/token-endpoint-auth-method.csv https://www.iana.org/assignments/oauth-parameters/token-endpoint-auth-method.csv a0b93b5ac25717f45d66f3937bdcd8214f56b3c0a1b9f6a8728f0a148333a275 +iana-oauth/pkce-code-challenge-method.csv https://www.iana.org/assignments/oauth-parameters/pkce-code-challenge-method.csv e6b6f9ce315ee6fda3678be6b9f46d8d888fcb0b968f94b7731e30449c7b85e1 +iana-oauth/authorization-server-metadata.csv https://www.iana.org/assignments/oauth-parameters/authorization-server-metadata.csv ea71c8f3564e78621a9cb45c404b910190ce5f54faaf6066db1cb2168283ce43 +iana-oauth/protected-resource-metadata.csv https://www.iana.org/assignments/oauth-parameters/protected-resource-metadata.csv 2601894c565a3443a4d39c8f120e207cfeebe596ddd5410d968911e3b5d3b28a +iana-oauth/client-metadata.csv https://www.iana.org/assignments/oauth-parameters/client-metadata.csv f885dc450ce4802ff2c81875b637c3fb5a4c8b8e26f088169e57598a4eeede96 +iana-oauth/uri.csv https://www.iana.org/assignments/oauth-parameters/uri.csv dca1a780e342845d2f3b0fe2391f12faf27217f0234933c1a399e27b55dcadec +iana-oauth/token-introspection-response.csv https://www.iana.org/assignments/oauth-parameters/token-introspection-response.csv d1a3e84f98853b72f51ed59e99d796bf20c415a8aca13e04373c0a25bbb9b1f4 +iana-oauth/endpoint.csv https://www.iana.org/assignments/oauth-parameters/endpoint.csv 1b5d125a9cfa06ac62286e322449cf369647069a6336483f372fbd1d84ad5aca diff --git a/vendor/core/config.cmake.in b/vendor/core/config.cmake.in index d2661ede6..fc7845b14 100644 --- a/vendor/core/config.cmake.in +++ b/vendor/core/config.cmake.in @@ -31,6 +31,7 @@ if(NOT SOURCEMETA_CORE_COMPONENTS) list(APPEND SOURCEMETA_CORE_COMPONENTS mcp) list(APPEND SOURCEMETA_CORE_COMPONENTS http) list(APPEND SOURCEMETA_CORE_COMPONENTS jose) + list(APPEND SOURCEMETA_CORE_COMPONENTS oauth) list(APPEND SOURCEMETA_CORE_COMPONENTS semver) list(APPEND SOURCEMETA_CORE_COMPONENTS gzip) list(APPEND SOURCEMETA_CORE_COMPONENTS html) @@ -228,6 +229,26 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS}) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") + elseif(component STREQUAL "oauth") + 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_oauth.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 338f485e7..074e65101 100644 --- a/vendor/core/src/core/crypto/CMakeLists.txt +++ b/vendor/core/src/core/crypto/CMakeLists.txt @@ -2,7 +2,7 @@ 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 verify.h sign.h aes_gcm.h + uuid.h crc32.h base64.h secure.h verify.h sign.h aes_gcm.h SOURCES crypto_sha256.cc crypto_hmac_sha256.cc crypto_hmac_sha384.cc crypto_hmac_sha512.cc crypto_sha384.cc @@ -22,7 +22,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_openssl.h crypto_pkcs8.h) target_link_libraries(sourcemeta_core_crypto PRIVATE OpenSSL::Crypto) elseif(APPLE) enable_language(OBJCXX) @@ -113,8 +114,8 @@ elseif(APPLE) crypto_verify_apple.cc crypto_secure_equals_apple.cc crypto_sign_apple.cc crypto_aes_gcm_apple.cc crypto_eddsa_cryptokit.mm "${CRYPTOKIT_OBJECT}" - crypto_eddsa.h crypto_eddsa_apple.h crypto_aes_apple.h crypto_bignum.h - crypto_shake256.h crypto_pkcs8.h) + crypto_eddsa.h crypto_eddsa_apple.h crypto_apple.h crypto_aes_apple.h + crypto_bignum.h crypto_shake256.h crypto_pkcs8.h) # The generated Objective-C interface header lives in the build tree target_include_directories(sourcemeta_core_crypto @@ -142,6 +143,7 @@ elseif(WIN32) 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_bignum.h crypto_ecc.h crypto_shake256.h crypto_pkcs8.h) target_link_libraries(sourcemeta_core_crypto PRIVATE bcrypt) else() diff --git a/vendor/core/src/core/crypto/crypto_apple.h b/vendor/core/src/core/crypto/crypto_apple.h new file mode 100644 index 000000000..6b54644cf --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_apple.h @@ -0,0 +1,39 @@ +#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 + +#include // CF*, kCF* +#include // Sec*, kSec* + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string + +namespace sourcemeta::core { + +// 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) + -> std::optional { + CFErrorRef error{nullptr}; + auto data{SecKeyCopyExternalRepresentation(key, &error)}; + if (data == nullptr) { + if (error != nullptr) { + CFRelease(error); + } + + return std::nullopt; + } + + std::string result{reinterpret_cast(CFDataGetBytePtr(data)), + static_cast(CFDataGetLength(data))}; + CFRelease(data); + return result; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_base64.cc b/vendor/core/src/core/crypto/crypto_base64.cc index 1357e4382..89c97cb02 100644 --- a/vendor/core/src/core/crypto/crypto_base64.cc +++ b/vendor/core/src/core/crypto/crypto_base64.cc @@ -36,8 +36,9 @@ constexpr std::array BASE64_DECODE_TABLE{ constexpr std::array BASE64URL_DECODE_TABLE{ build_decode_table(BASE64URL_ALPHABET)}; +template auto encode(const std::string_view input, const std::string_view alphabet, - const bool padding, std::string &output) -> void { + const bool padding, Output &output) -> void { std::size_t index{0}; while (index + 3 <= input.size()) { const std::uint32_t first{static_cast(input[index])}; @@ -71,9 +72,14 @@ auto encode(const std::string_view input, const std::string_view alphabet, } } -auto decode(const std::string_view input, - const std::array &table, const bool padding) - -> std::optional { +template +auto decode_into(const std::string_view input, + const std::array &table, const bool padding, + Output &output) -> bool { + // Decoding appends to the output, so a failure after some bytes were written + // rolls the output back to its original length rather than leaving a partial + // decode in a reused buffer + const auto base{output.size()}; auto data{input}; if (padding) { @@ -82,7 +88,7 @@ auto decode(const std::string_view input, // quantum is always completed at the end of a quantity", hence the padded // form must be a multiple of four characters if (data.size() % 4 != 0) { - return std::nullopt; + return false; } if (data.ends_with('=')) { @@ -94,11 +100,10 @@ auto decode(const std::string_view input, } if (data.size() % 4 == 1) { - return std::nullopt; + return false; } - std::string output; - output.reserve(((data.size() / 4) * 3) + 2); + output.reserve(output.size() + ((data.size() / 4) * 3) + 2); std::size_t index{0}; while (index + 4 <= data.size()) { @@ -111,7 +116,8 @@ auto decode(const std::string_view input, table[static_cast(data[index + 3])]}; if (first == INVALID_SEXTET || second == INVALID_SEXTET || third == INVALID_SEXTET || fourth == INVALID_SEXTET) { - return std::nullopt; + output.resize(base, '\0'); + return false; } const std::uint32_t group{(first << 18u) | (second << 12u) | (third << 6u) | @@ -132,7 +138,8 @@ auto decode(const std::string_view input, table[static_cast(data[index + 1])]}; if (first == INVALID_SEXTET || second == INVALID_SEXTET || (second & 0x0Fu) != 0) { - return std::nullopt; + output.resize(base, '\0'); + return false; } output.push_back(static_cast((first << 2u) | (second >> 4u))); @@ -144,7 +151,8 @@ auto decode(const std::string_view input, table[static_cast(data[index + 2])]}; if (first == INVALID_SEXTET || second == INVALID_SEXTET || third == INVALID_SEXTET || (third & 0x03u) != 0) { - return std::nullopt; + output.resize(base, '\0'); + return false; } output.push_back(static_cast((first << 2u) | (second >> 4u))); @@ -152,7 +160,7 @@ auto decode(const std::string_view input, static_cast(((second & 0x0Fu) << 4u) | (third >> 2u))); } - return output; + return true; } } // namespace @@ -170,8 +178,29 @@ auto base64_encode(const std::string_view input) -> std::string { return result; } +auto base64_encode(const std::string_view input, SecureString &output) -> void { + // The input is copied first so that growing the output cannot invalidate it + // when the two alias the same storage + const SecureString input_copy{input}; + output.reserve(output.size() + ((input_copy.size() + 2) / 3) * 4); + encode(std::string_view{input_copy}, BASE64_ALPHABET, true, output); +} + auto base64_decode(const std::string_view input) -> std::optional { - return decode(input, BASE64_DECODE_TABLE, true); + std::string output; + if (!decode_into(input, BASE64_DECODE_TABLE, true, output)) { + return std::nullopt; + } + + return output; +} + +auto base64_decode(const std::string_view input, SecureString &output) -> bool { + // The input is copied first so that growing the output cannot invalidate it + // when the two alias the same storage + const SecureString input_copy{input}; + return decode_into(std::string_view{input_copy}, BASE64_DECODE_TABLE, true, + output); } auto base64url_encode(const std::string_view input, std::ostream &output) @@ -186,9 +215,23 @@ auto base64url_encode(const std::string_view input) -> std::string { return result; } +auto base64url_encode(const std::string_view input, SecureString &output) + -> void { + // The input is copied first so that growing the output cannot invalidate it + // when the two alias the same storage + const SecureString input_copy{input}; + output.reserve(output.size() + ((input_copy.size() + 2) / 3) * 4); + encode(std::string_view{input_copy}, BASE64URL_ALPHABET, false, output); +} + auto base64url_decode(const std::string_view input) -> std::optional { - return decode(input, BASE64URL_DECODE_TABLE, false); + std::string output; + if (!decode_into(input, BASE64URL_DECODE_TABLE, false, output)) { + return std::nullopt; + } + + return output; } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_der.h b/vendor/core/src/core/crypto/crypto_der.h index ba47bee01..da79f6124 100644 --- a/vendor/core/src/core/crypto/crypto_der.h +++ b/vendor/core/src/core/crypto/crypto_der.h @@ -8,6 +8,7 @@ #include // std::optional, std::nullopt #include // std::string #include // std::string_view +#include // std::pair namespace sourcemeta::core { @@ -116,6 +117,68 @@ inline auto der_append_unsigned_integer(std::string &output, output.append(value); } +// The minimal big-endian magnitude of a canonical non-negative DER INTEGER +// (ITU-T X.690 Section 8.3), returning no value for an empty, negative, or +// non-canonically sign-prefixed value, so a malformed integer cannot be +// silently reinterpreted as a different positive number +inline auto der_unsigned_integer(std::string_view content) + -> std::optional { + if (content.empty()) { + return std::nullopt; + } + + // A leading octet with the high bit set encodes a negative value, and a + // leading zero octet is only canonical when the next octet would otherwise + // be read as negative + if (static_cast(content.front()) >= 0x80) { + return std::nullopt; + } + + if (content.size() >= 2 && content.front() == '\x00' && + static_cast(content[1]) < 0x80) { + return std::nullopt; + } + + while (!content.empty() && content.front() == '\x00') { + content.remove_prefix(1); + } + + return content; +} + +// Read the modulus and public exponent from a PKCS#1 RSAPublicKey structure +// (RFC 8017 Appendix A.1.1), a DER SEQUENCE of exactly the two integers, each +// returned as its minimal big-endian magnitude. Trailing bytes at either level +// are rejected so the input parses as exactly that structure +inline auto der_read_rsa_public_key(const std::string_view der) + -> std::optional> { + const auto sequence{der_read(der)}; + if (!sequence.has_value() || sequence->tag != 0x30 || + !sequence->rest.empty()) { + return std::nullopt; + } + + const auto modulus{der_read(sequence->content)}; + if (!modulus.has_value() || modulus->tag != 0x02) { + return std::nullopt; + } + + const auto exponent{der_read(modulus->rest)}; + if (!exponent.has_value() || exponent->tag != 0x02 || + !exponent->rest.empty()) { + return std::nullopt; + } + + const auto modulus_value{der_unsigned_integer(modulus->content)}; + const auto exponent_value{der_unsigned_integer(exponent->content)}; + if (!modulus_value.has_value() || !exponent_value.has_value()) { + return std::nullopt; + } + + return std::pair{std::string{modulus_value.value()}, + std::string{exponent_value.value()}}; +} + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/crypto/crypto_eddsa.h b/vendor/core/src/core/crypto/crypto_eddsa.h index 81ae154ce..a74b8e226 100644 --- a/vendor/core/src/core/crypto/crypto_eddsa.h +++ b/vendor/core/src/core/crypto/crypto_eddsa.h @@ -189,6 +189,18 @@ inline auto edwards_point_encode(const EdwardsPoint &point, const Bignum &prime, return encoding; } +// The public key is the encoded base point multiplied by the pruned secret +// scalar (RFC 8032 Sections 5.1.5 and 5.2.5). The multiplication is +// constant-time because the scalar is secret, though the resulting point is +// public +inline auto edwards_public_key_point(const Bignum &scalar, + const EdwardsParameters ¶meters, + const std::size_t length) -> std::string { + return edwards_point_encode(edwards_point_scalar_multiply_constant_time( + scalar, parameters.base, parameters), + parameters.prime, length); +} + // Recover an Ed25519 point from its 32-byte encoding (RFC 8032 Section 5.1.3), // returning no value when the encoding does not name a point on the curve inline auto edwards25519_decode_point(const std::string_view encoding, @@ -370,8 +382,39 @@ inline auto edwards25519_verify(const std::string_view public_key, return edwards_point_equal(left, right, parameters.prime); } -// Produce an Ed25519 signature over a message (RFC 8032 Section 5.1.6), given -// the 32-byte secret seed +// Prune the 32-byte secret scalar in place (RFC 8032 Section 5.1.5): "The +// lowest three bits of the first octet are cleared, the highest bit of the last +// octet is cleared, and the second highest bit of the last octet is set" +inline auto edwards25519_prune_scalar(std::string &scalar_bytes) noexcept + -> void { + scalar_bytes.front() = static_cast( + static_cast(scalar_bytes.front()) & 0xf8u); + scalar_bytes.back() = static_cast( + (static_cast(scalar_bytes.back()) & 0x7fu) | 0x40u); +} + +// The Ed25519 public key derived from the 32-byte private seed, the encoded +// point [s]B where s is the pruned first half of the seed hash (RFC 8032 +// Section 5.1.5) +inline auto edwards25519_public_key(const std::string_view secret) + -> std::optional { + if (secret.size() != 32) { + return std::nullopt; + } + + const auto parameters{edwards25519()}; + auto hashed{sha512_digest(secret)}; + const SecureBufferScope hashed_scope{hashed.data(), hashed.size()}; + const std::string_view digest{reinterpret_cast(hashed.data()), + hashed.size()}; + std::string scalar_bytes{digest.substr(0, 32)}; + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards25519_prune_scalar(scalar_bytes); + auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; + const SecureBignumScope scalar_a_scope{scalar_a}; + return edwards_public_key_point(scalar_a, parameters, 32); +} + inline auto edwards25519_sign(const std::string_view secret, const std::string_view message) -> std::optional { @@ -390,23 +433,17 @@ inline auto edwards25519_sign(const std::string_view secret, // The secret scalar is the pruned first half, the prefix the second half std::string scalar_bytes{digest.substr(0, 32)}; - const SecureScope scalar_bytes_scope{scalar_bytes}; - scalar_bytes.front() = static_cast( - static_cast(scalar_bytes.front()) & 0xf8u); - scalar_bytes.back() = static_cast( - (static_cast(scalar_bytes.back()) & 0x7fu) | 0x40u); + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards25519_prune_scalar(scalar_bytes); auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; const SecureBignumScope scalar_a_scope{scalar_a}; const auto prefix{digest.substr(32)}; - const auto public_key{ - edwards_point_encode(edwards_point_scalar_multiply_constant_time( - scalar_a, parameters.base, parameters), - parameters.prime, 32)}; + const auto public_key{edwards_public_key_point(scalar_a, parameters, 32)}; // r = SHA-512(prefix || M) reduced, then R = [r]B std::string nonce_preimage{prefix}; - const SecureScope nonce_preimage_scope{nonce_preimage}; + const SecureStringScope nonce_preimage_scope{nonce_preimage}; nonce_preimage.append(message); auto nonce_digest{sha512_digest(nonce_preimage)}; const SecureBufferScope nonce_digest_scope{nonce_digest.data(), @@ -607,8 +644,38 @@ inline auto edwards448_verify(const std::string_view public_key, return edwards_point_equal(left, right, parameters.prime); } -// Produce an Ed448 signature over a message (RFC 8032 Section 5.2.6), given the -// 57-byte secret seed +// Prune the 57-byte secret scalar in place (RFC 8032 Section 5.2.5): "The two +// least significant bits of the first octet are cleared, all eight bits of the +// last octet are cleared, and the highest bit of the second to last octet is +// set" +inline auto edwards448_prune_scalar(std::string &scalar_bytes) noexcept + -> void { + scalar_bytes.front() = static_cast( + static_cast(scalar_bytes.front()) & 0xfcu); + scalar_bytes[55] = + static_cast(static_cast(scalar_bytes[55]) | 0x80u); + scalar_bytes[56] = '\x00'; +} + +// The Ed448 public key derived from the 57-byte private seed (RFC 8032 +// Section 5.2.5) +inline auto edwards448_public_key(const std::string_view secret) + -> std::optional { + if (secret.size() != 57) { + return std::nullopt; + } + + const auto parameters{edwards448()}; + auto digest{shake256(secret, 114)}; + const SecureStringScope digest_scope{digest}; + std::string scalar_bytes{digest.substr(0, 57)}; + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards448_prune_scalar(scalar_bytes); + auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; + const SecureBignumScope scalar_a_scope{scalar_a}; + return edwards_public_key_point(scalar_a, parameters, 57); +} + inline auto edwards448_sign(const std::string_view secret, const std::string_view message) -> std::optional { @@ -621,24 +688,17 @@ inline auto edwards448_sign(const std::string_view secret, // prefix, so it and everything derived from it below is wiped before // returning auto digest{shake256(secret, 114)}; - const SecureScope digest_scope{digest}; + const SecureStringScope digest_scope{digest}; // The secret scalar is the pruned first half, the prefix the second half std::string scalar_bytes{digest.substr(0, 57)}; - const SecureScope scalar_bytes_scope{scalar_bytes}; - scalar_bytes.front() = static_cast( - static_cast(scalar_bytes.front()) & 0xfcu); - scalar_bytes[55] = - static_cast(static_cast(scalar_bytes[55]) | 0x80u); - scalar_bytes[56] = '\x00'; + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards448_prune_scalar(scalar_bytes); auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; const SecureBignumScope scalar_a_scope{scalar_a}; const auto prefix{std::string_view{digest}.substr(57)}; - const auto public_key{ - edwards_point_encode(edwards_point_scalar_multiply_constant_time( - scalar_a, parameters.base, parameters), - parameters.prime, 57)}; + const auto public_key{edwards_public_key_point(scalar_a, parameters, 57)}; // dom4 is "SigEd448" followed by the zero pre-hash flag and an empty context std::string domain{"SigEd448"}; @@ -647,11 +707,11 @@ inline auto edwards448_sign(const std::string_view secret, // r = SHAKE256(dom4 || prefix || M) reduced, then R = [r]B std::string nonce_preimage{domain}; - const SecureScope nonce_preimage_scope{nonce_preimage}; + const SecureStringScope nonce_preimage_scope{nonce_preimage}; nonce_preimage.append(prefix); nonce_preimage.append(message); auto nonce_hash{shake256(nonce_preimage, 114)}; - const SecureScope nonce_hash_scope{nonce_hash}; + const SecureStringScope nonce_hash_scope{nonce_hash}; auto scalar_r{bignum_from_bytes_little_endian(nonce_hash)}; const SecureBignumScope scalar_r_scope{scalar_r}; bignum_reduce(scalar_r, parameters.order); diff --git a/vendor/core/src/core/crypto/crypto_helpers.h b/vendor/core/src/core/crypto/crypto_helpers.h index 9c2e2e787..b5f99c061 100644 --- a/vendor/core/src/core/crypto/crypto_helpers.h +++ b/vendor/core/src/core/crypto/crypto_helpers.h @@ -1,6 +1,7 @@ #ifndef SOURCEMETA_CORE_CRYPTO_HELPERS_H_ #define SOURCEMETA_CORE_CRYPTO_HELPERS_H_ +#include #include #include #include @@ -9,6 +10,7 @@ #include // std::size_t #include // std::uint8_t +#include // std::optional, std::nullopt #include // std::string #include // std::string_view #include // std::unreachable @@ -19,41 +21,6 @@ namespace sourcemeta::core { // the range of valid key sizes inline constexpr std::size_t MAXIMUM_KEY_BYTES{512}; -// Overwrite a buffer that held secret material so it does not linger in freed -// memory. The volatile access stops the compiler from eliding the write as a -// dead store -inline auto secure_zero(void *const data, const std::size_t size) noexcept - -> void { - if (data == nullptr) { - return; - } - - auto *pointer{static_cast(data)}; - for (std::size_t index{0}; index < size; index += 1) { - pointer[index] = 0; - } -} - -inline auto secure_zero(std::string &value) noexcept -> void { - secure_zero(value.data(), value.size()); -} - -// Overwrite the referenced buffer when leaving the current scope, so secret -// material a local holds is wiped across every return path without threading a -// manual call through each one. It clears the buffer the string owns at scope -// exit, so a reassignment or a growth that reallocates before then can still -// leave an earlier copy in freed memory, a residual that a wiping allocator -// would be needed to close -struct SecureScope { - explicit SecureScope(std::string &value) noexcept : target{value} {} - SecureScope(const SecureScope &) = delete; - auto operator=(const SecureScope &) -> SecureScope & = delete; - SecureScope(SecureScope &&) = delete; - auto operator=(SecureScope &&) -> SecureScope & = delete; - ~SecureScope() { secure_zero(this->target); } - std::string ⌖ -}; - // 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 { @@ -146,6 +113,22 @@ inline auto curve_field_bytes(const EllipticCurve curve) noexcept 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 + -> std::optional { + switch (field_bytes) { + case 32: + return EllipticCurve::P256; + case 48: + return EllipticCurve::P384; + case 66: + return EllipticCurve::P521; + default: + return std::nullopt; + } +} + // The group order of each NIST prime curve as big-endian octets (FIPS 186-4 // Appendix D.1.2), so a private scalar can be range-checked against it inline auto curve_order_bytes(const EllipticCurve curve) -> std::string { @@ -178,7 +161,7 @@ inline auto ec_private_scalar_in_range(const std::string_view scalar, const auto width{curve_field_bytes(curve)}; std::string folded(width, '\x00'); // The buffer holds a copy of the secret scalar, so it is wiped on return - const SecureScope folded_scope{folded}; + const SecureStringScope folded_scope{folded}; std::uint8_t overflow{0}; for (std::size_t index = 0; index < scalar.size(); ++index) { const auto byte{ @@ -251,6 +234,33 @@ inline auto digest_message(const SignatureHashFunction hash, std::unreachable(); } +// The same digest returned in wiping storage, for the secret-bearing hashing of +// the deterministic nonce generator, where the message and the result derive +// from the private key +inline auto secure_digest_message(const SignatureHashFunction hash, + const std::string_view message) + -> SecureString { + switch (hash) { + case SignatureHashFunction::SHA256: { + auto digest{sha256_digest(message)}; + const SecureBufferScope digest_scope{digest.data(), digest.size()}; + return {reinterpret_cast(digest.data()), digest.size()}; + } + case SignatureHashFunction::SHA384: { + auto digest{sha384_digest(message)}; + const SecureBufferScope digest_scope{digest.data(), digest.size()}; + return {reinterpret_cast(digest.data()), digest.size()}; + } + case SignatureHashFunction::SHA512: { + auto digest{sha512_digest(message)}; + const SecureBufferScope digest_scope{digest.data(), digest.size()}; + return {reinterpret_cast(digest.data()), digest.size()}; + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/crypto/crypto_openssl.h b/vendor/core/src/core/crypto/crypto_openssl.h new file mode 100644 index 000000000..a292d7642 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_openssl.h @@ -0,0 +1,81 @@ +#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 + +#include + +#include // BIGNUM, BN_* +#include // OSSL_PKEY_PARAM_* +#include // EVP_PKEY, EVP_PKEY_get_* + +#include // std::array +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +// 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 + -> std::optional { + if (name == "P-256" || name == "prime256v1") { + return EllipticCurve::P256; + } else if (name == "P-384" || name == "secp384r1") { + return EllipticCurve::P384; + } else if (name == "P-521" || name == "secp521r1") { + return EllipticCurve::P521; + } else { + return std::nullopt; + } +} + +// A big-endian minimal-length byte string of a bignum, as the JWK members and +// the signature range check expect +inline auto bignum_to_bytes(const BIGNUM *number) -> std::string { + std::string result(static_cast(BN_num_bytes(number)), '\x00'); + BN_bn2bin(number, reinterpret_cast(result.data())); + return result; +} + +// Read the fixed-width uncompressed public point of an elliptic curve or +// Edwards curve key from the native handle +inline auto read_public_point(EVP_PKEY *key) -> std::optional { + std::size_t length{0}; + if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY, nullptr, 0, + &length) != 1) { + return std::nullopt; + } + + std::string point(length, '\x00'); + if (EVP_PKEY_get_octet_string_param( + key, OSSL_PKEY_PARAM_PUB_KEY, + reinterpret_cast(point.data()), point.size(), + &length) != 1) { + return std::nullopt; + } + + // The second call reports how many octets it actually wrote, which can be + // fewer than the buffer, so the string is trimmed to avoid trailing zeros + point.resize(length); + return point; +} + +inline auto read_ec_curve(EVP_PKEY *key) -> std::optional { + std::array group{}; + std::size_t length{0}; + if (EVP_PKEY_get_utf8_string_param(key, OSSL_PKEY_PARAM_GROUP_NAME, + group.data(), group.size(), + &length) != 1) { + return std::nullopt; + } + + return ec_curve_from_group_name(std::string_view{group.data(), length}); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_pkcs8.h b/vendor/core/src/core/crypto/crypto_pkcs8.h index 1a88cab3b..cf95c1084 100644 --- a/vendor/core/src/core/crypto/crypto_pkcs8.h +++ b/vendor/core/src/core/crypto/crypto_pkcs8.h @@ -43,7 +43,7 @@ inline auto pem_to_der(const std::string_view pem) } // The base64 body carries the whole private key, so it is wiped once decoded - const SecureScope base64_scope{base64}; + const SecureStringScope base64_scope{base64}; return base64_decode(base64); } diff --git a/vendor/core/src/core/crypto/crypto_sign_apple.cc b/vendor/core/src/core/crypto/crypto_sign_apple.cc index 5cd617c36..2a9af5ce3 100644 --- a/vendor/core/src/core/crypto/crypto_sign_apple.cc +++ b/vendor/core/src/core/crypto/crypto_sign_apple.cc @@ -1,6 +1,8 @@ #include #include +#include "crypto_apple.h" +#include "crypto_der.h" #include "crypto_eddsa.h" #include "crypto_eddsa_apple.h" #include "crypto_helpers.h" @@ -292,7 +294,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; const auto parsed{parse_pkcs8(der.value())}; if (!parsed.has_value()) { return std::nullopt; @@ -461,4 +463,64 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) std::unreachable(); } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: { + auto *public_key{SecKeyCopyPublicKey(internal->key)}; + if (public_key == nullptr) { + return std::nullopt; + } + + const auto der{copy_external_representation(public_key)}; + CFRelease(public_key); + if (!der.has_value()) { + return std::nullopt; + } + + const auto components{der_read_rsa_public_key(der.value())}; + if (!components.has_value()) { + return std::nullopt; + } + + return make_rsa_public_key(components->first, components->second); + } + case PrivateKey::Type::EllipticCurve: { + auto *public_key{SecKeyCopyPublicKey(internal->key)}; + if (public_key == nullptr) { + return std::nullopt; + } + + const auto point{copy_external_representation(public_key)}; + CFRelease(public_key); + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + if (!point.has_value() || !curve.has_value() || + point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return make_ec_public_key( + curve.value(), point->substr(1, internal->field_bytes), + point->substr(1 + internal->field_bytes, internal->field_bytes)); + } + case PrivateKey::Type::Edwards: { + const auto point{internal->edwards_curve == EdwardsCurve::Ed25519 + ? edwards25519_public_key(internal->edwards_seed) + : edwards448_public_key(internal->edwards_seed)}; + if (!point.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(internal->edwards_curve, point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sign_openssl.cc b/vendor/core/src/core/crypto/crypto_sign_openssl.cc index a10c7a098..26d70ca91 100644 --- a/vendor/core/src/core/crypto/crypto_sign_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_sign_openssl.cc @@ -2,6 +2,7 @@ #include #include "crypto_helpers.h" +#include "crypto_openssl.h" #include "crypto_pkcs8.h" #include // BN_bn2binpad, BN_bin2bn @@ -287,7 +288,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; if (!parse_pkcs8(der.value()).has_value()) { return std::nullopt; } @@ -453,4 +454,66 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) return result; } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: { + BIGNUM *modulus{nullptr}; + BIGNUM *exponent{nullptr}; + if (EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_N, + &modulus) != 1 || + EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_E, + &exponent) != 1) { + BN_free(modulus); + BN_free(exponent); + return std::nullopt; + } + + const auto modulus_bytes{bignum_to_bytes(modulus)}; + const auto exponent_bytes{bignum_to_bytes(exponent)}; + BN_free(modulus); + BN_free(exponent); + return make_rsa_public_key(modulus_bytes, exponent_bytes); + } + case PrivateKey::Type::EllipticCurve: { + const auto curve{read_ec_curve(internal->key)}; + const auto point{read_public_point(internal->key)}; + if (!curve.has_value() || !point.has_value() || + point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return make_ec_public_key( + curve.value(), point->substr(1, internal->field_bytes), + point->substr(1 + internal->field_bytes, internal->field_bytes)); + } + case PrivateKey::Type::Edwards: { + const auto point{read_public_point(internal->key)}; + if (!point.has_value()) { + return std::nullopt; + } + + std::optional curve; + if (point->size() == eddsa_public_key_bytes(EdwardsCurve::Ed25519)) { + curve = EdwardsCurve::Ed25519; + } else if (point->size() == eddsa_public_key_bytes(EdwardsCurve::Ed448)) { + curve = EdwardsCurve::Ed448; + } + + if (!curve.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(curve.value(), point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sign_other.cc b/vendor/core/src/core/crypto/crypto_sign_other.cc index f796a3c8b..734e45e6d 100644 --- a/vendor/core/src/core/crypto/crypto_sign_other.cc +++ b/vendor/core/src/core/crypto/crypto_sign_other.cc @@ -2,6 +2,7 @@ #include #include "crypto_bignum.h" +#include "crypto_der.h" #include "crypto_ecc.h" #include "crypto_eddsa.h" #include "crypto_helpers.h" @@ -165,16 +166,19 @@ auto hash_sizes(const SignatureHashFunction hash) -> HashSizes { } // HMAC (RFC 2104) keyed on the signature hash function, the primitive that the -// deterministic nonce generator is built on +// deterministic nonce generator is built on. The key, the block-sized pads, and +// the result all derive from the private key, so they are kept in wiping +// storage auto hmac(const SignatureHashFunction hash, const std::string_view key, - const std::string_view message) -> std::string { + const std::string_view message) -> SecureString { const auto block_bytes{hash_sizes(hash).block_bytes}; - std::string block_key{key.size() > block_bytes ? digest_message(hash, key) - : std::string{key}}; + SecureString block_key{key.size() > block_bytes + ? secure_digest_message(hash, key) + : SecureString{key}}; block_key.resize(block_bytes, '\x00'); - std::string inner_input(block_bytes, '\x00'); - std::string outer_input(block_bytes, '\x00'); + SecureString inner_input(block_bytes, '\x00'); + SecureString outer_input(block_bytes, '\x00'); for (std::size_t index{0}; index < block_bytes; ++index) { const auto byte{static_cast(block_key[index])}; inner_input[index] = static_cast(byte ^ 0x36u); @@ -182,8 +186,8 @@ auto hmac(const SignatureHashFunction hash, const std::string_view key, } inner_input.append(message); - outer_input.append(digest_message(hash, inner_input)); - return digest_message(hash, outer_input); + outer_input.append(secure_digest_message(hash, inner_input)); + return secure_digest_message(hash, outer_input); } // RFC 6979 Section 2.3.2 bits2int, which is also the FIPS 186-4 Section 6.4 @@ -295,20 +299,18 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, .z = bignum_from_u64(1)}; auto private_octets{bignum_to_bytes(private_scalar, order_bytes)}; - const SecureScope private_octets_scope{private_octets}; + const SecureStringScope private_octets_scope{private_octets}; const auto hashed_octets{ bits2octets(digest, parameters.order, order_bits, order_bytes)}; // RFC 6979 Section 3.2 steps b to g: seed the HMAC generator. The generator - // state and the private octets are derived from the private key, so each is - // wiped when leaving this function + // state and the private octets are derived from the private key, so they are + // held in wiping storage that clears itself on every reassignment and on the + // way out of this function const auto output_bytes{hash_sizes(hash).output_bytes}; - std::string hmac_value(output_bytes, '\x01'); - const SecureScope hmac_value_scope{hmac_value}; - std::string hmac_key(output_bytes, '\x00'); - const SecureScope hmac_key_scope{hmac_key}; - std::string seed{hmac_value}; - const SecureScope seed_scope{seed}; + SecureString hmac_value(output_bytes, '\x01'); + SecureString hmac_key(output_bytes, '\x00'); + SecureString seed{hmac_value}; seed.push_back('\x00'); seed.append(private_octets); seed.append(hashed_octets); @@ -323,9 +325,8 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, for (std::size_t attempt = 0; attempt < 256; ++attempt) { // RFC 6979 Section 3.2 step h.2: draw enough output to cover the order. The - // candidate is the secret nonce, so it is wiped when the attempt ends - std::string candidate; - const SecureScope candidate_scope{candidate}; + // candidate is the secret nonce, so it too lives in wiping storage + SecureString candidate; while (candidate.size() * 8 < order_bits) { hmac_value = hmac(hash, hmac_key, hmac_value); candidate.append(hmac_value); @@ -341,8 +342,7 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, } // RFC 6979 Section 3.2 step h: reseed before the next candidate - std::string reseed{hmac_value}; - const SecureScope reseed_scope{reseed}; + SecureString reseed{hmac_value}; reseed.push_back('\x00'); hmac_key = hmac(hash, hmac_key, reseed); hmac_value = hmac(hash, hmac_key, hmac_value); @@ -351,6 +351,34 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, return std::nullopt; } +// The public point [d]G of an elliptic curve private key recovered from its +// scalar, so a key parsed from a PEM document, which carries only the scalar on +// this backend, can still expose its public JWK +auto ec_public_from_scalar(const EllipticCurve curve, + const std::string_view scalar) + -> std::pair { + const auto parameters{to_curve_parameters(curve)}; + const JacobianPoint generator{.x = parameters.generator_x, + .y = parameters.generator_y, + .z = bignum_from_u64(1)}; + auto scalar_number{bignum_from_bytes(scalar)}; + const SecureBignumScope scalar_scope{scalar_number}; + // The complete-formula ladder returns a projective point, whose affine + // coordinates are X / Z and Y / Z, not the Jacobian X / Z^2 and Y / Z^3, so + // the normalization mirrors the constant-time affine recovery of the signing + // path rather than the Jacobian point_to_affine + const auto product{point_scalar_multiply_constant_time( + scalar_number, generator, parameters)}; + const auto field{barrett_context(parameters.prime)}; + const auto z_inverse{field_inverse_ct(product.z, field)}; + auto coordinate_x{field_mod_multiply_ct(product.x, z_inverse, field)}; + auto coordinate_y{field_mod_multiply_ct(product.y, z_inverse, field)}; + bignum_normalize(coordinate_x); + bignum_normalize(coordinate_y); + return {bignum_to_bytes(coordinate_x, parameters.field_bytes), + bignum_to_bytes(coordinate_y, parameters.field_bytes)}; +} + } // namespace // The reference backend parses the key material into big integers inside each @@ -365,6 +393,12 @@ struct PrivateKey::Internal { 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} {} @@ -412,7 +446,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; const auto parsed{parse_pkcs8(der.value())}; if (!parsed.has_value()) { return std::nullopt; @@ -447,26 +481,36 @@ auto make_private_key(const std::string_view pem) -> std::optional { return std::nullopt; } - const auto stripped_modulus{strip_left(modulus->content, '\x00')}; - const auto stripped_private_exponent{ - strip_left(private_exponent->content, '\x00')}; - if (stripped_modulus.empty() || - stripped_modulus.size() > MAXIMUM_KEY_BYTES || - stripped_private_exponent.size() > MAXIMUM_KEY_BYTES) { + // Decode each field as a canonical non-negative DER INTEGER, so a + // negative or non-canonically encoded value cannot be silently + // reinterpreted as a different positive number and used for signing + const auto modulus_value{der_unsigned_integer(modulus->content)}; + const auto public_exponent_value{ + der_unsigned_integer(public_exponent->content)}; + const auto private_exponent_value{ + der_unsigned_integer(private_exponent->content)}; + if (!modulus_value.has_value() || !public_exponent_value.has_value() || + !private_exponent_value.has_value() || modulus_value->empty() || + private_exponent_value->empty() || + modulus_value->size() > MAXIMUM_KEY_BYTES || + private_exponent_value->size() > MAXIMUM_KEY_BYTES || + !rsa_public_exponent_acceptable(public_exponent_value.value(), + modulus_value.value())) { return std::nullopt; } return PrivateKey{new PrivateKey::Internal{ .kind = PrivateKey::Type::RSA, - .modulus = std::string{stripped_modulus}, - .public_exponent = - std::string{strip_left(public_exponent->content, '\x00')}, - .private_exponent = std::string{stripped_private_exponent}, + .modulus = std::string{modulus_value.value()}, + .public_exponent = std::string{public_exponent_value.value()}, + .private_exponent = std::string{private_exponent_value.value()}, .scalar = {}, .elliptic_curve = {}, .edwards_seed = {}, .edwards_curve = {}, - .rsa_pss_restricted = parsed->rsa_pss_restricted}}; + .rsa_pss_restricted = parsed->rsa_pss_restricted, + .coordinate_x = {}, + .coordinate_y = {}}}; } case PKCS8KeyKind::EllipticCurve: { // RFC 5915 Section 3: ECPrivateKey is a SEQUENCE whose second field is @@ -493,16 +537,25 @@ auto make_private_key(const std::string_view pem) -> std::optional { return std::nullopt; } - return PrivateKey{new PrivateKey::Internal{ - .kind = PrivateKey::Type::EllipticCurve, - .modulus = {}, - .public_exponent = {}, - .private_exponent = {}, - .scalar = - std::string{pad_left(stripped_scalar, scalar_width, '\x00')}, - .elliptic_curve = parsed->curve, - .edwards_seed = {}, - .edwards_curve = {}}}; + auto padded_scalar{pad_left(stripped_scalar, scalar_width, '\x00')}; + const SecureStringScope padded_scalar_scope{padded_scalar}; + // A scalar at or above the curve order is not a valid private key, so it + // is rejected before it drives the public point recovery + if (!ec_private_scalar_in_range(padded_scalar, parsed->curve)) { + return std::nullopt; + } + auto point{ec_public_from_scalar(parsed->curve, padded_scalar)}; + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .modulus = {}, + .public_exponent = {}, + .private_exponent = {}, + .scalar = padded_scalar, + .elliptic_curve = parsed->curve, + .edwards_seed = {}, + .edwards_curve = {}, + .coordinate_x = std::move(point.first), + .coordinate_y = std::move(point.second)}}; } case PKCS8KeyKind::Edwards: { const auto seed{der_read(parsed->key)}; @@ -520,7 +573,9 @@ auto make_private_key(const std::string_view pem) -> std::optional { .scalar = {}, .elliptic_curve = {}, .edwards_seed = std::string{seed->content}, - .edwards_curve = parsed->edwards_curve}}; + .edwards_curve = parsed->edwards_curve, + .coordinate_x = {}, + .coordinate_y = {}}}; } } @@ -534,8 +589,6 @@ auto make_ec_private_key(const EllipticCurve curve, -> std::optional { const auto stripped{strip_left(scalar, '\x00')}; const auto width{curve_field_bytes(curve)}; - // The scalar alone drives signing here, but the public coordinates are still - // range-checked so that malformed input is rejected as on the other backends if (stripped.empty() || stripped.size() > width || strip_left(coordinate_x, '\x00').size() > width || strip_left(coordinate_y, '\x00').size() > width || @@ -543,15 +596,33 @@ auto make_ec_private_key(const EllipticCurve curve, return std::nullopt; } - return PrivateKey{new PrivateKey::Internal{ - .kind = PrivateKey::Type::EllipticCurve, - .modulus = {}, - .public_exponent = {}, - .private_exponent = {}, - .scalar = std::string{pad_left(stripped, width, '\x00')}, - .elliptic_curve = curve, - .edwards_seed = {}, - .edwards_curve = {}}}; + auto padded_scalar{pad_left(stripped, width, '\x00')}; + const SecureStringScope padded_scalar_scope{padded_scalar}; + // The stored public point must be the one the scalar actually generates, so a + // document pairing the scalar with a different or off-curve public key is + // rejected rather than trusted. A signature-based correspondence check cannot + // catch this on a deterministic backend, where an attacker can craft a valid + // point that verifies one precomputed signature (a duplicate-signature + // key-selection attack), so the point is recomputed and compared directly + const auto expected{ec_public_from_scalar(curve, padded_scalar)}; + if (pad_left(strip_left(coordinate_x, '\x00'), width, '\x00') != + expected.first || + pad_left(strip_left(coordinate_y, '\x00'), width, '\x00') != + expected.second) { + return std::nullopt; + } + + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .modulus = {}, + .public_exponent = {}, + .private_exponent = {}, + .scalar = std::string{padded_scalar}, + .elliptic_curve = curve, + .edwards_seed = {}, + .edwards_curve = {}, + .coordinate_x = expected.first, + .coordinate_y = expected.second}}; } auto make_edwards_private_key(const EdwardsCurve curve, @@ -568,7 +639,9 @@ auto make_edwards_private_key(const EdwardsCurve curve, .scalar = {}, .elliptic_curve = {}, .edwards_seed = std::string{seed}, - .edwards_curve = curve}}; + .edwards_curve = curve, + .coordinate_x = {}, + .coordinate_y = {}}}; } auto rsassa_pkcs1_v15_sign(const PrivateKey &key, @@ -642,4 +715,38 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) std::unreachable(); } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: + return make_rsa_public_key(internal->modulus, internal->public_exponent); + case PrivateKey::Type::EllipticCurve: + // The public coordinates are populated for every elliptic curve private + // key, whether supplied directly or recomputed from a PEM scalar at parse + // time, so the guard is a defensive check rather than a common path + if (internal->coordinate_x.empty()) { + return std::nullopt; + } + + return make_ec_public_key(internal->elliptic_curve, + internal->coordinate_x, internal->coordinate_y); + case PrivateKey::Type::Edwards: { + const auto point{internal->edwards_curve == EdwardsCurve::Ed25519 + ? edwards25519_public_key(internal->edwards_seed) + : edwards448_public_key(internal->edwards_seed)}; + if (!point.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(internal->edwards_curve, point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sign_rsa.cc b/vendor/core/src/core/crypto/crypto_sign_rsa.cc index da3701d4b..f04b87f98 100644 --- a/vendor/core/src/core/crypto/crypto_sign_rsa.cc +++ b/vendor/core/src/core/crypto/crypto_sign_rsa.cc @@ -54,7 +54,7 @@ auto make_rsa_private_key( // RSAPrivateKey (RFC 8017 Appendix A.1.2), whose version is zero for the // two-prime form std::string rsa_private_key_body; - const SecureScope rsa_private_key_body_scope{rsa_private_key_body}; + const SecureStringScope rsa_private_key_body_scope{rsa_private_key_body}; der_append_unsigned_integer(rsa_private_key_body, std::string_view{}); der_append_unsigned_integer(rsa_private_key_body, modulus); der_append_unsigned_integer(rsa_private_key_body, public_exponent); @@ -65,25 +65,25 @@ auto make_rsa_private_key( der_append_unsigned_integer(rsa_private_key_body, exponent2); der_append_unsigned_integer(rsa_private_key_body, coefficient); std::string rsa_private_key; - const SecureScope rsa_private_key_scope{rsa_private_key}; + const SecureStringScope rsa_private_key_scope{rsa_private_key}; der_append_element(rsa_private_key, 0x30, rsa_private_key_body); // PrivateKeyInfo (RFC 5958 Section 2), whose version is likewise zero std::string document_body; - const SecureScope document_body_scope{document_body}; + const SecureStringScope document_body_scope{document_body}; der_append_unsigned_integer(document_body, std::string_view{}); document_body.append( reinterpret_cast(RSA_ALGORITHM_IDENTIFIER.data()), RSA_ALGORITHM_IDENTIFIER.size()); der_append_element(document_body, 0x04, rsa_private_key); std::string document; - const SecureScope document_scope{document}; + const SecureStringScope document_scope{document}; der_append_element(document, 0x30, document_body); auto encoded{base64_encode(document)}; - const SecureScope encoded_scope{encoded}; + const SecureStringScope encoded_scope{encoded}; std::string pem{"-----BEGIN PRIVATE KEY-----\n"}; - const SecureScope pem_scope{pem}; + const SecureStringScope pem_scope{pem}; for (std::size_t offset{0}; offset < encoded.size(); offset += 64) { pem.append(encoded.substr(offset, 64)); pem.push_back('\n'); diff --git a/vendor/core/src/core/crypto/crypto_sign_windows.cc b/vendor/core/src/core/crypto/crypto_sign_windows.cc index 76d711c93..72205c0d8 100644 --- a/vendor/core/src/core/crypto/crypto_sign_windows.cc +++ b/vendor/core/src/core/crypto/crypto_sign_windows.cc @@ -11,6 +11,8 @@ #include // BCrypt*, BCRYPT_* +#include "crypto_windows.h" + #include // std::array #include // std::countl_zero #include // assert @@ -363,7 +365,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; const auto parsed{parse_pkcs8(der.value())}; if (!parsed.has_value()) { return std::nullopt; @@ -527,4 +529,63 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) std::unreachable(); } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: { + const auto blob{export_key_blob(internal->key, BCRYPT_RSAPUBLIC_BLOB)}; + if (!blob.has_value() || blob->size() < sizeof(BCRYPT_RSAKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_RSAKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + const std::size_t offset{sizeof(header)}; + if (blob->size() < offset + header.cbPublicExp + header.cbModulus) { + return std::nullopt; + } + + return make_rsa_public_key( + blob->substr(offset + header.cbPublicExp, header.cbModulus), + blob->substr(offset, header.cbPublicExp)); + } + case PrivateKey::Type::EllipticCurve: { + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + const auto blob{export_key_blob(internal->key, BCRYPT_ECCPUBLIC_BLOB)}; + if (!curve.has_value() || !blob.has_value() || + blob->size() < sizeof(BCRYPT_ECCKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_ECCKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + const std::size_t offset{sizeof(header)}; + const std::size_t width{header.cbKey}; + if (width != internal->field_bytes || + blob->size() < offset + (width * 2)) { + return std::nullopt; + } + + return make_ec_public_key(curve.value(), blob->substr(offset, width), + blob->substr(offset + width, width)); + } + case PrivateKey::Type::Edwards: { + const auto point{internal->edwards_curve == EdwardsCurve::Ed25519 + ? edwards25519_public_key(internal->edwards_seed) + : edwards448_public_key(internal->edwards_seed)}; + if (!point.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(internal->edwards_curve, point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_apple.cc b/vendor/core/src/core/crypto/crypto_verify_apple.cc index 2de9240f0..4f8b6e15f 100644 --- a/vendor/core/src/core/crypto/crypto_verify_apple.cc +++ b/vendor/core/src/core/crypto/crypto_verify_apple.cc @@ -1,6 +1,7 @@ #include #include +#include "crypto_apple.h" #include "crypto_der.h" #include "crypto_eddsa.h" #include "crypto_eddsa_apple.h" @@ -372,4 +373,62 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, std::unreachable(); } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + const auto der{copy_external_representation(internal->key)}; + if (!der.has_value()) { + return std::nullopt; + } + + auto components{der_read_rsa_public_key(der.value())}; + if (!components.has_value()) { + return std::nullopt; + } + + return RSAPublicComponents{.modulus = std::move(components->first), + .exponent = std::move(components->second)}; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + if (!curve.has_value()) { + return std::nullopt; + } + + // The X9.63 uncompressed point is the 0x04 lead byte followed by the two + // fixed-width coordinates + const auto point{copy_external_representation(internal->key)}; + if (!point.has_value() || point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return ECPublicComponents{ + .curve = curve.value(), + .x = point->substr(1, internal->field_bytes), + .y = point->substr(1 + internal->field_bytes, internal->field_bytes)}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + return EdwardsPublicComponents{.curve = internal->edwards_curve, + .point = internal->edwards_point}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_openssl.cc b/vendor/core/src/core/crypto/crypto_verify_openssl.cc index 51d5a7f44..8644c98d6 100644 --- a/vendor/core/src/core/crypto/crypto_verify_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_verify_openssl.cc @@ -2,6 +2,7 @@ #include #include "crypto_helpers.h" +#include "crypto_openssl.h" #include // BN_* #include // OSSL_PKEY_PARAM_* @@ -407,4 +408,69 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, return result; } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + BIGNUM *modulus{nullptr}; + BIGNUM *exponent{nullptr}; + if (EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_N, &modulus) != + 1 || + EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_E, &exponent) != + 1) { + BN_free(modulus); + BN_free(exponent); + return std::nullopt; + } + + RSAPublicComponents result{.modulus = bignum_to_bytes(modulus), + .exponent = bignum_to_bytes(exponent)}; + BN_free(modulus); + BN_free(exponent); + return result; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto curve{read_ec_curve(internal->key)}; + const auto point{read_public_point(internal->key)}; + if (!curve.has_value() || !point.has_value() || + point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return ECPublicComponents{ + .curve = curve.value(), + .x = point->substr(1, internal->field_bytes), + .y = point->substr(1 + internal->field_bytes, internal->field_bytes)}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + const auto point{read_public_point(internal->key)}; + if (!point.has_value()) { + return std::nullopt; + } + + const auto curve{internal->signature_bytes == + eddsa_signature_bytes(EdwardsCurve::Ed25519) + ? EdwardsCurve::Ed25519 + : EdwardsCurve::Ed448}; + return EdwardsPublicComponents{.curve = curve, .point = point.value()}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_other.cc b/vendor/core/src/core/crypto/crypto_verify_other.cc index 7f54aeba1..a0651343e 100644 --- a/vendor/core/src/core/crypto/crypto_verify_other.cc +++ b/vendor/core/src/core/crypto/crypto_verify_other.cc @@ -486,4 +486,41 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, std::unreachable(); } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + return RSAPublicComponents{.modulus = internal->modulus, + .exponent = internal->exponent}; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto width{curve_field_bytes(internal->elliptic_curve)}; + return ECPublicComponents{ + .curve = internal->elliptic_curve, + .x = pad_left(internal->coordinate_x, width, '\x00'), + .y = pad_left(internal->coordinate_y, width, '\x00')}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + // The Edwards public point is kept in the coordinate slot on this backend + return EdwardsPublicComponents{.curve = internal->edwards_curve, + .point = internal->coordinate_x}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_windows.cc b/vendor/core/src/core/crypto/crypto_verify_windows.cc index 6530c92cf..9b6deaffb 100644 --- a/vendor/core/src/core/crypto/crypto_verify_windows.cc +++ b/vendor/core/src/core/crypto/crypto_verify_windows.cc @@ -10,6 +10,8 @@ #include // BCrypt*, BCRYPT_* +#include "crypto_windows.h" + #include // std::countl_zero #include // assert #include // std::size_t @@ -370,4 +372,71 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, std::unreachable(); } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + const auto blob{export_key_blob(internal->key, BCRYPT_RSAPUBLIC_BLOB)}; + if (!blob.has_value() || blob->size() < sizeof(BCRYPT_RSAKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_RSAKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + // The public blob is the header followed by the exponent then the modulus + const std::size_t offset{sizeof(header)}; + if (blob->size() < offset + header.cbPublicExp + header.cbModulus) { + return std::nullopt; + } + + const auto exponent{blob->substr(offset, header.cbPublicExp)}; + const auto modulus{ + blob->substr(offset + header.cbPublicExp, header.cbModulus)}; + return RSAPublicComponents{ + .modulus = std::string{strip_left(modulus, '\x00')}, + .exponent = std::string{strip_left(exponent, '\x00')}}; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + const auto blob{export_key_blob(internal->key, BCRYPT_ECCPUBLIC_BLOB)}; + if (!curve.has_value() || !blob.has_value() || + blob->size() < sizeof(BCRYPT_ECCKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_ECCKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + // The public blob is the header followed by the two fixed-width coordinates + const std::size_t offset{sizeof(header)}; + const std::size_t width{header.cbKey}; + if (width != internal->field_bytes || blob->size() < offset + (width * 2)) { + return std::nullopt; + } + + return ECPublicComponents{.curve = curve.value(), + .x = blob->substr(offset, width), + .y = blob->substr(offset + width, width)}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + return EdwardsPublicComponents{.curve = internal->edwards_curve, + .point = internal->edwards_point}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_windows.h b/vendor/core/src/core/crypto/crypto_windows.h new file mode 100644 index 000000000..4afae01ae --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_windows.h @@ -0,0 +1,43 @@ +#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 + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include // ULONG, LPCWSTR + +#include // BCryptExportKey, BCRYPT_KEY_HANDLE, BCRYPT_SUCCESS + +#include // std::optional, std::nullopt +#include // std::string + +namespace sourcemeta::core { + +// 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) + -> std::optional { + ULONG size{0}; + if (!BCRYPT_SUCCESS( + BCryptExportKey(key, nullptr, blob_type, nullptr, 0, &size, 0))) { + return std::nullopt; + } + + std::string blob; + blob.resize(size); + if (!BCRYPT_SUCCESS(BCryptExportKey( + key, nullptr, blob_type, + reinterpret_cast(blob.data()), size, &size, 0))) { + return std::nullopt; + } + + blob.resize(size); + return blob; +} + +} // namespace sourcemeta::core + +#endif 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 9baf31a3a..be947c010 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include 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 d54c90c6a..2183182b8 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 @@ -5,6 +5,8 @@ #include #endif +#include + #include // std::optional #include // std::ostream #include // std::string @@ -40,6 +42,22 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input, auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input) -> std::string; +/// @ingroup crypto +/// Encode a byte sequence using Base64 (RFC 4648 Section 4), appending to a +/// wiping string so that an encoded secret is never held in ordinary storage. +/// The output must not alias the input. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString result; +/// sourcemeta::core::base64_encode("foobar", result); +/// assert(result == "Zm9vYmFy"); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input, + SecureString &output) -> void; + /// @ingroup crypto /// Decode a Base64 string (RFC 4648 Section 4), returning no value unless the /// input is a canonical padded encoding. For example: @@ -55,6 +73,24 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input) auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_decode(const std::string_view input) -> std::optional; +/// @ingroup crypto +/// Decode a Base64 string (RFC 4648 Section 4) into a wiping string, for a +/// decoded value that is secret such as a client credential, returning whether +/// the input is a canonical padded encoding. On success the decoded bytes are +/// appended, and on failure the output is left with its original contents, so a +/// reused buffer never keeps a partial decode. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString result; +/// assert(sourcemeta::core::base64_decode("Zm9vYmFy", result)); +/// assert(result == "foobar"); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_decode(const std::string_view input, + SecureString &output) -> bool; + /// @ingroup crypto /// Encode a byte sequence using unpadded Base64url (RFC 4648 Section 5) into a /// stream. For example: @@ -84,6 +120,22 @@ 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 +/// 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 +/// an intermediate buffer. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString result; +/// sourcemeta::core::base64url_encode("fo", result); +/// assert(result == "Zm8"); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT +base64url_encode(const std::string_view input, SecureString &output) -> void; + /// @ingroup crypto /// Decode an unpadded Base64url string (RFC 4648 Section 5), returning no /// value unless the input is a canonical encoding. For example: diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h new file mode 100644 index 000000000..f7feb179f --- /dev/null +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h @@ -0,0 +1,267 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_SECURE_H_ +#define SOURCEMETA_CORE_CRYPTO_SECURE_H_ + +#ifndef SOURCEMETA_CORE_CRYPTO_EXPORT +#include +#endif + +#include // std::size_t +#include // std::less +#include // std::numeric_limits +#include // operator new, operator delete, std::align_val_t +#include // std::string +#include // std::string_view +#include // std::vector + +namespace sourcemeta::core { + +/// @ingroup crypto +/// Overwrite a buffer that held secret material with zeroes, so it does not +/// linger in memory after it is no longer needed. The write goes through a +/// volatile access, so the compiler does not elide it as a dead store. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::array buffer{{1, 2, 3, 4}}; +/// sourcemeta::core::secure_zero(buffer.data(), buffer.size()); +/// ``` +inline auto secure_zero(void *const data, const std::size_t size) noexcept + -> void { + if (data == nullptr) { + return; + } + + auto *pointer{static_cast(data)}; + for (std::size_t index{0}; index < size; index += 1) { + pointer[index] = 0; + } +} + +/// @ingroup crypto +/// Overwrite the storage a string owns with zeroes, so a secret it held does +/// not linger in memory. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string secret{"hunter2"}; +/// sourcemeta::core::secure_zero(secret); +/// assert(secret == std::string(7, '\x00')); +/// ``` +inline auto secure_zero(std::string &value) noexcept -> void { + secure_zero(value.data(), value.size()); +} + +/// @ingroup crypto +/// Overwrite the referenced string when leaving the current scope, so secret +/// material a local holds is wiped across every return path without threading +/// a manual call through each one. It clears only the live bytes the string +/// owns at scope exit, so a reassignment, an in-place shrink, or a growth that +/// reallocates before then can still leave earlier bytes in freed memory or in +/// the capacity beyond the final length, a residual that only a wiping +/// allocator closes. Prefer a wiping string for secrets that change size. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string secret{"hunter2"}; +/// { +/// const sourcemeta::core::SecureStringScope scope{secret}; +/// } +/// assert(secret == std::string(7, '\x00')); +/// ``` +struct SecureStringScope { + /// Capture the string to wipe when leaving the current scope. + explicit SecureStringScope(std::string &value) noexcept : target{value} {} + SecureStringScope(const SecureStringScope &) = delete; + auto operator=(const SecureStringScope &) -> SecureStringScope & = delete; + SecureStringScope(SecureStringScope &&) = delete; + auto operator=(SecureStringScope &&) -> SecureStringScope & = delete; + ~SecureStringScope() { secure_zero(this->target); } + /// The captured string, whose storage is wiped at scope exit. + std::string ⌖ +}; + +/// @ingroup crypto +/// A standard allocator that wipes every block it owns before releasing it, so +/// a secret the storage held does not survive in freed memory. The deallocation +/// covers the whole block, so a reallocation, a reassignment, or the +/// destruction of the owner all clear the earlier bytes, closing the residue +/// that the scope-based cleanup above cannot reach on its own. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// std::vector> secret; +/// secret.push_back('x'); +/// ``` +template struct SecureAllocator { + /// The type of object this allocator hands out storage for. + using value_type = T; + + /// Construct an allocator, which holds no state of its own. + SecureAllocator() noexcept = default; + + /// Construct from an allocator for another type, as the containers require. + template + constexpr SecureAllocator(const SecureAllocator &) noexcept {} + + /// Allocate storage for the given number of objects. + [[nodiscard]] auto allocate(const std::size_t count) -> T * { + if (count > std::numeric_limits::max() / sizeof(T)) { + throw std::bad_array_new_length{}; + } + + return static_cast( + ::operator new(count * sizeof(T), std::align_val_t{alignof(T)})); + } + + /// Wipe and release the storage of the given number of objects. + auto deallocate(T *const pointer, const std::size_t count) noexcept -> void { + secure_zero(pointer, count * sizeof(T)); + ::operator delete(pointer, std::align_val_t{alignof(T)}); + } + + /// Every instance is interchangeable, since the allocator holds no state. + template + auto operator==(const SecureAllocator &) const noexcept -> bool { + return true; + } + + /// Every instance is interchangeable, since the allocator holds no state. + template + auto operator!=(const SecureAllocator &) const noexcept -> bool { + return false; + } +}; + +/// @ingroup crypto +/// A string whose bytes always live in heap storage that is wiped whenever it +/// is released, so a secret it holds never reaches freed memory when the string +/// reallocates on growth, is reassigned, or is destroyed, and never lingers in +/// an inline buffer. Bytes abandoned by an in-place shrink stay in the still +/// owned block only until that block is next reallocated or freed, when they +/// too are wiped. For example: +/// +/// ```cpp +/// #include +/// +/// sourcemeta::core::SecureString secret{"hunter2"}; +/// secret.append("!"); +/// ``` +class SecureString { +public: + /// The type that counts and indexes the held bytes. + using size_type = std::vector>::size_type; + + /// Construct an empty string. + SecureString() = default; + + /// Construct from a view of bytes. + SecureString(const std::string_view value) + : buffer_(value.begin(), value.end()) {} + + /// Construct from a pointer and a length. + SecureString(const char *const data, const size_type length) + : buffer_(data, data + length) {} + + /// Construct a run of a repeated byte. + SecureString(const size_type count, const char value) + : buffer_(count, value) {} + + /// The number of bytes held. + [[nodiscard]] auto size() const noexcept -> size_type { + return this->buffer_.size(); + } + + /// Whether no bytes are held. + [[nodiscard]] auto empty() const noexcept -> bool { + return this->buffer_.empty(); + } + + /// Reserve storage for at least the given number of bytes. + auto reserve(const size_type capacity) -> void { + this->buffer_.reserve(capacity); + } + + /// Resize to the given number of bytes, padding new ones with the given + /// value. + auto resize(const size_type count, const char value) -> void { + this->buffer_.resize(count, value); + } + + /// Append a single byte. + auto push_back(const char value) -> void { this->buffer_.push_back(value); } + + /// Append a view of bytes. + auto append(const std::string_view value) -> void { + // Inserting a range whose iterators point into this container is undefined, + // so a view that aliases the storage is taken through an independent buffer + // that wipes itself, while an independent view is inserted directly. The + // ordering uses the total order over pointers, which is defined even for + // pointers into different objects + const char *const first{this->buffer_.data()}; + const std::less before{}; + if (!this->buffer_.empty() && !before(value.data(), first) && + before(value.data(), first + this->buffer_.size())) { + const std::vector> copy(value.begin(), + value.end()); + this->buffer_.insert(this->buffer_.end(), copy.begin(), copy.end()); + } else { + this->buffer_.insert(this->buffer_.end(), value.begin(), value.end()); + } + } + + /// Append a run of a repeated byte. + auto append(const size_type count, const char value) -> void { + this->buffer_.insert(this->buffer_.end(), count, value); + } + + /// A reference to the byte at the given index. + [[nodiscard]] auto operator[](const size_type index) noexcept -> char & { + return this->buffer_[index]; + } + + /// The byte at the given index. + [[nodiscard]] auto operator[](const size_type index) const noexcept -> char { + return this->buffer_[index]; + } + + /// The first byte. + [[nodiscard]] auto front() const noexcept -> char { + return this->buffer_.front(); + } + + /// The last byte. + [[nodiscard]] auto back() const noexcept -> char { + return this->buffer_.back(); + } + + /// A view over the held bytes. + [[nodiscard]] operator std::string_view() const noexcept { + return {this->buffer_.data(), this->buffer_.size()}; + } + + /// Whether the held bytes equal the given view. + [[nodiscard]] auto operator==(const std::string_view other) const noexcept + -> bool { + return std::string_view{*this} == other; + } + +private: + std::vector> buffer_; +}; + +} // 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 5447737ce..ab2786597 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 @@ -106,6 +106,24 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT make_rsa_private_key( const std::string_view exponent2, const std::string_view coefficient) -> std::optional; +/// @ingroup crypto +/// Derive the public key from a private key, returning no value when the public +/// part cannot be produced. The derived key exports its components through the +/// public component functions, so a private key parsed from a PEM document can +/// still be rendered to a public key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_private_key(pem)}; +/// assert(key.has_value()); +/// const auto public_key{sourcemeta::core::derive_public_key(key.value())}; +/// assert(public_key.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT derive_public_key(const PrivateKey &key) + -> std::optional; + /// @ingroup crypto /// Produce an RSASSA-PKCS1-v1_5 signature (RFC 8017 Section 8.2.1) over a /// message, returning no value when the key is not an RSA key. For example: diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h index 3e1dedb62..5f048a286 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h @@ -7,6 +7,7 @@ #include // std::uint8_t #include // std::optional +#include // std::string #include // std::string_view namespace sourcemeta::core { @@ -115,6 +116,89 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT make_eddsa_public_key( const EdwardsCurve curve, const std::string_view public_key) -> std::optional; +/// @ingroup crypto +/// The public components of an RSA key, as raw big-endian bytes in minimal +/// form. +struct RSAPublicComponents { + /// The RSA modulus. + std::string modulus; + /// The RSA public exponent. + std::string exponent; +}; + +/// @ingroup crypto +/// The public coordinates of an elliptic curve key, each padded to the curve +/// field width, together with the curve they belong to. +struct ECPublicComponents { + /// The curve the coordinates belong to. + EllipticCurve curve; + /// The x coordinate. + std::string x; + /// The y coordinate. + std::string y; +}; + +/// @ingroup crypto +/// The public point of an Edwards-curve key, together with the curve it +/// belongs to. +struct EdwardsPublicComponents { + /// The curve the point belongs to. + EdwardsCurve curve; + /// The encoded public point. + std::string point; +}; + +/// @ingroup crypto +/// Extract the public components of an RSA key, returning no value when the key +/// is not an RSA key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_rsa_public_key(modulus, exponent)}; +/// assert(key.has_value()); +/// const auto components{sourcemeta::core::rsa_public_components(key.value())}; +/// assert(components.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT rsa_public_components(const PublicKey &key) + -> std::optional; + +/// @ingroup crypto +/// Extract the public coordinates of an elliptic curve key, returning no value +/// when the key is not an elliptic curve key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_ec_public_key( +/// sourcemeta::core::EllipticCurve::P256, x, y)}; +/// assert(key.has_value()); +/// const auto components{sourcemeta::core::ec_public_components(key.value())}; +/// assert(components.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT ec_public_components(const PublicKey &key) + -> std::optional; + +/// @ingroup crypto +/// Extract the public point of an Edwards-curve key, returning no value when +/// the key is not an Edwards-curve key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_eddsa_public_key( +/// sourcemeta::core::EdwardsCurve::Ed25519, public_key)}; +/// assert(key.has_value()); +/// const auto components{ +/// sourcemeta::core::edwards_public_components(key.value())}; +/// assert(components.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT edwards_public_components( + const PublicKey &key) -> std::optional; + /// @ingroup crypto /// Verify an RSASSA-PKCS1-v1_5 signature (RFC 8017 Section 8.2.2) over a /// message with the given RSA key. The signature is invalid rather than an diff --git a/vendor/core/src/core/http/CMakeLists.txt b/vendor/core/src/core/http/CMakeLists.txt index ad184a816..e3fe59f4e 100644 --- a/vendor/core/src/core/http/CMakeLists.txt +++ b/vendor/core/src/core/http/CMakeLists.txt @@ -10,11 +10,12 @@ endif() sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME http PRIVATE_HEADERS problem.h status.h method.h message.h error.h system.h - aws_sigv4.h + aws_sigv4.h syntax.h SOURCES helpers.h problem.cc match_accept.cc match_accept_language.cc negotiate_encoding.cc from_date.cc format_link.cc field_list.cc accept_includes_all.cc content_type_matches.cc parse_bearer.cc cache_control_max_age.cc serialize_cookie.cc aws_sigv4.cc + scan_quoted_string.cc ${SOURCEMETA_CORE_HTTP_CLIENT_SOURCE}) if(SOURCEMETA_CORE_INSTALL) diff --git a/vendor/core/src/core/http/helpers.h b/vendor/core/src/core/http/helpers.h index 2a607910b..1d5776646 100644 --- a/vendor/core/src/core/http/helpers.h +++ b/vendor/core/src/core/http/helpers.h @@ -1,6 +1,7 @@ #ifndef SOURCEMETA_CORE_HTTP_HELPERS_H_ #define SOURCEMETA_CORE_HTTP_HELPERS_H_ +#include #include #include // std::size_t @@ -12,10 +13,6 @@ // NOLINTBEGIN(bugprone-suspicious-stringview-data-usage) namespace sourcemeta::core { -inline auto http_is_ows(const char character) noexcept -> bool { - return character == ' ' || character == '\t'; -} - inline auto http_subview(const std::string_view value, const std::size_t offset, const std::size_t length) noexcept -> std::string_view { @@ -50,24 +47,6 @@ inline auto http_media_specificity(const std::string_view range, return 2; } -inline auto http_trim_trailing_ows(const std::string_view value) noexcept - -> std::string_view { - std::size_t size{value.size()}; - while (size > 0 && http_is_ows(value[size - 1])) { - --size; - } - return http_subview(value, 0, size); -} - -inline auto http_trim_leading_ows(const std::string_view value) noexcept - -> std::string_view { - std::size_t position{0}; - while (position < value.size() && http_is_ows(value[position])) { - ++position; - } - return http_subview(value, position, value.size() - position); -} - template inline auto http_for_each_list_entry(const std::string_view header, Visitor visit) -> void { diff --git a/vendor/core/src/core/http/include/sourcemeta/core/http.h b/vendor/core/src/core/http/include/sourcemeta/core/http.h index 94629f83d..c59121bea 100644 --- a/vendor/core/src/core/http/include/sourcemeta/core/http.h +++ b/vendor/core/src/core/http/include/sourcemeta/core/http.h @@ -12,6 +12,7 @@ #include #include #include +#include #include // NOLINTEND(misc-include-cleaner) diff --git a/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h b/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h new file mode 100644 index 000000000..467805620 --- /dev/null +++ b/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h @@ -0,0 +1,251 @@ +#ifndef SOURCEMETA_CORE_HTTP_SYNTAX_H_ +#define SOURCEMETA_CORE_HTTP_SYNTAX_H_ + +#ifndef SOURCEMETA_CORE_HTTP_EXPORT +#include +#endif + +#include + +#include // std::size_t +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup http +/// Whether a character is optional whitespace, a space or a horizontal tab +/// (RFC 9110 Section 5.6.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_ows(' ')); +/// assert(!sourcemeta::core::http_is_ows('x')); +/// ``` +inline auto http_is_ows(const char character) noexcept -> bool { + return character == ' ' || character == '\t'; +} + +/// @ingroup http +/// Whether a character is a token character (RFC 9110 Section 5.6.2), the set a +/// bare header token draws from. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_tchar('a')); +/// assert(!sourcemeta::core::http_is_tchar('"')); +/// ``` +inline auto http_is_tchar(const char character) noexcept -> bool { + switch (character) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return is_alphanum(character); + } +} + +/// @ingroup http +/// Whether a character may appear in the alphabet of a token68 (RFC 7235 +/// Section 2.1) or b64token (RFC 6750 Section 2.1) credential, before its +/// padding. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_b64token_char('/')); +/// assert(!sourcemeta::core::http_is_b64token_char('=')); +/// ``` +inline auto http_is_b64token_char(const char character) noexcept -> bool { + return is_alphanum(character) || character == '-' || character == '.' || + character == '_' || character == '~' || character == '+' || + character == '/'; +} + +/// @ingroup http +/// Whether a string is a well-formed token68 (RFC 7235 Section 2.1) or b64token +/// (RFC 6750 Section 2.1), at least one alphabet character followed by any "=" +/// padding. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_b64token("YWJj==")); +/// assert(!sourcemeta::core::http_is_b64token("a b")); +/// ``` +inline auto http_is_b64token(const std::string_view value) noexcept -> bool { + std::size_t position{0}; + while (position < value.size() && http_is_b64token_char(value[position])) { + position += 1; + } + + if (position == 0) { + return false; + } + + while (position < value.size()) { + if (value[position] != '=') { + return false; + } + + position += 1; + } + + return true; +} + +/// @ingroup http +/// Whether a string is a well-formed token (RFC 9110 Section 5.6.2), at least +/// one token character and nothing else. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_token("Bearer")); +/// assert(!sourcemeta::core::http_is_token("has space")); +/// ``` +inline auto http_is_token(const std::string_view value) noexcept -> bool { + if (value.empty()) { + return false; + } + + for (const auto character : value) { + if (!http_is_tchar(character)) { + return false; + } + } + + return true; +} + +/// @ingroup http +/// Append a string to the sink as a quoted-string (RFC 9110 Section 5.6.4), +/// wrapping it in double quotes and escaping any double quote or backslash, +/// returning whether it was encodable. Nothing is appended when the string +/// carries a byte no quoted-string admits, such as a control character, which +/// keeps a value from injecting into a header. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string sink; +/// assert(sourcemeta::core::http_encode_quoted_string(R"(a"b)", sink)); +/// assert(sink == R"("a\"b")"); +/// ``` +inline auto http_encode_quoted_string(const std::string_view value, + std::string &sink) -> bool { + for (const auto character : value) { + const auto byte{static_cast(character)}; + // RFC 9110 Section 5.6.4: qdtext and quoted-pair admit HTAB, SP through + // VCHAR, and obs-text, so every other control character (CR and LF among + // them) makes the value unencodable + if (byte != 0x09 && (byte < 0x20 || byte == 0x7F)) { + return false; + } + } + + sink.push_back('"'); + for (const auto character : value) { + if (character == '"' || character == '\\') { + sink.push_back('\\'); + } + + sink.push_back(character); + } + + sink.push_back('"'); + return true; +} + +/// @ingroup http +/// The view with any leading optional whitespace removed (RFC 9110 +/// Section 5.6.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_trim_leading_ows(" x") == "x"); +/// ``` +inline auto http_trim_leading_ows(std::string_view value) noexcept + -> std::string_view { + while (!value.empty() && http_is_ows(value.front())) { + value.remove_prefix(1); + } + + return value; +} + +/// @ingroup http +/// The view with any trailing optional whitespace removed (RFC 9110 +/// Section 5.6.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_trim_trailing_ows("x ") == "x"); +/// ``` +inline auto http_trim_trailing_ows(std::string_view value) noexcept + -> std::string_view { + while (!value.empty() && http_is_ows(value.back())) { + value.remove_suffix(1); + } + + return value; +} + +/// @ingroup http +/// Scan a quoted-string (RFC 9110 Section 5.6.4) whose opening double quote is +/// at position in input, returning the position just past the closing quote, or +/// no value when it is malformed. The unescaped content is written to value, +/// borrowing from input when it carries no quoted-pair and appending to storage +/// otherwise, whose capacity should be reserved up front so that earlier views +/// into it stay valid. Control characters other than horizontal tab are +/// rejected as a header-injection defense. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// std::string_view value; +/// const auto next{sourcemeta::core::http_scan_quoted_string( +/// R"("hello")", 0, storage, value)}; +/// assert(next.has_value()); +/// assert(value == "hello"); +/// ``` +SOURCEMETA_CORE_HTTP_EXPORT +auto http_scan_quoted_string(const std::string_view input, + const std::size_t position, std::string &storage, + std::string_view &value) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/http/parse_bearer.cc b/vendor/core/src/core/http/parse_bearer.cc index cb6c8bca7..3c6761121 100644 --- a/vendor/core/src/core/http/parse_bearer.cc +++ b/vendor/core/src/core/http/parse_bearer.cc @@ -5,41 +5,6 @@ #include // std::size_t #include // std::string_view -namespace { - -auto is_b64token_character(const char character) noexcept -> bool { - return (character >= 'A' && character <= 'Z') || - (character >= 'a' && character <= 'z') || - (character >= '0' && character <= '9') || character == '-' || - character == '.' || character == '_' || character == '~' || - character == '+' || character == '/'; -} - -// RFC 6750 §2.1: b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / -// "/" ) *"=". At least one character from the alphabet, followed by optional -// trailing padding. -auto is_b64token(const std::string_view token) noexcept -> bool { - std::size_t position{0}; - while (position < token.size() && is_b64token_character(token[position])) { - ++position; - } - - if (position == 0) { - return false; - } - - while (position < token.size()) { - if (token[position] != '=') { - return false; - } - ++position; - } - - return true; -} - -} // namespace - namespace sourcemeta::core { auto http_parse_bearer(const std::string_view authorization) noexcept @@ -58,7 +23,7 @@ auto http_parse_bearer(const std::string_view authorization) noexcept const auto token{http_trim_trailing_ows(http_trim_leading_ows( http_subview(authorization, scheme.size() + 1, authorization.size() - scheme.size() - 1)))}; - if (!is_b64token(token)) { + if (!http_is_b64token(token)) { return {}; } diff --git a/vendor/core/src/core/http/scan_quoted_string.cc b/vendor/core/src/core/http/scan_quoted_string.cc new file mode 100644 index 000000000..c0da05909 --- /dev/null +++ b/vendor/core/src/core/http/scan_quoted_string.cc @@ -0,0 +1,80 @@ +#include + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace { + +// RFC 9110 Section 5.6.4: qdtext and the escaped octet of a quoted-pair both +// admit HTAB, SP, the visible characters, and obs-text, so every other control +// character is rejected. The double quote and the backslash are handled by the +// caller before this check +auto http_is_quoted_content(const char character) noexcept -> bool { + const auto byte{static_cast(character)}; + return byte == 0x09 || (byte >= 0x20 && byte != 0x7f); +} + +} // namespace + +namespace sourcemeta::core { + +auto http_scan_quoted_string(const std::string_view input, + const std::size_t position, std::string &storage, + std::string_view &value) + -> std::optional { + if (position >= input.size() || input[position] != '"') { + return std::nullopt; + } + + const std::size_t content_start{position + 1}; + std::size_t scan{content_start}; + bool escaped{false}; + while (scan < input.size()) { + const char character{input[scan]}; + if (character == '\\') { + if (scan + 1 >= input.size() || + !http_is_quoted_content(input[scan + 1])) { + return std::nullopt; + } + + escaped = true; + scan += 2; + continue; + } + + if (character == '"') { + if (!escaped) { + value = input.substr(content_start, scan - content_start); + } else { + const std::size_t base{storage.size()}; + storage.reserve(base + (scan - content_start)); + std::size_t index{content_start}; + while (index < scan) { + if (input[index] == '\\') { + storage.push_back(input[index + 1]); + index += 2; + } else { + storage.push_back(input[index]); + index += 1; + } + } + + value = std::string_view{storage}.substr(base); + } + + return scan + 1; + } + + if (!http_is_quoted_content(character)) { + return std::nullopt; + } + + scan += 1; + } + + return std::nullopt; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/http/serialize_cookie.cc b/vendor/core/src/core/http/serialize_cookie.cc index 008b653ac..21e342c30 100644 --- a/vendor/core/src/core/http/serialize_cookie.cc +++ b/vendor/core/src/core/http/serialize_cookie.cc @@ -1,6 +1,5 @@ #include #include -#include #include // std::array #include // std::to_chars @@ -14,31 +13,6 @@ namespace { -auto is_token_character(const char character) noexcept -> bool { - // RFC 9110 §5.6.2: tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / - // "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA - switch (character) { - case '!': - case '#': - case '$': - case '%': - case '&': - case '\'': - case '*': - case '+': - case '-': - case '.': - case '^': - case '_': - case '`': - case '|': - case '~': - return true; - default: - return sourcemeta::core::is_alphanum(character); - } -} - auto is_cookie_octet(const char character) noexcept -> bool { // RFC 6265 §4.1.1: cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / // %x5D-7E, that is US-ASCII excluding CTLs, whitespace, DQUOTE, comma, @@ -57,20 +31,6 @@ auto is_attribute_octet(const char character) noexcept -> bool { return value >= 0x20 && value <= 0x7E && character != ';'; } -auto is_token(const std::string_view value) noexcept -> bool { - if (value.empty()) { - return false; - } - - for (const auto character : value) { - if (!is_token_character(character)) { - return false; - } - } - - return true; -} - auto is_cookie_value(const std::string_view value) noexcept -> bool { // RFC 6265 §4.1.1: cookie-value is either bare cookie-octets or the same // wrapped in a matched pair of double quotes @@ -160,7 +120,8 @@ auto required_size(const sourcemeta::core::HTTPCookie &cookie, namespace sourcemeta::core { auto http_cookie_valid(const HTTPCookie &cookie) -> bool { - if (!is_token(cookie.name) || !is_cookie_value(cookie.value)) { + if (!sourcemeta::core::http_is_token(cookie.name) || + !is_cookie_value(cookie.value)) { return false; } diff --git a/vendor/core/src/core/jose/CMakeLists.txt b/vendor/core/src/core/jose/CMakeLists.txt index 2d11654cb..ea752575d 100644 --- a/vendor/core/src/core/jose/CMakeLists.txt +++ b/vendor/core/src/core/jose/CMakeLists.txt @@ -2,7 +2,7 @@ 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 SOURCES jose_algorithm.cc jose_jwk.cc jose_jwk_private.cc jose_jwks.cc - jose_jwks_provider.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) 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 43d41b27d..bef1d3dae 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 @@ -61,6 +61,38 @@ SOURCEMETA_CORE_JOSE_EXPORT auto to_jws_algorithm(const std::string_view value) noexcept -> std::optional; +/// @ingroup jose +/// Map a JSON Web Signature algorithm to its `alg` value, the inverse of +/// parsing (RFC 7515 Section 4.1.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jws_algorithm_name( +/// sourcemeta::core::JWSAlgorithm::ES256) == "ES256"); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jws_algorithm_name(const JWSAlgorithm algorithm) noexcept + -> std::string_view; + +/// @ingroup jose +/// Whether an algorithm is an asymmetric digital signature algorithm rather +/// than a symmetric message authentication code (RFC 7518 Section 3.1). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jws_algorithm_is_asymmetric( +/// sourcemeta::core::JWSAlgorithm::ES256)); +/// assert(!sourcemeta::core::jws_algorithm_is_asymmetric( +/// sourcemeta::core::JWSAlgorithm::HS256)); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jws_algorithm_is_asymmetric(const JWSAlgorithm algorithm) noexcept -> bool; + } // 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 634b015c1..cd13d7cf3 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 @@ -114,6 +114,14 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWK { return this->secret_; } + /// Serialize the public part of this key as a JSON Web Key (RFC 7517), + /// returning no value for a symmetric key, which has no public form. + [[nodiscard]] auto public_jwk() const -> std::optional; + + /// The SHA-256 JSON Web Key thumbprint of this key (RFC 7638), + /// base64url-encoded. + [[nodiscard]] auto thumbprint() const -> std::optional; + private: JWK() = default; static auto parse(const JSON &value, JWK &result) -> bool; @@ -127,6 +135,11 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWK { std::string curve_; std::optional public_key_; std::string secret_; + std::string modulus_; + std::string exponent_; + std::string coordinate_x_; + std::string coordinate_y_; + std::string public_point_; #if defined(_MSC_VER) #pragma warning(default : 4251) #endif 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 7d60b98df..6ac81e71a 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 @@ -64,8 +64,8 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { [[nodiscard]] static auto from(JSON &&value) -> std::optional; /// Parse a private key from an unencrypted PKCS#8 PEM document (RFC 5958), - /// returning no value on invalid input. The key identifier, algorithm, and - /// curve are left unset, as a PEM document carries no such metadata. + /// returning no value on invalid input. The key identifier and algorithm are + /// left unset, as a PEM document carries no such metadata. [[nodiscard]] static auto from_pem(const std::string_view pem) -> std::optional; @@ -89,7 +89,9 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { // Elliptic curve keys (RFC 7518 Section 6.2) and octet key pairs (RFC 8037 // Section 2) carry a curve name, which the elliptic curve algorithms pin to - // exactly one curve. It is empty for keys parsed from a PEM document + // exactly one curve. A key parsed from a PEM document carries it once its + // public part has been recovered, and it stays empty for a key that carries + // no curve, such as an RSA or symmetric key /// The curve this key is pinned to, empty when it carries none. [[nodiscard]] auto curve() const noexcept -> std::string_view { return this->curve_; @@ -111,6 +113,16 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { return this->secret_; } + /// Serialize the public part of this key as a JSON Web Key (RFC 7517), + /// returning no value for a symmetric key, which has no public form, or for a + /// key parsed from a PEM document whose public part could not be recovered. + [[nodiscard]] auto public_jwk() const -> std::optional; + + /// The SHA-256 JSON Web Key thumbprint of this key (RFC 7638), + /// base64url-encoded, returning no value for a key whose public part could + /// not be recovered. + [[nodiscard]] auto thumbprint() const -> std::optional; + private: JWKPrivate() = default; static auto parse(const JSON &value, JWKPrivate &result) -> bool; @@ -124,6 +136,11 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { std::string curve_; std::optional private_key_; std::string secret_; + std::string modulus_; + std::string exponent_; + std::string coordinate_x_; + std::string coordinate_y_; + std::string public_point_; #if defined(_MSC_VER) #pragma warning(default : 4251) #endif diff --git a/vendor/core/src/core/jose/jose_algorithm.cc b/vendor/core/src/core/jose/jose_algorithm.cc index f74f8bb79..8044a08c9 100644 --- a/vendor/core/src/core/jose/jose_algorithm.cc +++ b/vendor/core/src/core/jose/jose_algorithm.cc @@ -2,6 +2,7 @@ #include // std::optional, std::nullopt #include // std::string_view +#include // std::unreachable namespace sourcemeta::core { @@ -38,4 +39,61 @@ auto to_jws_algorithm(const std::string_view value) noexcept } } +auto jws_algorithm_name(const JWSAlgorithm algorithm) noexcept + -> std::string_view { + switch (algorithm) { + case JWSAlgorithm::RS256: + return "RS256"; + case JWSAlgorithm::RS384: + return "RS384"; + case JWSAlgorithm::RS512: + return "RS512"; + case JWSAlgorithm::PS256: + return "PS256"; + case JWSAlgorithm::PS384: + return "PS384"; + case JWSAlgorithm::PS512: + return "PS512"; + case JWSAlgorithm::ES256: + return "ES256"; + case JWSAlgorithm::ES384: + return "ES384"; + case JWSAlgorithm::ES512: + return "ES512"; + case JWSAlgorithm::EdDSA: + return "EdDSA"; + case JWSAlgorithm::HS256: + return "HS256"; + case JWSAlgorithm::HS384: + return "HS384"; + case JWSAlgorithm::HS512: + return "HS512"; + } + + std::unreachable(); +} + +auto jws_algorithm_is_asymmetric(const JWSAlgorithm algorithm) noexcept + -> bool { + switch (algorithm) { + case JWSAlgorithm::RS256: + case JWSAlgorithm::RS384: + case JWSAlgorithm::RS512: + case JWSAlgorithm::PS256: + case JWSAlgorithm::PS384: + case JWSAlgorithm::PS512: + case JWSAlgorithm::ES256: + case JWSAlgorithm::ES384: + case JWSAlgorithm::ES512: + case JWSAlgorithm::EdDSA: + return true; + case JWSAlgorithm::HS256: + case JWSAlgorithm::HS384: + case JWSAlgorithm::HS512: + return false; + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwk.cc b/vendor/core/src/core/jose/jose_jwk.cc index 79d5158ee..de344187f 100644 --- a/vendor/core/src/core/jose/jose_jwk.cc +++ b/vendor/core/src/core/jose/jose_jwk.cc @@ -92,8 +92,9 @@ auto JWK::parse(const JSON &value, JWK &result) -> bool { } result.type_ = Type::RSA; - parsed_key = - make_rsa_public_key(decoded_modulus.value(), decoded_exponent.value()); + result.modulus_ = decoded_modulus.value(); + result.exponent_ = decoded_exponent.value(); + parsed_key = make_rsa_public_key(result.modulus_, result.exponent_); } else if (key_type_value == "EC") { // A public key must not carry the private parameter (RFC 7518 Section // 6.2.2) @@ -126,8 +127,10 @@ auto JWK::parse(const JSON &value, JWK &result) -> bool { result.type_ = Type::EllipticCurve; result.curve_ = curve->to_string(); + result.coordinate_x_ = decoded_x.value(); + result.coordinate_y_ = decoded_y.value(); parsed_key = make_ec_public_key(jwk_to_elliptic_curve(result.curve_), - decoded_x.value(), decoded_y.value()); + result.coordinate_x_, result.coordinate_y_); } else if (key_type_value == "OKP") { // A public key must not carry the private parameter (RFC 8037 Section 2) if (value.try_at("d", HASH_D) != nullptr) { @@ -154,8 +157,9 @@ auto JWK::parse(const JSON &value, JWK &result) -> bool { result.type_ = Type::OctetKeyPair; result.curve_ = curve->to_string(); + result.public_point_ = decoded_public_key.value(); parsed_key = make_eddsa_public_key(jwk_to_edwards_curve(result.curve_), - decoded_public_key.value()); + result.public_point_); } else if (key_type_value == "oct") { const auto *key_value{value.try_at("k", HASH_K)}; if (key_value == nullptr || !key_value->is_string()) { diff --git a/vendor/core/src/core/jose/jose_jwk_private.cc b/vendor/core/src/core/jose/jose_jwk_private.cc index 378a15eb5..a164e6f46 100644 --- a/vendor/core/src/core/jose/jose_jwk_private.cc +++ b/vendor/core/src/core/jose/jose_jwk_private.cc @@ -109,6 +109,8 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { } result.type_ = Type::RSA; + result.modulus_ = modulus.value(); + result.exponent_ = public_exponent.value(); parsed_key = make_rsa_private_key(modulus.value(), public_exponent.value(), private_exponent.value(), prime1.value(), prime2.value(), exponent1.value(), @@ -140,6 +142,8 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { result.type_ = Type::EllipticCurve; result.curve_ = curve->to_string(); + result.coordinate_x_ = coordinate_x.value(); + result.coordinate_y_ = coordinate_y.value(); parsed_key = make_ec_private_key(jwk_to_elliptic_curve(result.curve_), scalar.value(), coordinate_x.value(), coordinate_y.value()); @@ -166,6 +170,7 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { result.type_ = Type::OctetKeyPair; result.curve_ = curve->to_string(); + result.public_point_ = public_key.value(); parsed_key = make_edwards_private_key(jwk_to_edwards_curve(result.curve_), seed.value()); } else if (key_type_value == "oct") { @@ -215,6 +220,48 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { // reuses it. A key that cannot be turned into one stays null and simply fails // to sign result.private_key_ = std::move(parsed_key); + + // A private JWK carries the public key beside the private key (RFC 7518 + // Section 6.2.2, RFC 8037 Section 2). For the curve types, reject a document + // whose stored public part does not correspond to its private part, verified + // through a signature the private key produces and the public key checks. For + // elliptic curve keys the platform key construction additionally binds the + // coordinates to the scalar, closing the gap where a crafted point could + // satisfy one precomputed signature on a deterministic backend. RSA + // correspondence is not checked here, so an inconsistent RSA key instead + // stays null and fails to sign + if (result.type_ == Type::EllipticCurve || + result.type_ == Type::OctetKeyPair) { + if (!result.private_key_.has_value()) { + return false; + } + + using namespace std::string_view_literals; + const auto probe{"sourcemeta core JWK keypair consistency probe"sv}; + bool consistent{false}; + if (result.type_ == Type::EllipticCurve) { + const auto public_key{ + make_ec_public_key(jwk_to_elliptic_curve(result.curve_), + result.coordinate_x_, result.coordinate_y_)}; + const auto signature{ecdsa_sign(result.private_key_.value(), + SignatureHashFunction::SHA256, probe)}; + consistent = + public_key.has_value() && signature.has_value() && + ecdsa_verify(public_key.value(), SignatureHashFunction::SHA256, probe, + signature.value()); + } else { + const auto public_key{make_eddsa_public_key( + jwk_to_edwards_curve(result.curve_), result.public_point_)}; + const auto signature{eddsa_sign(result.private_key_.value(), probe)}; + consistent = public_key.has_value() && signature.has_value() && + eddsa_verify(public_key.value(), probe, signature.value()); + } + + if (!consistent) { + return false; + } + } + return true; } @@ -252,6 +299,43 @@ auto JWKPrivate::from_pem(const std::string_view pem) } result.private_key_ = std::move(parsed_key); + + // A PEM document carries no encoded public members, so recover them from the + // parsed key to support public serialization and thumbprints + const auto public_key{derive_public_key(result.private_key_.value())}; + if (public_key.has_value()) { + switch (public_key.value().type()) { + case PublicKey::Type::RSA: { + const auto components{rsa_public_components(public_key.value())}; + if (components.has_value()) { + result.modulus_ = components.value().modulus; + result.exponent_ = components.value().exponent; + } + + break; + } + case PublicKey::Type::EllipticCurve: { + const auto components{ec_public_components(public_key.value())}; + if (components.has_value()) { + result.curve_ = elliptic_curve_to_jwk(components.value().curve); + result.coordinate_x_ = components.value().x; + result.coordinate_y_ = components.value().y; + } + + break; + } + case PublicKey::Type::Edwards: { + const auto components{edwards_public_components(public_key.value())}; + if (components.has_value()) { + result.curve_ = edwards_curve_to_jwk(components.value().curve); + result.public_point_ = components.value().point; + } + + break; + } + } + } + return result; } diff --git a/vendor/core/src/core/jose/jose_jwk_thumbprint.cc b/vendor/core/src/core/jose/jose_jwk_thumbprint.cc new file mode 100644 index 000000000..323356c42 --- /dev/null +++ b/vendor/core/src/core/jose/jose_jwk_thumbprint.cc @@ -0,0 +1,142 @@ +#include +#include + +#include +#include + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace { + +// The minimal big-endian magnitude of an integer, so the RSA members serialize +// and thumbprint as the Base64urlUInt form RFC 7518 Section 2 requires, with no +// leading zero octets, regardless of how the source JWK padded them. A single +// octet is kept so the value zero stays a valid member rather than an empty one +auto minimal(std::string_view value) -> std::string_view { + while (value.size() > 1 && value.front() == '\x00') { + value.remove_prefix(1); + } + + return value; +} + +// The public members are mutually exclusive by construction, so a non-empty +// modulus identifies an RSA key, a non-empty coordinate an elliptic curve key, +// and a non-empty point an octet key pair. All empty means a symmetric key or +// a key whose public part could not be recovered, which has no public form +auto build_public_jwk(const std::string_view curve, + const std::string_view modulus, + const std::string_view exponent, + const std::string_view coordinate_x, + const std::string_view coordinate_y, + const std::string_view point) + -> std::optional { + using sourcemeta::core::base64url_encode; + using sourcemeta::core::JSON; + if (!modulus.empty()) { + auto object{JSON::make_object()}; + object.assign("kty", JSON{std::string{"RSA"}}); + object.assign("n", JSON{base64url_encode(minimal(modulus))}); + object.assign("e", JSON{base64url_encode(minimal(exponent))}); + return object; + } + + if (!coordinate_x.empty()) { + auto object{JSON::make_object()}; + object.assign("kty", JSON{std::string{"EC"}}); + object.assign("crv", JSON{std::string{curve}}); + object.assign("x", JSON{base64url_encode(coordinate_x)}); + object.assign("y", JSON{base64url_encode(coordinate_y)}); + return object; + } + + if (!point.empty()) { + auto object{JSON::make_object()}; + object.assign("kty", JSON{std::string{"OKP"}}); + object.assign("crv", JSON{std::string{curve}}); + object.assign("x", JSON{base64url_encode(point)}); + return object; + } + + return std::nullopt; +} + +// RFC 7638 Section 3: the hash input is a JSON object of the required members +// only, in lexicographic member order, with no whitespace, the values being +// the base64url component strings, which contain no character JSON must escape +auto build_thumbprint( + const std::string_view curve, const std::string_view modulus, + const std::string_view exponent, const std::string_view coordinate_x, + const std::string_view coordinate_y, const std::string_view point, + const std::string_view secret) -> std::optional { + using sourcemeta::core::base64url_encode; + // The canonical form embeds the secret for a symmetric key, so it is held in + // wiping storage that clears itself on every growth and on the way out + sourcemeta::core::SecureString canonical; + if (!modulus.empty()) { + canonical.append(R"({"e":")"); + canonical.append(base64url_encode(minimal(exponent))); + canonical.append(R"(","kty":"RSA","n":")"); + canonical.append(base64url_encode(minimal(modulus))); + canonical.append(R"("})"); + } else if (!coordinate_x.empty()) { + canonical.append(R"({"crv":")"); + canonical.append(curve); + canonical.append(R"(","kty":"EC","x":")"); + canonical.append(base64url_encode(coordinate_x)); + canonical.append(R"(","y":")"); + canonical.append(base64url_encode(coordinate_y)); + canonical.append(R"("})"); + } else if (!point.empty()) { + canonical.append(R"({"crv":")"); + canonical.append(curve); + canonical.append(R"(","kty":"OKP","x":")"); + canonical.append(base64url_encode(point)); + canonical.append(R"("})"); + } else if (!secret.empty()) { + // RFC 7638 Section 3.2.1: the octet sequence thumbprint is over the secret, + // encoded straight into the wiping buffer so it leaves no intermediate copy + canonical.append(R"({"k":")"); + base64url_encode(secret, canonical); + canonical.append(R"(","kty":"oct"})"); + } else { + return std::nullopt; + } + + const auto digest{sourcemeta::core::sha256_digest(canonical)}; + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + return base64url_encode(std::string_view{ + reinterpret_cast(digest.data()), digest.size()}); +} + +} // namespace + +namespace sourcemeta::core { + +auto JWK::public_jwk() const -> std::optional { + return build_public_jwk(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_); +} + +auto JWK::thumbprint() const -> std::optional { + return build_thumbprint(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_, this->secret_); +} + +auto JWKPrivate::public_jwk() const -> std::optional { + return build_public_jwk(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_); +} + +auto JWKPrivate::thumbprint() const -> std::optional { + return build_thumbprint(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_, this->secret_); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwt.cc b/vendor/core/src/core/jose/jose_jwt.cc index 3a297cbb2..48496b1ce 100644 --- a/vendor/core/src/core/jose/jose_jwt.cc +++ b/vendor/core/src/core/jose/jose_jwt.cc @@ -38,6 +38,19 @@ 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; + } + } + + return true; +} + auto date_claim(const sourcemeta::core::JSON &object, const sourcemeta::core::JSON::StringView name, const sourcemeta::core::JSON::Object::hash_type hash) @@ -101,14 +114,12 @@ auto JWT::parse(const std::string_view input, JWT &result) -> bool { return false; } - // RFC 7515 Section 4: the header parameter names must be unique, so a header - // with a duplicate is rejected, since the JSON layer preserves repeated - // members rather than collapsing them - std::unordered_set header_parameters; - for (const auto ¶meter : header_json.value().as_object()) { - if (!header_parameters.emplace(parameter.first).second) { - return false; - } + // 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())) { + return false; } // The algorithm header parameter is required and must be a string (RFC 7515 diff --git a/vendor/core/src/core/jose/jose_key.h b/vendor/core/src/core/jose/jose_key.h index 6023213ff..74033d20f 100644 --- a/vendor/core/src/core/jose/jose_key.h +++ b/vendor/core/src/core/jose/jose_key.h @@ -90,6 +90,37 @@ inline auto jwk_to_edwards_curve(const std::string_view curve) noexcept } } +// The JWK curve name each elliptic curve carries (RFC 7518 Section 6.2.1.1), +// the reverse of the parsing mapping, so a key built without a curve name can +// recover it from the parsed material +inline auto elliptic_curve_to_jwk(const EllipticCurve curve) noexcept + -> std::string_view { + switch (curve) { + case EllipticCurve::P256: + return "P-256"; + case EllipticCurve::P384: + return "P-384"; + case EllipticCurve::P521: + return "P-521"; + } + + std::unreachable(); +} + +// The JWK curve name each Edwards curve carries (RFC 8037 Section 2), the +// reverse of the parsing mapping +inline auto edwards_curve_to_jwk(const EdwardsCurve curve) noexcept + -> std::string_view { + switch (curve) { + case EdwardsCurve::Ed25519: + return "Ed25519"; + case EdwardsCurve::Ed448: + return "Ed448"; + } + + std::unreachable(); +} + // The RSA algorithms only require an RSA key, each ECDSA algorithm is tied to a // specific curve (RFC 7518 Section 3.1), and the Edwards-curve algorithm // requires an octet key pair of either curve (RFC 8037 Section 3.1) diff --git a/vendor/core/src/core/jsonld/CMakeLists.txt b/vendor/core/src/core/jsonld/CMakeLists.txt index fbcc41e46..6effb5047 100644 --- a/vendor/core/src/core/jsonld/CMakeLists.txt +++ b/vendor/core/src/core/jsonld/CMakeLists.txt @@ -8,7 +8,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME jsonld jsonld_value_compaction.cc jsonld_compaction.cc jsonld_node_map.cc jsonld_flatten.cc jsonld_materialize.cc - jsonld_algorithms.h jsonld_keywords.h) + jsonld_algorithms.h jsonld_keywords.h jsonld_serialise.h) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME jsonld) diff --git a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h index 4fcd73200..d1eb1e1f5 100644 --- a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h +++ b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h @@ -52,7 +52,9 @@ struct JSONLDNode { /// JSON value, a language may carry a direction, and the JSON flag preserves an /// opaque JSON literal verbatim. struct JSONLDLiteral { - /// The literal datatype, defaulting to the native type of the value. + /// The literal datatype, defaulting to the native type of the value. An + /// explicit datatype carries a native number or boolean as its canonical + /// string lexical form. std::optional datatype{}; /// The language tag of the literal. std::optional language{}; diff --git a/vendor/core/src/core/jsonld/jsonld_materialize.cc b/vendor/core/src/core/jsonld/jsonld_materialize.cc index c9d732950..24bd629a9 100644 --- a/vendor/core/src/core/jsonld/jsonld_materialize.cc +++ b/vendor/core/src/core/jsonld/jsonld_materialize.cc @@ -3,6 +3,7 @@ #include #include "jsonld_keywords.h" +#include "jsonld_serialise.h" #include // std::ranges::sort, std::ranges::stable_sort, std::ranges::unique, std::ranges::none_of #include // assert @@ -155,18 +156,27 @@ auto attach(JSON &node, const std::vector &edges, JSON value) auto materialize_literal(const JSONLDLiteral &descriptor, const JSON &value) -> JSON { auto result{JSON::make_object()}; - result.assign_assume_new(JSON::String{KEYWORD_VALUE}, JSON{value}, - KEYWORD_VALUE_HASH); if (descriptor.json) { + result.assign_assume_new(JSON::String{KEYWORD_VALUE}, JSON{value}, + KEYWORD_VALUE_HASH); result.assign_assume_new(JSON::String{KEYWORD_TYPE}, JSON{KEYWORD_JSON}, KEYWORD_TYPE_HASH); return result; } if (descriptor.datatype.has_value()) { + auto lexical{ + typed_literal_lexical_form(value, descriptor.datatype.value())}; + result.assign_assume_new( + JSON::String{KEYWORD_VALUE}, + lexical.has_value() ? JSON{std::move(lexical).value()} : JSON{value}, + KEYWORD_VALUE_HASH); result.assign_assume_new(JSON::String{KEYWORD_TYPE}, JSON{descriptor.datatype.value()}, KEYWORD_TYPE_HASH); + } else { + result.assign_assume_new(JSON::String{KEYWORD_VALUE}, JSON{value}, + KEYWORD_VALUE_HASH); } if (descriptor.language.has_value()) { result.assign_assume_new(JSON::String{KEYWORD_LANGUAGE}, diff --git a/vendor/core/src/core/jsonld/jsonld_serialise.h b/vendor/core/src/core/jsonld/jsonld_serialise.h new file mode 100644 index 000000000..b186878ca --- /dev/null +++ b/vendor/core/src/core/jsonld/jsonld_serialise.h @@ -0,0 +1,245 @@ +#ifndef SOURCEMETA_CORE_JSONLD_SERIALISE_H_ +#define SOURCEMETA_CORE_JSONLD_SERIALISE_H_ + +#include +#include + +#include // std::copy +#include // std::array +#include // assert +#include // std::to_chars, std::from_chars, std::chars_format +#include // std::size_t +#include // std::int32_t +#include // std::optional, std::nullopt +#include // std::unreachable + +// Serialisers that turn a native number or boolean into the string lexical +// form of an RDF literal when a position carries an explicit datatype. Plain +// JSON stringification is not enough for two reasons. It picks the shortest +// round-trip spelling in whichever notation is shorter, so magnitudes like +// 0.00001 or 1e21 come out in scientific notation, which is outside the +// lexical space of exponent-free datatypes like xsd:decimal (XSD 1.1 Part 2 +// Section 3.3.3). And for xsd:double and xsd:float the output must be the +// exact canonical form that conforming RDF conversion derives from native +// numbers (JSON-LD 1.1 API Section 8.6), so that the literal terms consumers +// obtain stay byte-identical to the native behaviour they get today + +namespace sourcemeta::core { + +inline constexpr JSON::StringView DATATYPE_XSD_DOUBLE{ + "http://www.w3.org/2001/XMLSchema#double"}; +inline constexpr JSON::StringView DATATYPE_XSD_FLOAT{ + "http://www.w3.org/2001/XMLSchema#float"}; + +// Both floating point datatypes take the scientific canonical form +inline auto is_floating_point_datatype(const JSON::StringView datatype) + -> bool { + return datatype == DATATYPE_XSD_DOUBLE || datatype == DATATYPE_XSD_FLOAT; +} + +// The double form of JSON-LD 1.1 API Section 8.6, where the mantissa is +// "rounded to 15 digits after the decimal point" with trailing zeros dropped +// down to a single digit after the required decimal point, the exponent +// carries no plus sign or leading zeros, and "the canonical representation +// for zero is 0.0E0". The form is assembled in the conversion buffer itself +// so the function performs at most a single allocation +inline auto scientific_lexical_form(const double value) -> JSON::String { + if (value == 0.0) { + return "0.0E0"; + } + + std::array buffer{}; + const auto conversion{std::to_chars(buffer.data(), + buffer.data() + buffer.size(), value, + std::chars_format::scientific, 15)}; + assert(conversion.ec == std::errc{}); + const JSON::StringView digits{ + buffer.data(), static_cast(conversion.ptr - buffer.data())}; + const auto exponent_marker{digits.find('e')}; + assert(exponent_marker != JSON::StringView::npos); + + // The exponent characters are copied out first, as the assembled form is + // written over the region they occupy + auto exponent{digits.substr(exponent_marker + 1)}; + const bool negative_exponent{exponent.front() == '-'}; + if (negative_exponent || exponent.front() == '+') { + exponent.remove_prefix(1); + } + const auto first_significant_exponent{exponent.find_first_not_of('0')}; + std::array exponent_digits{}; + std::size_t exponent_size{0}; + if (first_significant_exponent == JSON::StringView::npos) { + exponent_digits[exponent_size] = '0'; + exponent_size += 1; + } else { + for (const auto character : exponent.substr(first_significant_exponent)) { + exponent_digits[exponent_size] = character; + exponent_size += 1; + } + } + + const auto mantissa{digits.substr(0, exponent_marker)}; + const auto last_significant{mantissa.find_last_not_of('0')}; + assert(last_significant != JSON::StringView::npos); + const auto mantissa_size{mantissa.at(last_significant) == '.' + ? last_significant + 2 + : last_significant + 1}; + auto *cursor{buffer.data() + mantissa_size}; + *cursor = 'E'; + cursor += 1; + if (negative_exponent) { + *cursor = '-'; + cursor += 1; + } + cursor = std::copy(exponent_digits.data(), + exponent_digits.data() + exponent_size, cursor); + return {buffer.data(), static_cast(cursor - buffer.data())}; +} + +// The shortest decimal expansion that parses back to the same value, with no +// exponent so that it stays within the lexical space of datatypes that do not +// admit scientific notation +inline auto fixed_lexical_form(const double value) -> JSON::String { + std::array buffer{}; + const auto conversion{std::to_chars(buffer.data(), + buffer.data() + buffer.size(), value, + std::chars_format::fixed)}; + assert(conversion.ec == std::errc{}); + return {buffer.data(), + static_cast(conversion.ptr - buffer.data())}; +} + +// The most extreme decimal exponent that still expands to an exponent-free +// spelling. A tiny exponent notation input would otherwise demand an +// expansion of up to two billion digits, so magnitudes beyond this bound +// produce no result and the native value passes through untouched +inline constexpr std::int64_t MAXIMUM_EXPANSION_EXPONENT{10000}; + +// The exact exponent-free expansion of an arbitrary precision decimal, with +// trailing zeros stripped so that the spelling is minimal. The expansion is +// assembled once into a preallocated result out of views over the scientific +// form +inline auto fixed_lexical_form(const Decimal &value) + -> std::optional { + assert(value.is_finite()); + if (!value.is_zero()) { + const auto magnitude{value.logb().to_int64()}; + if (magnitude > MAXIMUM_EXPANSION_EXPONENT || + magnitude < -MAXIMUM_EXPANSION_EXPONENT) { + return std::nullopt; + } + } + + const auto scientific{value.reduce().to_scientific_string()}; + const JSON::StringView view{scientific}; + const auto exponent_marker{view.find('e')}; + assert(exponent_marker != JSON::StringView::npos); + auto mantissa{view.substr(0, exponent_marker)}; + const bool negative{mantissa.front() == '-'}; + if (negative) { + mantissa.remove_prefix(1); + } + const auto point{mantissa.find('.')}; + const auto head{mantissa.substr( + 0, point == JSON::StringView::npos ? mantissa.size() : point)}; + const auto tail{point == JSON::StringView::npos ? JSON::StringView{} + : mantissa.substr(point + 1)}; + assert(head.size() == 1); + + auto exponent{view.substr(exponent_marker + 1)}; + if (exponent.front() == '+') { + exponent.remove_prefix(1); + } + std::int32_t adjusted{0}; + [[maybe_unused]] const auto conversion{std::from_chars( + exponent.data(), exponent.data() + exponent.size(), adjusted)}; + assert(conversion.ec == std::errc{}); + + const auto count{static_cast(head.size() + tail.size())}; + const auto sign_size{static_cast(negative ? 1 : 0)}; + JSON::String result; + if (adjusted >= count - 1) { + result.reserve(sign_size + static_cast(adjusted) + 1); + if (negative) { + result += '-'; + } + result += head; + result += tail; + result.append(static_cast(adjusted - (count - 1)), '0'); + } else if (adjusted >= 0) { + result.reserve(sign_size + static_cast(count) + 1); + if (negative) { + result += '-'; + } + result += head; + result += tail.substr(0, static_cast(adjusted)); + result += '.'; + result += tail.substr(static_cast(adjusted)); + } else { + const auto padding{static_cast(-adjusted) - 1}; + result.reserve(sign_size + 2 + padding + static_cast(count)); + if (negative) { + result += '-'; + } + result += "0."; + result.append(padding, '0'); + result += head; + result += tail; + } + return result; +} + +// A native number or boolean paired with an explicit datatype becomes its +// canonical string lexical form, as no result is produced for other values. +// JSON-LD 1.1 API Section 8.2.2 converts a native number with a non-zero +// fractional part to the "canonical lexical form of an xsd:double" no matter +// which datatype was requested, yielding ill-typed literals for datatypes +// whose lexical space has no exponent, whereas a string value passes through +// RDF conversion verbatim +inline auto typed_literal_lexical_form(const JSON &value, + const JSON::StringView datatype) + -> std::optional { + switch (value.type()) { + case JSON::Type::Boolean: + return JSON::String{value.to_boolean() ? "true" : "false"}; + case JSON::Type::Integer: { + if (is_floating_point_datatype(datatype)) { + return scientific_lexical_form(static_cast(value.to_integer())); + } + std::array buffer{}; + const auto conversion{std::to_chars( + buffer.data(), buffer.data() + buffer.size(), value.to_integer())}; + assert(conversion.ec == std::errc{}); + return JSON::String{buffer.data(), static_cast( + conversion.ptr - buffer.data())}; + } + case JSON::Type::Real: + return is_floating_point_datatype(datatype) + ? scientific_lexical_form(value.to_real()) + : fixed_lexical_form(value.to_real()); + case JSON::Type::Decimal: { + const auto &decimal{value.to_decimal()}; + if (is_floating_point_datatype(datatype)) { + const auto converted{to_double(decimal.to_scientific_string())}; + if (converted.has_value()) { + return scientific_lexical_form(converted.value()); + } + // A magnitude outside the double range has no scientific canonical + // form to borrow, so the exact expansion is the deterministic + // fallback + } + return fixed_lexical_form(decimal); + } + case JSON::Type::Null: + case JSON::Type::String: + case JSON::Type::Array: + case JSON::Type::Object: + return std::nullopt; + } + + std::unreachable(); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h index 2a77f7192..c616e2e64 100644 --- a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h +++ b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h @@ -8,7 +8,7 @@ #include // std::size_t #include // std::reference_wrapper #include // std::initializer_list -#include // std::advance, std::back_inserter +#include // std::advance, std::back_inserter, std::next #include // std::optional #include // std::ranges::subrange #include // std::is_same_v, std::decay_t @@ -663,6 +663,47 @@ template class GenericPointer { this->data[prefix_size + 1].to_property() == tail_right; } + /// Check whether two JSON Pointers are equal up to a given number of + /// leading tokens. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::Pointer pointer{"foo", "bar", "baz"}; + /// const sourcemeta::core::Pointer other{"foo", "bar", "qux"}; + /// assert(pointer.shares_prefix(other, 2)); + /// assert(!pointer.shares_prefix(other, 3)); + /// ``` + [[nodiscard]] auto shares_prefix(const GenericPointer &other, + const size_type prefix_size) const -> bool { + return this->data.size() >= prefix_size && + other.data.size() >= prefix_size && + std::equal(this->data.cbegin(), + std::next(this->data.cbegin(), + static_cast(prefix_size)), + other.data.cbegin()); + } + + /// Check whether a JSON Pointer starts with another JSON Pointer without + /// the two being equal. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::Pointer pointer{"foo", "bar", "baz"}; + /// const sourcemeta::core::Pointer prefix{"foo", "bar"}; + /// assert(pointer.starts_with_strict(prefix)); + /// assert(!pointer.starts_with_strict(pointer)); + /// ``` + [[nodiscard]] auto + starts_with_strict(const GenericPointer &other) const + -> bool { + return this->data.size() > other.data.size() && + this->shares_prefix(other, other.data.size()); + } + /// Check whether a JSON Pointer starts with the initial part of another JSON /// Pointer. For example: /// diff --git a/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h b/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h index 7019df93e..c2627dfab 100644 --- a/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h +++ b/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h @@ -42,6 +42,27 @@ namespace sourcemeta::core { SOURCEMETA_CORE_LANGTAG_EXPORT auto is_langtag(const std::string_view value) -> bool; +/// @ingroup langtag +/// Check whether the given string is a well-formed language tag per RFC 5646 +/// (BCP 47) that also follows the recommended casing conventions of RFC 5646 +/// Section 2.1.1: script subtags in titlecase, region subtags in uppercase, +/// and every other subtag, including every subtag after a singleton, in +/// lowercase. The deprecated irregular grandfathered tags are rejected, as +/// the registry maps each of them to a canonical replacement. For example: +/// +/// ```cpp +/// #include +/// +/// #include +/// +/// assert(sourcemeta::core::is_canonical_langtag("en-GB")); +/// assert(sourcemeta::core::is_canonical_langtag("zh-Hant-HK")); +/// assert(!sourcemeta::core::is_canonical_langtag("en-gb")); +/// assert(!sourcemeta::core::is_canonical_langtag("EN")); +/// ``` +SOURCEMETA_CORE_LANGTAG_EXPORT +auto is_canonical_langtag(const std::string_view value) -> bool; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/langtag/langtag.cc b/vendor/core/src/core/langtag/langtag.cc index ed00282e4..5873a57fb 100644 --- a/vendor/core/src/core/langtag/langtag.cc +++ b/vendor/core/src/core/langtag/langtag.cc @@ -249,4 +249,41 @@ auto is_langtag(const std::string_view value) -> bool { return is_irregular_grandfathered(value); } +auto is_canonical_langtag(const std::string_view value) -> bool { + // The registry maps every irregular grandfathered tag to a canonical + // replacement, so none of them is canonical. + if (!is_langtag(value) || is_irregular_grandfathered(value)) { + return false; + } + + bool initial{true}; + bool after_singleton{false}; + std::size_t position{0}; + while (position < value.size()) { + const auto subtag{subtag_at(value, position)}; + + // Per RFC 5646 Section 2.1.1, region subtags are uppercase and script + // subtags are titlecase, while every other subtag, including everything + // that follows a singleton, is lowercase. + const auto letters{is_alpha(subtag)}; + const bool region{!initial && !after_singleton && letters && + subtag.size() == 2}; + const bool script{!initial && !after_singleton && letters && + subtag.size() == 4}; + for (std::size_t index{0}; index < subtag.size(); index += 1) { + const bool expect_uppercase{region || (script && index == 0)}; + const bool uppercase{!is_lowercase(subtag[index])}; + if (uppercase != expect_uppercase) { + return false; + } + } + + initial = false; + after_singleton = after_singleton || subtag.size() == 1; + advance(value, position, subtag.size()); + } + + return true; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/CMakeLists.txt b/vendor/core/src/core/oauth/CMakeLists.txt new file mode 100644 index 000000000..7f5b785f0 --- /dev/null +++ b/vendor/core/src/core/oauth/CMakeLists.txt @@ -0,0 +1,30 @@ +sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oauth + PRIVATE_HEADERS error.h profile.h pkce.h bearer.h random.h authorization.h + token.h client_authentication.h transaction.h metadata.h metadata_provider.h + token_exchange.h revocation.h introspection.h device.h dpop.h par.h + 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_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 + oauth_introspection.cc oauth_device.cc oauth_dpop.cc oauth_par.cc + oauth_assertion.cc oauth_registration.cc) + +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::json) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::crypto) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::jose) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::http) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::uri) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::text) + +if(SOURCEMETA_CORE_INSTALL) + sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME oauth) +endif() diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth.h new file mode 100644 index 000000000..4e7f144e9 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth.h @@ -0,0 +1,40 @@ +#ifndef SOURCEMETA_CORE_OAUTH_H_ +#define SOURCEMETA_CORE_OAUTH_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +// NOLINTBEGIN(misc-include-cleaner) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// NOLINTEND(misc-include-cleaner) + +/// @defgroup oauth OAuth +/// @brief A standards-driven implementation of the OAuth 2.0 and 2.1 message +/// family. +/// +/// This functionality is included as follows: +/// +/// ```cpp +/// #include +/// ``` + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_assertion.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_assertion.h new file mode 100644 index 000000000..1218048ff --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_assertion.h @@ -0,0 +1,243 @@ +#ifndef SOURCEMETA_CORE_OAUTH_ASSERTION_H_ +#define SOURCEMETA_CORE_OAUTH_ASSERTION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include +#include +#include + +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::uint8_t +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The grant type identifier of a JWT bearer authorization grant (RFC 7523 +/// Section 2.1). +inline constexpr std::string_view OAUTH_GRANT_TYPE_JWT_BEARER{ + "urn:ietf:params:oauth:grant-type:jwt-bearer"}; +/// @ingroup oauth +/// The client assertion type identifier of a JWT bearer client authentication +/// assertion (RFC 7523 Section 2.2). +inline constexpr std::string_view OAUTH_CLIENT_ASSERTION_TYPE_JWT_BEARER{ + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}; + +/// @ingroup oauth +/// Build a JWT bearer assertion (RFC 7523 Section 3) signed with the given key +/// and algorithm, carrying the issuer, subject, and a single audience, an +/// expiration a lifetime past the given time, an issue time, and a random +/// identifier, or no value when the key cannot sign. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::JWKPrivate::from_pem(pem)}; +/// assert(key.has_value()); +/// const auto assertion{sourcemeta::core::oauth_build_assertion( +/// "issuer", "subject", "https://server.example/token", +/// std::chrono::seconds{300}, std::chrono::system_clock::now(), +/// key.value(), sourcemeta::core::JWSAlgorithm::ES256)}; +/// assert(assertion.has_value()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_build_assertion( + const std::string_view issuer, const std::string_view subject, + const std::string_view audience, const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, const JWKPrivate &key, + const JWSAlgorithm algorithm) -> std::optional; + +/// @ingroup oauth +/// Build a JWT bearer client authentication assertion (RFC 7523 Section 3), a +/// self-issued assertion whose issuer and subject are both the client +/// identifier (RFC 7521 Section 5.2), or no value when the key cannot sign. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::JWKPrivate::from_pem(pem)}; +/// assert(key.has_value()); +/// const auto assertion{sourcemeta::core::oauth_build_client_assertion( +/// "s6BhdRkqt3", "https://server.example/token", std::chrono::seconds{300}, +/// std::chrono::system_clock::now(), key.value(), +/// sourcemeta::core::JWSAlgorithm::ES256)}; +/// assert(assertion.has_value()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_build_client_assertion( + const std::string_view client_id, const std::string_view audience, + const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, const JWKPrivate &key, + const JWSAlgorithm algorithm) -> std::optional; + +/// @ingroup oauth +/// Append the client authentication assertion parameters (RFC 7521 Section 4.2) +/// to the sink, the assertion and its JWT bearer type. No `client_id` is +/// emitted, since the client is identified by the assertion subject. The +/// assertion is a credential, so the sink is a wiping string, appended to and +/// never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_client_assertion("eyJ...", body); +/// assert(std::string_view{body}.starts_with("client_assertion_type=")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_assertion(const std::string_view assertion, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append a JWT bearer authorization grant token request body (RFC 7523 +/// Section 2.1) to the sink, the grant type, the assertion, and the scope when +/// present. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink when the client authenticates. The +/// assertion is a credential, so the sink is a wiping string, appended to and +/// never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_jwt_bearer("eyJ...", "read", +/// body); +/// assert(std::string_view{body}.starts_with("grant_type=")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_jwt_bearer(const std::string_view assertion, + const std::string_view scope, + SecureString &sink) -> void; + +/// @ingroup oauth +/// The reason a JWT bearer assertion failed verification (RFC 7523 Section 3). +enum class OAuthAssertionError : std::uint8_t { + /// The assertion was not a well-formed JSON Web Token. + Malformed, + /// The algorithm was absent or outside the accepted set (RFC 7523 Section 5). + UnsupportedAlgorithm, + /// No key verified the signature. + UnknownKey, + /// The signature did not verify. + Signature, + /// The issuer claim was absent or did not match (RFC 7523 Section 3 check 1). + Issuer, + /// The subject claim was absent or did not match (RFC 7523 Section 3 check 2, + /// RFC 7521 Section 4.2). + Subject, + /// The audience claim did not identify this server (RFC 7523 Section 3 + /// check 3). + Audience, + /// The assertion has expired (RFC 7523 Section 3 check 4). + Expired, + /// The assertion is not yet valid (RFC 7523 Section 3 check 5). + NotYetValid, + /// The issue time lies in the future (RFC 7523 Section 3 check 6). + IssuedInFuture, + /// The identifier was already seen within its window, a replay (RFC 7523 + /// Section 3 check 7). + Replay +}; + +/// @ingroup oauth +/// The inputs to JWT bearer assertion verification beyond the request. The +/// accepted algorithms are matched exactly, so an empty set accepts none, and a +/// replay store, when set, rejects a reused identifier. +struct OAuthAssertionVerifyOptions { + /// The algorithms accepted per local policy, a non-owning view whose backing + /// storage must outlive the verification (RFC 7523 Section 5). + std::span allowed_algorithms{}; + /// The tolerance applied to the expiration, not-before, and issue times. + std::chrono::seconds clock_skew{std::chrono::seconds{0}}; + /// The store tracking assertion identifiers to reject a replay, ignored when + /// null or when the assertion carries no identifier (RFC 7523 Section 3 + /// check 7). + OAuthDPoPReplayStore *replay_store{nullptr}; +}; + +/// @ingroup oauth +/// Verify a JWT bearer client authentication assertion (RFC 7523 Section 3, +/// RFC 7521 Section 5.2), returning the first failing check or no value when it +/// verifies. The issuer and subject must both be the client identifier, the +/// subject must match a presented `client_id` when one is given (RFC 7521 +/// Section 4.2), and the audience must match one of the accepted values by +/// Simple String Comparison without normalization (RFC 3986 Section 6.2.1). A +/// failure is reported to the client as `invalid_client` (RFC 7521 +/// Section 4.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array +/// audiences{{"https://server.example/token"}}; const std::array +/// allowed{sourcemeta::core::JWSAlgorithm::ES256}; +/// sourcemeta::core::OAuthAssertionVerifyOptions options; +/// options.allowed_algorithms = allowed; +/// const auto error{sourcemeta::core::oauth_verify_client_assertion( +/// assertion, audiences, "s6BhdRkqt3", keys, +/// std::chrono::system_clock::now(), options)}; +/// assert(!error.has_value() || +/// error.value() == sourcemeta::core::OAuthAssertionError::Expired); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_verify_client_assertion( + const std::string_view assertion, + const std::span expected_audiences, + const std::string_view request_client_id, const JWKS &keys, + const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional; + +/// @ingroup oauth +/// Verify a JWT bearer authorization grant assertion (RFC 7523 Section 3), +/// returning the first failing check or no value when it verifies. The issuer +/// must match the expected one, the subject identifies the accessor and is only +/// required to be present, and the audience must match one of the accepted +/// values by Simple String Comparison without normalization (RFC 3986 +/// Section 6.2.1). A failure is reported to the client as `invalid_grant` +/// (RFC 6749 Section 5.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array +/// audiences{{"https://server.example/token"}}; +/// sourcemeta::core::OAuthAssertionVerifyOptions options; +/// const auto error{sourcemeta::core::oauth_verify_assertion_grant( +/// assertion, "https://issuer.example", audiences, keys, +/// std::chrono::system_clock::now(), options)}; +/// assert(!error.has_value() || +/// error.value() == sourcemeta::core::OAuthAssertionError::Issuer); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_verify_assertion_grant( + const std::string_view assertion, const std::string_view expected_issuer, + const std::span expected_audiences, + const JWKS &keys, const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional; + +} // namespace sourcemeta::core + +#endif 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 new file mode 100644 index 000000000..0a9855622 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h @@ -0,0 +1,298 @@ +#ifndef SOURCEMETA_CORE_OAUTH_AUTHORIZATION_H_ +#define SOURCEMETA_CORE_OAUTH_AUTHORIZATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::function +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// A single query or form parameter as a name and value pair. Both members are +/// non-owning views that must outlive any use of this parameter. For example: +/// +/// ```cpp +/// #include +/// +/// const sourcemeta::core::OAuthParameter parameter{"resource", +/// "https://api.example"}; +/// ``` +struct OAuthParameter { + /// The parameter name. + std::string_view name; + /// The parameter value, still in its raw unescaped form. + std::string_view value; +}; + +/// @ingroup oauth +/// A non-owning view of the parameters of an authorization request (RFC 6749 +/// Section 4.1.1). Every field borrows from the caller and must outlive any use +/// of this struct. An empty scalar field is treated as absent and is not +/// emitted, and the spans carry the repeatable and extension parameters. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// request.client_id = "s6BhdRkqt3"; +/// request.state = "xyz"; +/// std::string url; +/// sourcemeta::core::oauth_build_authorization_url( +/// "https://server.example/authorize", request, url); +/// assert(url == +/// "https://server.example/authorize?response_type=code" +/// "&client_id=s6BhdRkqt3&state=xyz"); +/// ``` +struct OAuthAuthorizationRequest { + /// The client identifier (RFC 6749 Section 2.2). + std::string_view client_id; + /// The redirection endpoint the response is returned to (RFC 6749 + /// Section 3.1.2). + std::string_view redirect_uri; + /// The space-delimited requested scope (RFC 6749 Section 3.3). + std::string_view scope; + /// The opaque cross-site request forgery token echoed back on the response + /// (RFC 6749 Section 10.12). + std::string_view state; + /// The PKCE code challenge (RFC 7636 Section 4.3). + std::string_view code_challenge; + /// The PKCE code challenge method, emitted only alongside a challenge + /// (RFC 7636 Section 4.3). + std::string_view code_challenge_method; + /// The reference to a pushed authorization request (RFC 9126 Section 4). + 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. + std::string_view response_type; + /// The repeatable resource indicators, each an absolute URI without a + /// fragment (RFC 8707 Section 2). + std::span resources; + /// The extension parameters, such as an OpenID Connect `nonce`, emitted + /// verbatim after the known parameters. + std::span extra; +}; + +/// @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: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// request.client_id = "s6BhdRkqt3"; +/// request.redirect_uri = "https://client.example/cb"; +/// std::string url; +/// sourcemeta::core::oauth_build_authorization_url( +/// "https://server.example/authorize", request, url); +/// assert(url == +/// "https://server.example/authorize?response_type=code" +/// "&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2Fclient.example%2Fcb"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_url(const std::string_view endpoint, + const OAuthAuthorizationRequest &request, + std::string &sink) -> void; + +/// @ingroup oauth +/// Parse the query of an authorization request at the authorization server +/// (RFC 6749 Section 4.1.1) into the result, returning whether it is well +/// formed. 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. A duplicated parameter is a failure (RFC 6749 +/// Section 3.1), and `code_challenge_method` defaults to `plain` when a +/// challenge is present without one (RFC 7636 Section 4.3). Every repeatable +/// `resource` and every unrecognized parameter is passed to the callback with +/// its decoded value rather than stored on the result, so the caller collects +/// them. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// assert(sourcemeta::core::oauth_parse_authorization_request( +/// "response_type=code&client_id=s6BhdRkqt3", storage, request, +/// [](std::string_view, std::string_view) {})); +/// assert(request.response_type == "code"); +/// assert(request.client_id == "s6BhdRkqt3"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_authorization_request( + const std::string_view query, std::string &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// Whether a redirection URI a client presents matches one it registered +/// (RFC 6749 Section 3.1.2.3), by an exact byte comparison with one exception. +/// When the registered URI is a loopback redirect, an `http` URI whose host is +/// literally `127.0.0.1` or `[::1]`, only the port may differ (RFC 8252 +/// Section 7.3). The strict profile keeps the OAuth 2.1 rule that `localhost` +/// is not a loopback host (RFC 8252 Section 8.3). No URI is constructed, so the +/// inputs are compared as given. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_redirect_uri_matches( +/// "http://127.0.0.1:49152/cb", "http://127.0.0.1:51004/cb", +/// sourcemeta::core::OAuthProfile::Strict)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_redirect_uri_matches(const std::string_view registered, + const std::string_view presented, + const OAuthProfile profile) -> bool; + +/// @ingroup oauth +/// Whether a URI scheme is a private-use scheme suitable for a native app +/// redirect, a reverse-domain name that must contain at least one period +/// (RFC 8252 Section 7.1 and Section 8.4). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_private_use_scheme("com.example.app")); +/// assert(!sourcemeta::core::oauth_is_private_use_scheme("myapp")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_private_use_scheme(const std::string_view scheme) noexcept + -> bool; + +/// @ingroup oauth +/// A non-owning view of an authorization response returned on the redirection +/// endpoint (RFC 6749 Section 4.1.2). Each field borrows from the parsed query +/// or the decode arena, so both must outlive the view, and an absent parameter +/// is an empty view. A present `error` marks a failure response (RFC 6749 +/// Section 4.1.2.1). +struct OAuthAuthorizationResponse { + /// The authorization code (RFC 6749 Section 4.1.2). + std::string_view code; + /// The state value echoed from the request (RFC 6749 Section 4.1.2). + std::string_view state; + /// The issuer identifier of the authorization server (RFC 9207 Section 2). + std::string_view iss; + /// The error code of a failure response (RFC 6749 Section 4.1.2.1). + std::string_view error; + /// The human-readable error description of a failure response (RFC 6749 + /// Section 4.1.2.1). + std::string_view error_description; + /// The URI of a human-readable error page for a failure response (RFC 6749 + /// Section 4.1.2.1). + std::string_view error_uri; +}; + +/// @ingroup oauth +/// Parse the query of an authorization response (RFC 6749 Section 4.1.2) into +/// the result, returning whether the query is well formed. Each recognized +/// value is form-decoded (RFC 6749 Appendix B), borrowing from the input when +/// it carries no escape and otherwise from the storage arena, which the caller +/// owns and which is reused across parses. A duplicated recognized parameter is +/// a failure, and an unrecognized parameter is ignored. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// assert(sourcemeta::core::oauth_parse_authorization_response( +/// "code=SplxlOBeZQQYbYS6WxSbIA&state=xyz", storage, response)); +/// assert(response.code == "SplxlOBeZQQYbYS6WxSbIA"); +/// assert(response.state == "xyz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_authorization_response(const std::string_view query, + std::string &storage, + OAuthAuthorizationResponse &result) + -> bool; + +/// @ingroup oauth +/// Build a successful authorization redirect at the authorization server by +/// appending the query to the client's redirection endpoint (RFC 6749 +/// Section 4.1.2), returning whether it was produced. The `code` is required, +/// `state` is echoed when present, and an `iss` is emitted when present and +/// must be a valid issuer identifier (RFC 9207 Section 2). No value is produced +/// when the redirect URI contains a fragment, which a redirection endpoint must +/// not (RFC 6749 Section 3.1.2). Every value is percent-escaped, an existing +/// query on the endpoint is honored, and the sink is appended to and never +/// cleared. The redirect URI and the response fields must not alias the sink. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.code = "SplxlOBeZQQYbYS6WxSbIA"; +/// response.state = "xyz"; +/// std::string url; +/// assert(sourcemeta::core::oauth_build_authorization_redirect( +/// "https://client.example/cb", response, url)); +/// assert(url == "https://client.example/cb?code=SplxlOBeZQQYbYS6WxSbIA" +/// "&state=xyz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> bool; + +/// @ingroup oauth +/// Build a failed authorization redirect at the authorization server (RFC 6749 +/// Section 4.1.2.1), returning whether it was produced. The `error` is +/// required, `error_description`, `error_uri`, and `state` are emitted when +/// present, and an `iss` is emitted when present and must be a valid issuer +/// identifier (RFC 9207 Section 2). This 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. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.state = "xyz"; +/// response.error = "access_denied"; +/// std::string url; +/// assert(sourcemeta::core::oauth_build_authorization_error_redirect( +/// "https://client.example/cb", response, url)); +/// assert(url == +/// "https://client.example/cb?error=access_denied&state=xyz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_error_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h new file mode 100644 index 000000000..a5db27ec5 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h @@ -0,0 +1,163 @@ +#ifndef SOURCEMETA_CORE_OAUTH_BEARER_H_ +#define SOURCEMETA_CORE_OAUTH_BEARER_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append a `Bearer` credential (RFC 6750 Section 2.1) for an access token to +/// the sink, returning whether the token is a well-formed `b64token`. Nothing +/// is appended when it is not. The sink then holds the access token, which is +/// secret, so a caller that keeps it should use wiping storage. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string header; +/// assert(sourcemeta::core::oauth_bearer_header("mF_9.B5f-4.1JqM", header)); +/// assert(header == "Bearer mF_9.B5f-4.1JqM"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_bearer_header(const std::string_view token, std::string &sink) + -> bool; + +/// @ingroup oauth +/// Find the value of one authentication parameter within the challenge for a +/// scheme in a `WWW-Authenticate` header value (RFC 7235 Section 4.1), +/// returning no value when the scheme or parameter is absent, or the header is +/// malformed before the parameter is reached. The scheme and parameter name are +/// matched case insensitively, and a quoted-string value is unescaped (RFC 9110 +/// Section 5.6.4). The full grammar is parsed so that adjacent challenges and +/// parameters are told apart correctly. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto realm{sourcemeta::core::oauth_challenge_parameter( +/// R"(Bearer realm="example", error="invalid_token")", "Bearer", "realm")}; +/// assert(realm.has_value()); +/// assert(realm.value() == "example"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_challenge_parameter(const std::string_view header, + const std::string_view scheme, + const std::string_view name) + -> std::optional; + +/// @ingroup oauth +/// The parameters of a `WWW-Authenticate` challenge a protected resource +/// returns (RFC 6750 Section 3, RFC 9728 Section 5.1, RFC 9449 Section 7.1). +/// Every field is a non-owning view that must outlive any use of this struct, +/// and an empty field is omitted from the challenge. +struct OAuthChallenge { + /// The protection space the credentials apply to (RFC 6750 Section 3). + std::string_view realm; + /// The space-delimited scope the token must carry (RFC 6750 Section 3). + std::string_view scope; + /// The error code (RFC 6750 Section 3.1). + std::string_view error; + /// The human-readable error description (RFC 6750 Section 3). + std::string_view error_description; + /// The URI of a human-readable error page (RFC 6750 Section 3). + std::string_view error_uri; + /// The URL of the protected resource metadata document (RFC 9728 + /// Section 5.1). + std::string_view resource_metadata; + /// The space-delimited JWS algorithms the DPoP scheme accepts (RFC 9449 + /// Section 7.1). + std::string_view algs; +}; + +/// @ingroup oauth +/// Build a `WWW-Authenticate` challenge for a scheme and append it to the sink, +/// returning whether a challenge was produced (RFC 7235 Section 4.1). Each +/// present parameter is emitted as a quoted-string with the double quote and +/// backslash escaped (RFC 9110 Section 5.6.4). A challenge with no parameter +/// yields a bare scheme, which RFC 7235 Section 2.1 permits and a DPoP +/// challenge may use (RFC 9449 Section 7.1), so the `Bearer` scheme's own rule +/// that it carry at least one parameter (RFC 6750 Section 3) is the caller's +/// responsibility. Nothing is appended and false is returned only when the +/// scheme is not a token or a value carries a control character that would +/// allow header injection. Only header safety is enforced, so keeping each +/// value within the tighter character set its own attribute defines (RFC 6750 +/// Section 3) is likewise the caller's responsibility. It is a pure function, +/// so a resource server builds its fixed challenge once and caches it. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthChallenge challenge; +/// challenge.realm = "example"; +/// challenge.error = "invalid_token"; +/// std::string header; +/// sourcemeta::core::oauth_build_challenge("Bearer", challenge, header); +/// assert(header == R"(Bearer realm="example", error="invalid_token")"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_challenge(const std::string_view scheme, + const OAuthChallenge &challenge, std::string &sink) + -> bool; + +/// @ingroup oauth +/// Whether a set of access token claims names an audience, so that a resource +/// server accepts only a token minted for it (RFC 9068 Section 4, RFC 7662 +/// Section 2.2). The claims are the payload of a JWT access token or an +/// introspection response, and the `aud` claim is honored whether it is a +/// single string or an array of strings. Each value is treated as an opaque +/// case-sensitive string (RFC 7519 Section 4.1.3) and compared by code points +/// (RFC 3986 Section 6.2.1), with no normalization. An empty audience never +/// matches. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto claims{ +/// sourcemeta::core::parse_json(R"JSON({"aud":"https://api.example"})JSON")}; +/// assert(sourcemeta::core::oauth_has_audience(claims, "https://api.example")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_has_audience(const JSON &claims, const std::string_view audience) + -> bool; + +/// @ingroup oauth +/// Whether a set of access token claims carries the DPoP confirmation that +/// binds the token to a proof-of-possession key, namely a `jkt` JWK thumbprint +/// (RFC 9449 Section 6.1). A resource server that receives such a token under +/// the `Bearer` scheme must reject it, since a key-bound token stripped of its +/// proof is being replayed (RFC 9449 Section 7.2). Only the DPoP confirmation +/// is detected, not other sender-constraining methods such as mutual-TLS. The +/// claims are the payload of a JWT access token or an introspection response. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto claims{sourcemeta::core::parse_json( +/// R"JSON({"cnf":{"jkt":"0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"}})JSON")}; +/// assert(sourcemeta::core::oauth_is_dpop_bound(claims)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_dpop_bound(const JSON &claims) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_client_authentication.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_client_authentication.h new file mode 100644 index 000000000..1f4a6c3d0 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_client_authentication.h @@ -0,0 +1,151 @@ +#ifndef SOURCEMETA_CORE_OAUTH_CLIENT_AUTHENTICATION_H_ +#define SOURCEMETA_CORE_OAUTH_CLIENT_AUTHENTICATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::uint8_t +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append an HTTP `Basic` authentication credential (RFC 6749 Section 2.3.1) to +/// the sink, for use as an `Authorization` header value. The client identifier +/// and secret are each percent-encoded, joined with a colon, and Base64 +/// encoded. The credential is secret, so the sink is a wiping string, and it is +/// appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString header; +/// sourcemeta::core::oauth_client_secret_basic("s6BhdRkqt3", "gX1fBat3bV", +/// header); +/// assert(header == "Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_secret_basic(const std::string_view client_id, + const std::string_view client_secret, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append the `client_secret_post` client authentication parameters (RFC 6749 +/// Section 2.3.1) to a token request body. Both the identifier and the secret +/// are emitted, so this composes into the same sink as a grant builder. The +/// body carries a secret, so the sink is a wiping string, and it is appended to +/// and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_client_secret_post("id", "secret", body); +/// assert(body == "client_id=id&client_secret=secret"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_secret_post(const std::string_view client_id, + const std::string_view client_secret, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append a public client identification parameter (RFC 6749 Section 3.2.1) to +/// a token request body, emitting the identifier alone with no secret. This +/// composes into the same sink as a grant builder. The sink is appended to and +/// never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_client_id_only("s6BhdRkqt3", body); +/// assert(body == "client_id=s6BhdRkqt3"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_id_only(const std::string_view client_id, SecureString &sink) + -> void; + +/// @ingroup oauth +/// The client authentication mechanism a token request presented (RFC 6749 +/// Section 2.3). `Public` is a bare client identifier with no secret, which is +/// identification rather than an authentication mechanism (RFC 6749 +/// Section 3.2.1). +enum class OAuthClientAuthenticationMethod : std::uint8_t { + /// No client authentication and no identifier were presented. + None, + /// An HTTP `Basic` credential (RFC 6749 Section 2.3.1). + Basic, + /// A `client_secret` in the request body (RFC 6749 Section 2.3.1). + Post, + /// A bare `client_id` in the request body, identifying a public client + /// (RFC 6749 Section 3.2.1). + Public, + /// A `client_assertion` and its type (RFC 7521 Section 4.2). + Assertion +}; + +/// @ingroup oauth +/// The client credentials a token request presented, each a non-owning view +/// into the request or the decode arena (RFC 6749 Section 2.3). An absent field +/// is an empty view. +struct OAuthClientCredentials { + /// The mechanism the request presented. + OAuthClientAuthenticationMethod method; + /// The client identifier. + std::string_view client_id; + /// The client secret, present only for `Basic` and `Post`. + std::string_view client_secret; + /// The assertion type, present only for `Assertion` (RFC 7521 Section 4.2). + std::string_view assertion_type; + /// The assertion, present only for `Assertion` (RFC 7521 Section 4.2). + std::string_view assertion; +}; + +/// @ingroup oauth +/// Parse the client authentication a token request presented, from the +/// `Authorization` header value and the request body, into the credentials +/// (RFC 6749 Section 2.3). Returns whether the presentation is well formed. It +/// is malformed when more than one mechanism is presented (RFC 6749 +/// Section 2.3, RFC 7521 Section 4.2.1), when a `Basic` username conflicts with +/// a body `client_id` (RFC 6749 Section 5.2), when the `Basic` credential is +/// not a canonical Base64 of a colon-separated pair, or when only one of the +/// assertion parameters is present. The caller chooses the error code for a +/// rejection, which is `invalid_client` when a collision involves the assertion +/// mechanism (RFC 7521 Section 4.2.1) and `invalid_request` otherwise (RFC 6749 +/// Section 5.2). The decoded `Basic` credential is secret, so the arena is a +/// wiping string, which the caller owns, should clear between independent +/// parses, and must not alias the header or body inputs. This detects the +/// mechanism and extracts the credentials, but does +/// not verify the secret, which the caller does against its stored value in +/// constant time, nor that an `Assertion` `client_id` identifies the same +/// client as the assertion (RFC 7521 Section 4.2), which the caller checks when +/// it verifies the assertion. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthClientCredentials credentials; +/// assert(sourcemeta::core::oauth_parse_client_authentication( +/// "Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW", "", storage, credentials)); +/// assert(credentials.method == +/// sourcemeta::core::OAuthClientAuthenticationMethod::Basic); +/// assert(credentials.client_id == "s6BhdRkqt3"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_client_authentication(const std::string_view authorization, + const std::string_view body, + SecureString &storage, + OAuthClientCredentials &credentials) + -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_device.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_device.h new file mode 100644 index 000000000..c17868142 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_device.h @@ -0,0 +1,254 @@ +#ifndef SOURCEMETA_CORE_OAUTH_DEVICE_H_ +#define SOURCEMETA_CORE_OAUTH_DEVICE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include +#include + +#include // std::array +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::uint8_t +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// Append a device authorization request body (RFC 8628 Section 3.1) to the +/// sink. The `client_id` is required for a public client, the `scope` is +/// emitted when present, and the repeatable resources follow. This request has +/// no secret, so the sink is an ordinary string, appended to and never cleared. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string body; +/// sourcemeta::core::oauth_build_device_authorization_request( +/// "1406020730", "read", {}, body); +/// assert(body == "client_id=1406020730&scope=read"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_device_authorization_request( + const std::string_view client_id, const std::string_view scope, + const std::span resources, std::string &sink) -> void; + +/// @ingroup oauth +/// Append a device grant token request body (RFC 8628 Section 3.4) to the sink. +/// The `device_code` is required, and the repeatable resources follow. No +/// `client_id` is emitted, so the caller composes a client authentication +/// builder into the same sink. The device code is a credential, so the sink is +/// a wiping string, appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_device( +/// "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", {}, body); +/// assert(body == "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3A" +/// "device_code" +/// "&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_device( + const std::string_view device_code, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// A non-owning view over a device authorization response (RFC 8628 +/// Section 3.2) held in a caller-owned JSON value, which must outlive the view. +/// A client keeps displaying the user code even when it uses the complete +/// verification URI (RFC 8628 Section 3.3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json(R"JSON({ +/// "device_code": "GmRh", "user_code": "WDJB-MJHT", +/// "verification_uri": "https://example.com/device", "expires_in": 1800 +/// })JSON")}; +/// const sourcemeta::core::OAuthDeviceAuthorizationResponse response{document}; +/// assert(response.user_code().value() == "WDJB-MJHT"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDeviceAuthorizationResponse { +public: + /// Construct a view over a device authorization response, which is borrowed. + explicit OAuthDeviceAuthorizationResponse(const JSON &data); + + /// The device verification code (RFC 8628 Section 3.2). + [[nodiscard]] auto device_code() const -> std::optional; + /// The end-user verification code (RFC 8628 Section 3.2). + [[nodiscard]] auto user_code() const -> std::optional; + /// The end-user verification URI (RFC 8628 Section 3.2). + [[nodiscard]] auto verification_uri() const + -> std::optional; + /// The verification URI with the user code included (RFC 8628 Section 3.2). + [[nodiscard]] auto verification_uri_complete() const + -> std::optional; + /// The lifetime of the device and user codes, no value when non-positive + /// (RFC 8628 Section 3.2). + [[nodiscard]] auto expires_in() const -> std::optional; + /// The minimum polling interval, defaulting to five seconds when absent + /// (RFC 8628 Section 3.2). + [[nodiscard]] auto interval() const -> std::chrono::seconds; + + /// The underlying document. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; +}; + +/// @ingroup oauth +/// What a device flow client does after a token endpoint error (RFC 8628 +/// Section 3.5). +enum class OAuthDevicePollDecision : std::uint8_t { + /// Keep polling at the current interval. + Continue, + /// The request needs a fresh DPoP nonce, so the client re-issues the proof + /// with the server-supplied nonce and polls again without growing the + /// interval (RFC 9449 Section 8). + RetryWithNonce, + /// The end user denied the request. + Denied, + /// The codes expired before approval. + Expired, + /// A terminal error other than pending, slow down, denial, expiry, or a + /// nonce requirement. + Error +}; + +/// @ingroup oauth +/// A pure state machine driving the device flow polling (RFC 8628 Section 3.5). +/// It tracks the polling interval and the code lifetime, and interprets token +/// endpoint errors, without performing any I/O. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthDevicePoller poller{ +/// std::chrono::seconds{5}, std::chrono::seconds{1800}, +/// std::chrono::steady_clock::now()}; +/// assert(poller.observe(sourcemeta::core::OAuthTokenError::SlowDown) == +/// sourcemeta::core::OAuthDevicePollDecision::Continue); +/// assert(poller.interval() == std::chrono::seconds{10}); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDevicePoller { +public: + /// Construct a poller with the interval and lifetime the device authorization + /// response advertised, from a starting time. A steady clock is used so wall + /// clock adjustments cannot shorten or extend the lifetime. An interval of + /// zero or less defaults to five seconds (RFC 8628 Section 3.5). + OAuthDevicePoller(const std::chrono::seconds interval, + const std::chrono::seconds lifetime, + const std::chrono::steady_clock::time_point start) noexcept; + + /// The current polling interval, which a `slow_down` error grows. + [[nodiscard]] auto interval() const noexcept -> std::chrono::seconds; + + /// Whether the codes have expired locally by the given time. + [[nodiscard]] auto + expired(const std::chrono::steady_clock::time_point now) const noexcept + -> bool; + + /// Interpret a token endpoint error, permanently adding five seconds to the + /// interval on `slow_down` and continuing, continuing on + /// `authorization_pending`, retrying with a nonce on a DPoP nonce requirement + /// (RFC 9449 Section 8), and reporting a terminal decision otherwise (RFC + /// 8628 Section 3.5). + auto observe(const OAuthTokenError error) noexcept -> OAuthDevicePollDecision; + +private: + std::chrono::seconds interval_; + std::chrono::seconds lifetime_; + std::chrono::steady_clock::time_point start_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// Build a device authorization response document (RFC 8628 Section 3.2), +/// returning no value when a required part is missing: an empty device code, +/// user code, or verification URI, or a non-positive `expires_in`, which is a +/// REQUIRED positive lifetime. The verification URI complete is emitted when +/// present, and the interval only when it is positive and differs from the +/// default, so a client never sees a zero or negative polling delay. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto +/// response{sourcemeta::core::oauth_make_device_authorization_response( +/// "GmRh", "WDJB-MJHT", "https://example.com/device", "", +/// std::chrono::seconds{1800}, std::chrono::seconds{5})}; +/// assert(response.value().at("user_code").to_string() == "WDJB-MJHT"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_device_authorization_response( + const std::string_view device_code, const std::string_view user_code, + const std::string_view verification_uri, + const std::string_view verification_uri_complete, + const std::chrono::seconds expires_in, const std::chrono::seconds interval) + -> std::optional; + +/// @ingroup oauth +/// Mint a device flow user code, eight characters from the RFC 8628 Section 6.1 +/// recommended twenty-character alphabet, drawn with rejection sampling for a +/// uniform distribution. The code is shown to the end user, not kept secret. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto code{sourcemeta::core::oauth_device_user_code()}; +/// assert(code.size() == 8); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_device_user_code() -> std::array; + +/// @ingroup oauth +/// Whether a user code the end user typed matches a stored one, comparing after +/// discarding every non-alphanumeric character and folding to uppercase, so the +/// separators and case a user adds do not matter (RFC 8628 Section 6.1). The +/// final comparison is constant time. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_device_user_code_matches("wdjb-mjht", +/// "WDJBMJHT")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_device_user_code_matches(const std::string_view presented, + const std::string_view stored) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_dpop.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_dpop.h new file mode 100644 index 000000000..e288850bc --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_dpop.h @@ -0,0 +1,336 @@ +#ifndef SOURCEMETA_CORE_OAUTH_DPOP_H_ +#define SOURCEMETA_CORE_OAUTH_DPOP_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include + +#include // std::array +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::size_t +#include // std::uint8_t +#include // std::map +#include // std::mutex +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view +#include // std::vector + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The token type of a DPoP-bound access token (RFC 9449 Section 5), the value +/// a bound token response carries and a client confirms before use. +inline constexpr std::string_view OAUTH_TOKEN_TYPE_DPOP{"DPoP"}; + +/// @ingroup oauth +/// A client-side minter of DPoP proof JSON Web Tokens (RFC 9449 Section 4.2) +/// that holds one proof-of-possession key and the most recent nonce each server +/// has issued. Nonces are tracked per server because a nonce is only accepted +/// by the server that issued it (RFC 9449 Section 9), so a caller keys the +/// authorization server by its issuer and each resource server by its origin. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// #include +/// +/// auto key{sourcemeta::core::JWKPrivate::from_pem(pem)}; +/// assert(key.has_value()); +/// sourcemeta::core::OAuthDPoPProofer proofer{ +/// std::move(key.value()), sourcemeta::core::JWSAlgorithm::ES256}; +/// std::string header; +/// assert(proofer.proof("https://server.example.com", "POST", +/// "https://server.example.com/token", "", +/// std::chrono::system_clock::now(), header)); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDPoPProofer { +public: + /// Construct a proofer bound to a proof-of-possession key and the asymmetric + /// signature algorithm to sign proofs with (RFC 9449 Section 4.2). + OAuthDPoPProofer(JWKPrivate key, const JWSAlgorithm algorithm); + + /// A proofer owns a private key and a mutex, so it is neither copied nor + /// moved. + OAuthDPoPProofer(const OAuthDPoPProofer &) = delete; + auto operator=(const OAuthDPoPProofer &) -> OAuthDPoPProofer & = delete; + OAuthDPoPProofer(OAuthDPoPProofer &&) = delete; + auto operator=(OAuthDPoPProofer &&) -> OAuthDPoPProofer & = delete; + ~OAuthDPoPProofer() = default; + + /// Append a DPoP proof for a request to the sink, returning whether it could + /// be built and signed. The method and target URI are covered by the proof, + /// the access token binds the proof through its hash when it is presented to + /// a protected resource and is omitted otherwise (RFC 9449 Section 7), and + /// the most recent nonce the server issued is included when one is known. The + /// creation time is taken from the given clock reading (RFC 9449 + /// Section 4.2). + [[nodiscard]] auto + proof(const std::string_view server, const std::string_view method, + const std::string_view url, const std::string_view access_token, + const std::chrono::system_clock::time_point now, std::string &sink) + -> bool; + + /// Record the most recent nonce a server issued through its response header, + /// to be included in later proofs to that server, ignoring a malformed nonce + /// rather than echoing it (RFC 9449 Section 8). + auto observe(const std::string_view server, const std::string_view nonce) + -> void; + + /// The JSON Web Key thumbprint of the proof-of-possession key (RFC 9449 + /// Section 6.1), the value a client sends to bind an authorization code to + /// the key (RFC 9449 Section 10), or no value when the public part cannot be + /// recovered. + [[nodiscard]] auto thumbprint() const -> std::optional; + +private: + JWKPrivate key_; + JWSAlgorithm algorithm_; +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + mutable std::mutex mutex_; + std::map> nonces_; +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif +}; + +/// @ingroup oauth +/// Build the confirmation value that binds an access token to a DPoP key +/// (RFC 9449 Section 6.1), for a server to assign under the `cnf` claim of a +/// JSON Web Token access token or a top-level `cnf` of a token introspection +/// response (RFC 9449 Section 6.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto confirmation{sourcemeta::core::oauth_dpop_confirmation( +/// "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I")}; +/// assert(confirmation.at("jkt").to_string() == +/// "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_dpop_confirmation(const std::string_view thumbprint) -> JSON; + +/// @ingroup oauth +/// The JSON Web Key thumbprint of the key a DPoP proof is signed with (RFC 9449 +/// Section 6.1), the value a server matches against an authorization code +/// binding (RFC 9449 Section 10) or binds an issued token to, or no value when +/// the proof or its embedded key cannot be read. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto thumbprint{sourcemeta::core::oauth_dpop_proof_thumbprint(proof)}; +/// assert(!thumbprint.has_value() || !thumbprint.value().empty()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_dpop_proof_thumbprint(const std::string_view proof) + -> std::optional; + +/// @ingroup oauth +/// The reason a DPoP proof failed verification (RFC 9449 Section 4.3), one per +/// check performed, with a missing nonce kept distinct from a mismatched one +/// because a server resupplies a nonce for the former (RFC 9449 Section 9). +enum class OAuthDPoPError : std::uint8_t { + /// More or fewer than one DPoP header field was present (check 1). + ProofCount, + /// The header value was not a single well-formed JSON Web Token (check 2). + Malformed, + /// A required header parameter or claim was absent (check 3). + MissingClaim, + /// The token type header parameter was not `dpop+jwt` (check 4). + UnexpectedType, + /// The algorithm was absent, symmetric, or outside the accepted set + /// (check 5). + UnsupportedAlgorithm, + /// The embedded key carried private material (check 7). + PrivateKey, + /// The signature did not verify with the embedded key (check 6). + Signature, + /// The method claim did not match the request method (check 8). + MethodMismatch, + /// The target claim did not match the request target (check 9). + TargetMismatch, + /// A nonce was required but absent, the downgrade the server must reject + /// (check 10, RFC 9449 Section 11.3). + MissingNonce, + /// The nonce claim did not match the nonce the server issued (check 10). + NonceMismatch, + /// The creation time was outside the acceptable window (check 11). + Expired, + /// The access token hash was absent or did not match the presented token + /// (check 12). + AccessTokenMismatch, + /// The proof key did not match the key the access token is bound to, or a + /// token was presented with no binding to confirm it against (check 12). + KeyMismatch +}; + +/// @ingroup oauth +/// The inputs to DPoP proof verification a caller supplies beyond the request +/// (RFC 9449 Section 4.3). Every field has a safe default, so a token endpoint +/// verifying a proof with no bound token leaves the token and thumbprint unset, +/// and a protected resource sets both. +struct OAuthDPoPVerifyOptions { + /// The number of DPoP header fields the request carried, checked to be + /// exactly one (RFC 9449 Section 4.3 check 1). + std::size_t proof_count{1}; + /// The asymmetric algorithms accepted per local policy, every asymmetric + /// algorithm when empty (RFC 9449 Section 4.3 check 5). + std::span allowed_algorithms{}; + /// How far in the past a creation time may be (RFC 9449 Section 11.1). + std::chrono::seconds past_window{std::chrono::seconds{300}}; + /// How far in the future a creation time may be, to tolerate clock offset + /// (RFC 9449 Section 11.1). + std::chrono::seconds future_window{std::chrono::seconds{5}}; + /// The nonce the server issued to the client, requiring a matching nonce + /// claim when set (RFC 9449 Section 9). + std::optional expected_nonce{std::nullopt}; + /// The access token presented alongside the proof, requiring a matching hash + /// claim when set, and requiring the bound thumbprint to also be set so the + /// key binding is confirmed rather than skipped (RFC 9449 Section 7). + std::optional access_token{std::nullopt}; + /// The thumbprint the access token is bound to, requiring a matching proof + /// key when set (RFC 9449 Section 4.3 check 12). + std::optional bound_thumbprint{std::nullopt}; +}; + +/// @ingroup oauth +/// Verify a DPoP proof against the request it accompanies (RFC 9449 +/// Section 4.3), returning the first failing check or no value when every check +/// passes. The checks are run in a fixed order though the specification permits +/// any order, and replay is a separate concern the caller enforces after this +/// passes. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthDPoPVerifyOptions options; +/// const auto error{sourcemeta::core::oauth_dpop_verify( +/// proof, "POST", "https://server.example.com/token", +/// std::chrono::system_clock::now(), options)}; +/// assert(!error.has_value() || +/// error.value() == sourcemeta::core::OAuthDPoPError::Expired); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_dpop_verify( + const std::string_view proof, const std::string_view method, + const std::string_view url, const std::chrono::system_clock::time_point now, + const OAuthDPoPVerifyOptions &options) -> std::optional; + +/// @ingroup oauth +/// An in-memory store of the proof identifiers seen within their acceptance +/// window, to reject a replayed DPoP proof at one target (RFC 9449 +/// Section 11.1). A store instance is safe to share across threads, it holds a +/// hash of each identifier rather than the identifier itself, and it is bounded +/// to a fixed capacity so an attacker minting distinct proofs cannot exhaust +/// memory. When full it fails closed, rejecting a new identifier rather than +/// evicting a live entry whose replay guard is still needed, so a flood costs +/// availability rather than replay protection. Give each entry a window that +/// covers the whole acceptance window a proof enjoys, the sum of the past and +/// future tolerances, so an entry never expires while its proof is still +/// accepted. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthDPoPReplayStore store; +/// const auto now{std::chrono::system_clock::now()}; +/// assert(store.check_and_insert("id", "https://server.example.com/token", now, +/// std::chrono::seconds{300})); +/// assert(!store.check_and_insert("id", "https://server.example.com/token", +/// now, +/// std::chrono::seconds{300})); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDPoPReplayStore { +public: + /// The default maximum number of live entries a store retains. + static constexpr std::size_t DEFAULT_CAPACITY{131072}; + + /// Construct an empty store with the given maximum number of live entries, + /// beyond which a new identifier is rejected until a slot frees on expiry. + explicit OAuthDPoPReplayStore( + const std::size_t capacity = DEFAULT_CAPACITY) noexcept + : capacity_{capacity == 0 ? DEFAULT_CAPACITY : capacity} {} + + /// A store owns a mutex, so it is neither copied nor moved. + OAuthDPoPReplayStore(const OAuthDPoPReplayStore &) = delete; + auto operator=(const OAuthDPoPReplayStore &) + -> OAuthDPoPReplayStore & = delete; + OAuthDPoPReplayStore(OAuthDPoPReplayStore &&) = delete; + auto operator=(OAuthDPoPReplayStore &&) -> OAuthDPoPReplayStore & = delete; + ~OAuthDPoPReplayStore() = default; + + /// Record a proof identifier at a target and report whether it is new, + /// returning false for one already seen within its window, which is a replay + /// (RFC 9449 Section 11.1). Entries whose window has elapsed by the given + /// time are pruned, and a new identifier is refused once the store is full of + /// live entries, so the return also stands for a store that cannot admit it. + /// The target is canonicalized as an HTTP target URI when `normalize_target` + /// is set, so an equivalent but differently spelled DPoP target still + /// collides (RFC 9449 Section 4.3 check 9), and keyed verbatim otherwise, for + /// a target compared without normalization such as an assertion audience. + [[nodiscard]] auto + check_and_insert(const std::string_view identifier, + const std::string_view target, + const std::chrono::system_clock::time_point now, + const std::chrono::seconds window, + const bool normalize_target = true) -> bool; + + /// The number of entries whose window has not elapsed by the given time, + /// counted without modifying the store, since expired entries are pruned when + /// the next one is recorded. + [[nodiscard]] auto size(const std::chrono::system_clock::time_point now) const + -> std::size_t; + +private: + struct Entry { + std::array digest; + std::chrono::system_clock::time_point expiry; + }; + + std::size_t capacity_; +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + mutable std::mutex mutex_; + std::vector entries_; +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif +}; + +/// @ingroup oauth +/// Whether a value is a well-formed DPoP nonce, a non-empty string of the nonce +/// character set (RFC 9449 Section 8.1, RFC 6749 Appendix A), so a client +/// validates a received nonce before echoing it. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_valid_dpop_nonce("eyJ7S_zG.eyJH0-Z.HX4w")); +/// assert(!sourcemeta::core::oauth_is_valid_dpop_nonce("")); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_is_valid_dpop_nonce(const std::string_view value) noexcept -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_error.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_error.h new file mode 100644 index 000000000..b0e3c8a2e --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_error.h @@ -0,0 +1,315 @@ +#ifndef SOURCEMETA_CORE_OAUTH_ERROR_H_ +#define SOURCEMETA_CORE_OAUTH_ERROR_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::uint8_t +#include // std::exception +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The error codes an authorization endpoint returns in its redirect +/// (RFC 6749 Section 4.1.2.1). +enum class OAuthAuthorizationError : std::uint8_t { + /// The request is missing a parameter, includes an invalid value, or is + /// otherwise malformed. + InvalidRequest, + /// The client is not authorized to request a code using this method. + UnauthorizedClient, + /// The resource owner or authorization server denied the request. + AccessDenied, + /// The authorization server does not support this response type. + UnsupportedResponseType, + /// The requested scope is invalid, unknown, or malformed. + InvalidScope, + /// The authorization server encountered an unexpected condition. + ServerError, + /// The authorization server is temporarily unable to handle the request. + TemporarilyUnavailable +}; + +/// @ingroup oauth +/// The error codes a token endpoint returns (RFC 6749 Section 5.2), extended +/// with the codes the device grant, resource indicators, token exchange, DPoP, +/// and token revocation add to the same endpoint family. +enum class OAuthTokenError : std::uint8_t { + /// The request is missing a parameter, includes an unsupported value, or is + /// otherwise malformed (RFC 6749 Section 5.2). + InvalidRequest, + /// Client authentication failed (RFC 6749 Section 5.2). + InvalidClient, + /// The grant or refresh token is invalid, expired, revoked, or mismatched + /// (RFC 6749 Section 5.2). + InvalidGrant, + /// The authenticated client is not authorized to use this grant type + /// (RFC 6749 Section 5.2). + UnauthorizedClient, + /// The grant type is not supported by the authorization server (RFC 6749 + /// Section 5.2). + UnsupportedGrantType, + /// The requested scope is invalid, unknown, or exceeds the grant (RFC 6749 + /// Section 5.2). + InvalidScope, + /// The device authorization is still pending user approval (RFC 8628 + /// Section 3.5). + AuthorizationPending, + /// The client is polling too frequently and must slow down (RFC 8628 + /// Section 3.5). + SlowDown, + /// The end user denied the device authorization request (RFC 8628 + /// Section 3.5). + AccessDenied, + /// The device code expired before the user approved it (RFC 8628 + /// Section 3.5). + ExpiredToken, + /// The requested resource or audience is invalid or unknown (RFC 8707 + /// Section 2, RFC 8693 Section 2.2.2). + InvalidTarget, + /// The DPoP proof is missing or invalid (RFC 9449 Section 5). + InvalidDPoPProof, + /// The authorization server requires the client to use a DPoP nonce + /// (RFC 9449 Section 8). + UseDPoPNonce, + /// The token type is not supported by the revocation endpoint (RFC 7009 + /// Section 2.2.1). + UnsupportedTokenType +}; + +/// @ingroup oauth +/// The error codes a protected resource returns in its `WWW-Authenticate` +/// challenge (RFC 6750 Section 3.1), shared by the `Bearer` and `DPoP` +/// schemes. +enum class OAuthBearerError : std::uint8_t { + /// The request is malformed (RFC 6750 Section 3.1). + InvalidRequest, + /// The access token is expired, revoked, malformed, or otherwise invalid + /// (RFC 6750 Section 3.1). + InvalidToken, + /// The token does not carry the scope the request requires (RFC 6750 + /// Section 3.1). + InsufficientScope, + /// The DPoP proof accompanying the request is missing or invalid, returned + /// only under the `DPoP` scheme (RFC 9449 Section 7.1). + InvalidDPoPProof, + /// The request must be retried with a DPoP proof that carries a nonce the + /// resource server supplies, returned only under the `DPoP` scheme (RFC 9449 + /// Section 9). + UseDPoPNonce +}; + +/// @ingroup oauth +/// The error codes a dynamic client registration endpoint returns (RFC 7591 +/// Section 3.2.2). +enum class OAuthRegistrationError : std::uint8_t { + /// A redirect URI in the request is invalid. + InvalidRedirectURI, + /// A field in the client metadata is invalid. + InvalidClientMetadata, + /// The software statement is invalid. + InvalidSoftwareStatement, + /// The software statement is not approved by the authorization server. + UnapprovedSoftwareStatement +}; + +/// @ingroup oauth +/// The wire code for an authorization endpoint error (RFC 6749 +/// Section 4.1.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthAuthorizationError::AccessDenied) == +/// "access_denied"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthAuthorizationError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// The wire code for a token endpoint error (RFC 6749 Section 5.2 and its +/// extensions). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthTokenError::InvalidGrant) == +/// "invalid_grant"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthTokenError error) noexcept -> std::string_view; + +/// @ingroup oauth +/// The wire code for a protected resource challenge error (RFC 6750 +/// Section 3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthBearerError::InvalidToken) == +/// "invalid_token"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthBearerError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// The wire code for a dynamic client registration error (RFC 7591 +/// Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthRegistrationError::InvalidRedirectURI) == +/// "invalid_redirect_uri"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthRegistrationError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// Map an authorization endpoint error code to its value, returning no value +/// for an unrecognized code (RFC 6749 Section 4.1.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_authorization_error("invalid_scope") +/// .has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_authorization_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a token endpoint error code to its value, returning no value for an +/// unrecognized code (RFC 6749 Section 5.2 and its extensions). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_token_error("slow_down").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_token_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a protected resource challenge error code to its value, returning no +/// value for an unrecognized code (RFC 6750 Section 3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_bearer_error("invalid_token").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_bearer_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a dynamic client registration error code to its value, returning no +/// value for an unrecognized code (RFC 7591 Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_registration_error("invalid_redirect_uri") +/// .has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_registration_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// The HTTP status for a token endpoint error response. It is 400 Bad Request +/// in general, but 401 Unauthorized for a client authentication failure when +/// the client authenticated through the `Authorization` header, since that +/// response must then carry a `WWW-Authenticate` challenge (RFC 6749 +/// Section 5.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_token_error_status( +/// sourcemeta::core::OAuthTokenError::InvalidClient, true).code == +/// 401); +/// assert(sourcemeta::core::oauth_token_error_status( +/// sourcemeta::core::OAuthTokenError::InvalidGrant, true).code == +/// 400); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_token_error_status(const OAuthTokenError error, + const bool authenticated_via_header) noexcept + -> HTTPStatus; + +/// @ingroup oauth +/// The HTTP status a protected resource returns for a bearer error, as the +/// SHOULD-level recommendation of RFC 6750 Section 3.1: 400 for a malformed +/// request, 401 for an invalid token, and 403 for insufficient scope. The DPoP +/// resource codes accompany a 401 (RFC 9449 Section 7). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_bearer_error_status( +/// sourcemeta::core::OAuthBearerError::InsufficientScope).code == +/// 403); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_bearer_error_status(const OAuthBearerError error) noexcept + -> HTTPStatus; + +#if defined(_MSC_VER) +#pragma warning(disable : 4251 4275) +#endif + +/// @ingroup oauth +/// An error that occurs when parsing an invalid authorization server or +/// protected resource metadata document. +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthMetadataParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OAuth metadata document"; + } +}; + +/// @ingroup oauth +/// An error that occurs when parsing an invalid dynamic client registration +/// request or response. +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthRegistrationParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OAuth client registration document"; + } +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251 4275) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_introspection.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_introspection.h new file mode 100644 index 000000000..cabb75059 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_introspection.h @@ -0,0 +1,123 @@ +#ifndef SOURCEMETA_CORE_OAUTH_INTROSPECTION_H_ +#define SOURCEMETA_CORE_OAUTH_INTROSPECTION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include // std::chrono::seconds +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// Append a token introspection request body (RFC 7662 Section 2.1) to the +/// sink. The `token` is required, and the `token_type_hint` is emitted only +/// when present. The authorization the endpoint requires is supplied +/// separately, since RFC 7662 Section 2.1 mandates it but does not fix the +/// method. The token is secret, so the sink is a wiping string, and it is +/// appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_introspection_request( +/// "mF_9.B5f-4.1JqM", "", body); +/// assert(body == "token=mF_9.B5f-4.1JqM"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_introspection_request(const std::string_view token, + const std::string_view token_type_hint, + SecureString &sink) -> void; + +/// @ingroup oauth +/// A non-owning view over a token introspection response (RFC 7662 Section 2.2) +/// held in a caller-owned JSON value, which must outlive the view. The `active` +/// state is computed once at construction, since it is the one per-request hot +/// accessor. A response must not be cached past its `exp` (RFC 7662 Section 4). +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json( +/// R"JSON({"active":true,"scope":"read","client_id":"abc"})JSON")}; +/// const sourcemeta::core::OAuthIntrospectionResponse response{document}; +/// assert(response.active()); +/// assert(response.scope().value() == "read"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthIntrospectionResponse { +public: + /// Construct a view over an introspection response document, which is + /// borrowed. + explicit OAuthIntrospectionResponse(const JSON &data); + + /// Whether the token is active (RFC 7662 Section 2.2), computed at + /// construction. A missing or non-boolean `active` is treated as inactive. + [[nodiscard]] auto active() const noexcept -> bool; + + /// The space-delimited scope (RFC 7662 Section 2.2), or no value when absent. + [[nodiscard]] auto scope() const -> std::optional; + /// The client identifier the token was issued to (RFC 7662 Section 2.2). + [[nodiscard]] auto client_id() const -> std::optional; + /// The resource owner username (RFC 7662 Section 2.2). + [[nodiscard]] auto username() const -> std::optional; + /// The token type, which is `DPoP` for a DPoP-bound token (RFC 7662 + /// Section 2.2, RFC 9449 Section 6.2). + [[nodiscard]] auto token_type() const -> std::optional; + /// The subject of the token (RFC 7662 Section 2.2). + [[nodiscard]] auto subject() const -> std::optional; + /// The issuer of the token (RFC 7662 Section 2.2). + [[nodiscard]] auto issuer() const -> std::optional; + /// The identifier of the token (RFC 7662 Section 2.2). + [[nodiscard]] auto jti() const -> std::optional; + /// The expiration time (RFC 7662 Section 2.2), or no value when absent. + [[nodiscard]] auto expiration() const -> std::optional; + /// The issuance time (RFC 7662 Section 2.2). + [[nodiscard]] auto issued_at() const -> std::optional; + /// The not-before time (RFC 7662 Section 2.2). + [[nodiscard]] auto not_before() const -> std::optional; + + /// The underlying document, for reaching members without a typed accessor + /// such as `aud`, `may_act`, or `cnf`. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; + bool active_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// Build the minimal inactive introspection response `{ "active": false }` +/// (RFC 7662 Section 2.2). The minimality is a recommendation, while the field +/// value is the requirement, so a caller may add members. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto response{sourcemeta::core::oauth_make_introspection_inactive()}; +/// assert(!response.at("active").to_boolean()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_introspection_inactive() -> JSON; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h new file mode 100644 index 000000000..69c857708 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h @@ -0,0 +1,329 @@ +#ifndef SOURCEMETA_CORE_OAUTH_METADATA_H_ +#define SOURCEMETA_CORE_OAUTH_METADATA_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::uint8_t +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The kind of metadata document a well-known URL points at, which selects the +/// suffix and whether it is inserted before the path or appended after it. +enum class OAuthWellKnownKind : std::uint8_t { + /// The `oauth-authorization-server` document, inserted before the path + /// (RFC 8414 Section 3). + AuthorizationServer, + /// The `oauth-protected-resource` document, inserted before the path + /// (RFC 9728 Section 3). + ProtectedResource, + /// The `openid-configuration` document, inserted before the path (RFC 8414 + /// Section 5). + OpenIDConfigurationInserted, + /// The `openid-configuration` document, appended after the path, the legacy + /// OpenID Connect Discovery form (RFC 8414 Section 5). + OpenIDConfigurationAppended +}; + +/// @ingroup oauth +/// Derive a metadata well-known URL from an identifier and append it to the +/// sink, returning whether the identifier is well formed (RFC 8414 Section 3, +/// RFC 9728 Section 3). The identifier must use the `https` scheme and carry no +/// fragment, and no query unless it is a protected resource. A terminating +/// slash on the path is removed, and for the inserted kinds the well-known +/// string is placed between the host and the path, preserving a protected +/// resource query after it. The sink is appended to and never cleared. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string url; +/// assert(sourcemeta::core::oauth_well_known_url( +/// "https://example.com/issuer1", +/// sourcemeta::core::OAuthWellKnownKind::AuthorizationServer, url)); +/// assert(url == +/// "https://example.com/.well-known/oauth-authorization-server/issuer1"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_well_known_url(const std::string_view identifier, + const OAuthWellKnownKind kind, std::string &sink) + -> bool; + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// An authorization server metadata document (RFC 8414), owning its JSON. The +/// document is validated on construction against the issuer it was retrieved +/// for: the issuer must match by exact code points and be a valid issuer +/// identifier, `response_types_supported` must be present and non-empty, and an +/// authentication method that needs a signing algorithm list must carry a +/// non-empty one without `none`. Accessors apply the specification defaults, +/// and any member without a typed accessor is reachable through the underlying +/// document. A string accessor returns a view into the owned document, valid +/// for the lifetime of this object, so a view taken before the object is moved +/// from must not be used afterward. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json( +/// R"JSON({"issuer":"https://example.com", +/// "response_types_supported":["code"], +/// "authorization_endpoint":"https://example.com/authorize", +/// "token_endpoint":"https://example.com/token"})JSON")}; +/// const auto metadata{sourcemeta::core::OAuthServerMetadata::from( +/// std::move(document), "https://example.com")}; +/// assert(metadata.has_value()); +/// assert(metadata.value().token_endpoint().value() == +/// "https://example.com/token"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthServerMetadata { +public: + /// Construct and validate a metadata document for an expected issuer, + /// throwing when it is invalid. The document is moved in. + OAuthServerMetadata(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 (RFC 8414 Section 2). + [[nodiscard]] auto issuer() const -> std::string_view; + + /// The authorization endpoint (RFC 8414 Section 2). + [[nodiscard]] auto authorization_endpoint() const + -> std::optional; + + /// The token endpoint (RFC 8414 Section 2). + [[nodiscard]] auto token_endpoint() const -> std::optional; + + /// The dynamic client registration endpoint (RFC 8414 Section 2). + [[nodiscard]] auto registration_endpoint() const + -> std::optional; + + /// The token revocation endpoint (RFC 8414 Section 2). + [[nodiscard]] auto revocation_endpoint() const + -> std::optional; + + /// The token introspection endpoint (RFC 8414 Section 2). + [[nodiscard]] auto introspection_endpoint() const + -> std::optional; + + /// The JWK Set document location (RFC 8414 Section 2). + [[nodiscard]] auto jwks_uri() const -> std::optional; + + /// The pushed authorization request endpoint (RFC 9126 Section 5). + [[nodiscard]] auto pushed_authorization_request_endpoint() const + -> std::optional; + + /// Whether the server accepts authorization request data only through the + /// pushed authorization request endpoint, defaulting to false when absent + /// (RFC 9126 Section 5). + [[nodiscard]] auto require_pushed_authorization_requests() const -> bool; + + /// Whether the server signs authorization responses with an `iss` parameter, + /// defaulting to false when absent (RFC 9207 Section 3). + [[nodiscard]] auto authorization_response_iss_parameter_supported() const + -> bool; + + /// Whether a response type is supported (RFC 8414 Section 2). + [[nodiscard]] auto supports_response_type(const std::string_view value) const + -> bool; + + /// Whether a grant type is supported, defaulting to the authorization code + /// and implicit grants when absent (RFC 8414 Section 2). + [[nodiscard]] auto supports_grant_type(const std::string_view value) const + -> bool; + + /// Whether a PKCE code challenge method is supported, defaulting to none when + /// absent since an omitted list means PKCE is unsupported (RFC 8414 + /// Section 2). + [[nodiscard]] auto + supports_code_challenge_method(const std::string_view value) const -> bool; + + /// Whether a token endpoint authentication method is supported, defaulting to + /// `client_secret_basic` when absent (RFC 8414 Section 2). + [[nodiscard]] auto + supports_token_endpoint_auth_method(const std::string_view value) const + -> bool; + + /// The underlying document, for reaching members without a typed accessor. + [[nodiscard]] auto data() const -> const JSON &; + +private: + JSON data_; +}; + +/// @ingroup oauth +/// A protected resource metadata document (RFC 9728), owning its JSON. The +/// document is validated on construction against the resource it was retrieved +/// for: `resource` must be present, a valid resource identifier (an https URL +/// with no fragment, a query tolerated), and identical by code points to the +/// expected resource (RFC 9728 Section 3.3). Pass the resource identifier the +/// well-known URL was derived from, or, for the `WWW-Authenticate` +/// `resource_metadata` flow, the URL the request was made to (the Section 3.3 +/// second check). Only the plain JSON members are read, so a `signed_metadata` +/// statement (RFC 9728 Section 2.2) is not processed. 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({ +/// "resource": "https://api.example", +/// "authorization_servers": [ "https://auth.example" ] +/// })JSON")}; +/// const auto metadata{sourcemeta::core::OAuthResourceMetadata::from( +/// std::move(document), "https://api.example")}; +/// assert(metadata.has_value()); +/// assert(metadata.value().first_authorization_server().value() == +/// "https://auth.example"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthResourceMetadata { +public: + /// Construct and validate a metadata document for an expected resource, + /// throwing when it is invalid. The document is moved in. + OAuthResourceMetadata(JSON &&data, const std::string_view resource); + + /// Construct and validate a metadata document for an expected resource, + /// returning no value when it is invalid. The document is moved in. + [[nodiscard]] static auto from(JSON &&data, const std::string_view resource) + -> std::optional; + + /// The resource identifier (RFC 9728 Section 2). + [[nodiscard]] auto resource() const -> std::string_view; + + /// The first authorization server that can issue tokens for the resource, + /// which a client resolves to its metadata (RFC 9728 Section 5), or no value + /// when none is listed. + [[nodiscard]] auto first_authorization_server() const + -> std::optional; + + /// Whether an authorization server issuer is listed (RFC 9728 Section 2). + [[nodiscard]] auto + supports_authorization_server(const std::string_view value) const -> bool; + + /// The JWK Set document location for the resource's own keys (RFC 9728 + /// Section 2). + [[nodiscard]] auto jwks_uri() const -> std::optional; + + /// Whether a bearer token presentation method is listed (RFC 9728 Section 2), + /// where an explicit empty list means none is supported. Absence is + /// unspecified rather than unsupported, so this returns false then. + [[nodiscard]] auto supports_bearer_method(const std::string_view value) const + -> bool; + + /// Whether a scope is listed for the resource (RFC 9728 Section 2). + [[nodiscard]] auto supports_scope(const std::string_view value) const -> bool; + + /// Whether the resource requires DPoP-bound access tokens, defaulting to + /// false when absent (RFC 9728 Section 2). + [[nodiscard]] auto dpop_bound_access_tokens_required() const -> bool; + + /// The underlying document, for reaching members without a typed accessor + /// such as the internationalized names. + [[nodiscard]] auto data() const -> const JSON &; + +private: + JSON data_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// The configuration an authorization server publishes as its metadata +/// (RFC 8414 Section 2), each field a non-owning view. An empty scalar and a +/// zero-element array are omitted, since RFC 8414 Section 3.2 forbids a +/// zero-element array in the response. +struct OAuthServerMetadataConfig { + /// The issuer identifier (RFC 8414 Section 2), REQUIRED. + std::string_view issuer; + /// The authorization endpoint (RFC 8414 Section 2). + std::string_view authorization_endpoint; + /// The token endpoint (RFC 8414 Section 2). + std::string_view token_endpoint; + /// The dynamic client registration endpoint (RFC 8414 Section 2). + std::string_view registration_endpoint; + /// The JWK Set document location (RFC 8414 Section 2). + std::string_view jwks_uri; + /// The supported response types (RFC 8414 Section 2), REQUIRED and non-empty. + std::span response_types_supported; + /// The supported grant types (RFC 8414 Section 2). + std::span grant_types_supported; + /// The supported PKCE code challenge methods (RFC 8414 Section 2, RFC 7636). + std::span code_challenge_methods_supported; + /// The supported token endpoint authentication methods (RFC 8414 Section 2). + std::span token_endpoint_auth_methods_supported; + /// The supported JWS algorithms for the `private_key_jwt` and + /// `client_secret_jwt` token endpoint authentication methods (RFC 8414 + /// Section 2). REQUIRED and must exclude `none` when either of those methods + /// is advertised. + std::span + token_endpoint_auth_signing_alg_values_supported; + /// The supported scopes (RFC 8414 Section 2). + std::span scopes_supported; + /// The pushed authorization request endpoint (RFC 9126 Section 5). New + /// members are kept at the end so an existing positional initializer keeps + /// mapping to the older fields. + std::string_view pushed_authorization_request_endpoint; + /// Whether authorization request data is accepted only through the pushed + /// authorization request endpoint, emitted only when true (RFC 9126 + /// Section 5). + bool require_pushed_authorization_requests{false}; +}; + +/// @ingroup oauth +/// Build an authorization server metadata document for the well-known endpoint +/// (RFC 8414 Section 2), returning no value when the document would be +/// unusable: the issuer is not a valid issuer identifier, the required response +/// types are empty, the required authorization endpoint or (unless only the +/// implicit grant is offered) token endpoint is missing, any advertised +/// endpoint or JWK Set location is not a valid https URL, the signing algorithm +/// list contains `none`, or a JWT token endpoint authentication method is +/// advertised without a non-empty signing algorithm list. Each present scalar +/// and each non-empty array is emitted, and a zero-element array is omitted +/// (RFC 8414 Section 3.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array response_types{{"code"}}; +/// sourcemeta::core::OAuthServerMetadataConfig config; +/// config.issuer = "https://server.example"; +/// config.response_types_supported = response_types; +/// const auto document{sourcemeta::core::oauth_make_server_metadata(config)}; +/// assert(document.has_value()); +/// assert(document.value().at("issuer").to_string() == +/// "https://server.example"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata_provider.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata_provider.h new file mode 100644 index 000000000..e8effd76d --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata_provider.h @@ -0,0 +1,235 @@ +#ifndef SOURCEMETA_CORE_OAUTH_METADATA_PROVIDER_H_ +#define SOURCEMETA_CORE_OAUTH_METADATA_PROVIDER_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::function +#include // std::shared_ptr +#include // std::mutex +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// A long-lived, cached resolver of authorization server metadata (RFC 8414). +/// It derives the well-known URL from an issuer, retrieves and validates the +/// document through an injected transport, and caches it with a freshness-aware +/// refresh. It is meant to be constructed once per issuer at startup, since a +/// per-request instance defeats the caching. Reads take a snapshot, so a +/// returned document is immune to a concurrent refresh. The kind must be an +/// authorization server kind, either `AuthorizationServer` or an OpenID Connect +/// configuration form, since a protected resource document is not an +/// authorization server metadata document and would never validate. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthMetadataProvider provider{ +/// "https://example.com", +/// sourcemeta::core::OAuthWellKnownKind::AuthorizationServer, +/// [](std::string_view) -> std::optional< +/// sourcemeta::core::OAuthMetadataProvider::FetchResult> { +/// return sourcemeta::core::OAuthMetadataProvider::FetchResult{ +/// .body = +/// R"JSON({"issuer":"https://example.com", +/// "response_types_supported":["code"]})JSON", +/// .max_age = std::nullopt}; +/// }}; +/// const auto metadata{provider.metadata()}; +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthMetadataProvider { +public: + /// The outcome of one metadata retrieval. The freshness hint is kept separate + /// from the body because only the caller that made the request can observe + /// the caching response header. An absent hint means no lifetime was + /// advertised, which is distinct from an advertised lifetime of zero. + struct FetchResult { + /// The raw metadata bytes. + std::string body; + /// The advertised freshness lifetime, absent when none was given. + std::optional max_age; + }; + + /// A pluggable transport that turns a URL into raw metadata bytes plus an + /// optional freshness hint. Returns no value on a failed retrieval, such as a + /// transport error, an unsuccessful response, or an oversized body. Injecting + /// the transport keeps this module free of any networking dependency and + /// makes the provider substitutable for testing. A refresh runs the transport + /// while holding the cache lock, which serializes concurrent refreshes and + /// blocks other readers for its duration, though a forced refresh still + /// retrieves once per call, so the transport must bound its own wait with a + /// timeout. + using Fetcher = + std::function(std::string_view url)>; + + /// A source of the current time, defaulting to the system clock and existing + /// as an injection point so the refresh can be driven deterministically under + /// test. + using Clock = std::function; + + /// Tunables for the caching policy. The `minimum_ttl` must not exceed the + /// `maximum_ttl`, since an advertised lifetime is clamped low first then + /// high. + struct Options { + /// The lifetime to assume when the transport advertises none. + std::chrono::seconds fallback_ttl{std::chrono::hours{1}}; + /// The shortest lifetime honored, clamping smaller advertised values up. + std::chrono::seconds minimum_ttl{std::chrono::minutes{5}}; + /// The longest lifetime honored, clamping larger advertised values down. + std::chrono::seconds maximum_ttl{std::chrono::hours{24}}; + }; + + /// Construct a provider for an issuer with an injected transport, using the + /// default policy and the system clock. + OAuthMetadataProvider(std::string issuer, const OAuthWellKnownKind kind, + Fetcher fetcher); + /// Construct a provider overriding the caching policy. + OAuthMetadataProvider(std::string issuer, const OAuthWellKnownKind kind, + Fetcher fetcher, Options options); + /// Construct a provider overriding the policy and the clock. + OAuthMetadataProvider(std::string issuer, const OAuthWellKnownKind kind, + Fetcher fetcher, Options options, Clock clock); + + /// The provider owns a lock guarding its cache, so it is neither copyable nor + /// movable. Store it behind a pointer or construct it in place. + OAuthMetadataProvider(const OAuthMetadataProvider &) = delete; + auto operator=(const OAuthMetadataProvider &) + -> OAuthMetadataProvider & = delete; + OAuthMetadataProvider(OAuthMetadataProvider &&) = delete; + auto operator=(OAuthMetadataProvider &&) -> OAuthMetadataProvider & = delete; + + /// The cached metadata, retrieving it on the first call and refreshing it + /// once its freshness lifetime has elapsed. A failed refresh keeps serving + /// the last good document, and no value is returned only when the document + /// has never been retrieved successfully. The result is a snapshot immune to + /// a concurrent refresh. + [[nodiscard]] auto metadata() -> std::shared_ptr; + + /// Force an immediate retrieval regardless of freshness, serving the RFC 9728 + /// Section 5.2 recommendation to re-retrieve on a challenge. A failed + /// retrieval keeps the last good document. + [[nodiscard]] auto refresh() -> std::shared_ptr; + +private: + auto install_locked(const OAuthWellKnownKind kind, + const std::chrono::system_clock::time_point now) -> bool; + auto fetch_and_install_locked(const std::chrono::system_clock::time_point now) + -> bool; + + const std::string issuer_; + const OAuthWellKnownKind kind_; + const Fetcher fetcher_; + const Options options_; + const Clock clock_; + std::mutex mutex_; + std::shared_ptr metadata_; + std::chrono::system_clock::time_point next_refresh_{}; +}; + +/// @ingroup oauth +/// A long-lived, cached resolver of protected resource metadata (RFC 9728), +/// the resource-side counterpart of the authorization server provider. It +/// derives the well-known URL from a resource identifier, retrieves and +/// validates the document through an injected transport, and caches it with a +/// freshness-aware refresh. It is meant to be constructed once per resource at +/// startup, since a per-request instance defeats the caching. Reads take a +/// snapshot, so a returned document is immune to a concurrent refresh. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthResourceMetadataProvider provider{ +/// "https://api.example", +/// [](std::string_view) -> std::optional< +/// sourcemeta::core::OAuthResourceMetadataProvider::FetchResult> { +/// return sourcemeta::core::OAuthResourceMetadataProvider::FetchResult{ +/// .body = R"JSON({"resource":"https://api.example", +/// "authorization_servers":["https://auth.example"]})JSON", +/// .max_age = std::nullopt}; +/// }}; +/// const auto metadata{provider.metadata()}; +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthResourceMetadataProvider { +public: + /// The outcome of one metadata retrieval, shared with the authorization + /// server provider. + using FetchResult = OAuthMetadataProvider::FetchResult; + + /// A pluggable transport that turns a URL into raw metadata bytes plus an + /// optional freshness hint, shared with the authorization server provider. + using Fetcher = OAuthMetadataProvider::Fetcher; + + /// A source of the current time, shared with the authorization server + /// provider. + using Clock = OAuthMetadataProvider::Clock; + + /// Tunables for the caching policy, shared with the authorization server + /// provider. + using Options = OAuthMetadataProvider::Options; + + /// Construct a provider for a resource with an injected transport, using the + /// default policy and the system clock. + OAuthResourceMetadataProvider(std::string resource, Fetcher fetcher); + /// Construct a provider overriding the caching policy. + OAuthResourceMetadataProvider(std::string resource, Fetcher fetcher, + Options options); + /// Construct a provider overriding the policy and the clock. + OAuthResourceMetadataProvider(std::string resource, Fetcher fetcher, + Options options, Clock clock); + + /// The provider owns a lock guarding its cache, so it is neither copyable nor + /// movable. Store it behind a pointer or construct it in place. + OAuthResourceMetadataProvider(const OAuthResourceMetadataProvider &) = delete; + auto operator=(const OAuthResourceMetadataProvider &) + -> OAuthResourceMetadataProvider & = delete; + OAuthResourceMetadataProvider(OAuthResourceMetadataProvider &&) = delete; + auto operator=(OAuthResourceMetadataProvider &&) + -> OAuthResourceMetadataProvider & = delete; + + /// The cached metadata, retrieving it on the first call and refreshing it + /// once its freshness lifetime has elapsed. A failed refresh keeps serving + /// the last good document, and no value is returned only when the document + /// has never been retrieved successfully. The result is a snapshot immune to + /// a concurrent refresh. + [[nodiscard]] auto metadata() -> std::shared_ptr; + + /// Force an immediate retrieval regardless of freshness, serving the RFC 9728 + /// Section 5.2 recommendation to re-retrieve on a challenge. A failed + /// retrieval keeps the last good document. + [[nodiscard]] auto refresh() -> std::shared_ptr; + +private: + auto fetch_and_install_locked(const std::chrono::system_clock::time_point now) + -> bool; + + const std::string resource_; + const Fetcher fetcher_; + const Options options_; + const Clock clock_; + std::mutex mutex_; + std::shared_ptr metadata_; + std::chrono::system_clock::time_point next_refresh_{}; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_par.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_par.h new file mode 100644 index 000000000..1e2f9b11d --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_par.h @@ -0,0 +1,195 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PAR_H_ +#define SOURCEMETA_CORE_OAUTH_PAR_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include + +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::function +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append a pushed authorization request body (RFC 9126 Section 2.1) to the +/// sink. Every authorization request parameter is emitted except the +/// `request_uri`, which a pushed request MUST NOT carry, and the `client_id`, +/// which client authentication supplies, in the body for a public client or the +/// `client_secret_post` method and in the `Authorization` header for the +/// `client_secret_basic` method. The `response_type` defaults to the +/// authorization code flow. The body may carry a client secret, so the sink is +/// a wiping string, appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// request.redirect_uri = "https://client.example/cb"; +/// request.scope = "read"; +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_par_request(request, body); +/// sourcemeta::core::oauth_client_id_only("s6BhdRkqt3", body); +/// assert(std::string_view{body}.starts_with("response_type=code")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_par_request(const OAuthAuthorizationRequest &request, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append the front-channel authorization request URL (RFC 9126 Section 4) to +/// the sink, the reference form carrying only the `client_id` and the +/// `request_uri` obtained from the pushed authorization request endpoint. +/// Unlike a full authorization request it emits no `response_type`, since the +/// pushed request holds it. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string url; +/// sourcemeta::core::oauth_build_par_authorization_url( +/// "https://server.example/authorize", "s6BhdRkqt3", +/// "urn:ietf:params:oauth:request_uri:6esc", url); +/// assert(url == +/// "https://server.example/authorize?client_id=s6BhdRkqt3&request_uri=" +/// "urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A6esc"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_par_authorization_url(const std::string_view endpoint, + const std::string_view client_id, + const std::string_view request_uri, + std::string &sink) -> void; + +/// @ingroup oauth +/// A non-owning view over a pushed authorization request response (RFC 9126 +/// Section 2.2) held in a caller-owned JSON value, which must outlive the view. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json(R"JSON({ +/// "request_uri": "urn:ietf:params:oauth:request_uri:6esc", +/// "expires_in": 60 +/// })JSON")}; +/// const sourcemeta::core::OAuthPARResponse response{document}; +/// assert(response.request_uri().value() == +/// "urn:ietf:params:oauth:request_uri:6esc"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthPARResponse { +public: + /// Construct a view over a pushed authorization request response, which is + /// borrowed. + explicit OAuthPARResponse(const JSON &data); + + /// The request URI to use at the authorization endpoint (RFC 9126 + /// Section 2.2). + [[nodiscard]] auto request_uri() const -> std::optional; + /// The lifetime of the request URI, no value when non-positive (RFC 9126 + /// Section 2.2). + [[nodiscard]] auto expires_in() const -> std::optional; + + /// The underlying document. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; +}; + +/// @ingroup oauth +/// Parse a pushed authorization request body (RFC 9126 Section 2.1) into the +/// result, returning whether it is well formed. The parse follows the +/// authorization request rules, and additionally rejects a `request_uri` +/// parameter, which a pushed request MUST NOT provide, and every other +/// parameter, such as the client authentication ones, is passed to the +/// callback. The body may carry a client secret, so it is decoded into a wiping +/// arena the caller owns and reuses. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// assert(sourcemeta::core::oauth_parse_par_request( +/// "response_type=code&client_id=s6BhdRkqt3", storage, request, +/// [](std::string_view, std::string_view) {})); +/// assert(request.response_type == "code"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_parse_par_request( + const std::string_view body, SecureString &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// Mint a request URI reference for a pushed authorization request response +/// (RFC 9126 Section 2.2), the `urn:ietf:params:oauth:request_uri` form with a +/// cryptographically strong random reference so a value cannot be guessed +/// (RFC 9126 Section 7.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto request_uri{sourcemeta::core::oauth_par_request_uri()}; +/// assert(request_uri.starts_with("urn:ietf:params:oauth:request_uri:")); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_par_request_uri() + -> std::string; + +/// @ingroup oauth +/// Build a pushed authorization request response document (RFC 9126 +/// Section 2.2), returning no value when the request URI is empty or the +/// lifetime is non-positive, which is a REQUIRED positive value. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto response{sourcemeta::core::oauth_make_par_response( +/// "urn:ietf:params:oauth:request_uri:6esc", std::chrono::seconds{60})}; +/// assert(response.value().at("expires_in").to_integer() == 60); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_par_response(const std::string_view request_uri, + const std::chrono::seconds expires_in) + -> std::optional; + +/// @ingroup oauth +/// Reconcile the two ways a DPoP key is communicated at the pushed +/// authorization request endpoint (RFC 9449 Section 10.1): the `dpop_jkt` +/// parameter and the thumbprint of a verified DPoP header proof. Returns the +/// effective thumbprint to bind the authorization code to, an empty view when +/// neither is present, or no value when both are present and disagree, which +/// the server MUST reject. The returned view borrows from the arguments. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_par_dpop_binding("abc", "abc").value() == +/// "abc"); +/// assert(!sourcemeta::core::oauth_par_dpop_binding("abc", "xyz").has_value()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_par_dpop_binding(const std::string_view dpop_jkt, + const std::optional proof_thumbprint) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_pkce.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_pkce.h new file mode 100644 index 000000000..91081f30f --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_pkce.h @@ -0,0 +1,155 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PKCE_H_ +#define SOURCEMETA_CORE_OAUTH_PKCE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::array +#include // std::uint8_t +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The PKCE code challenge transformation method (RFC 7636 Section 4.2). +enum class OAuthPKCEMethod : std::uint8_t { + /// The SHA-256 transformation, the only method the strict profile permits + /// (RFC 7636 Section 4.2). + S256, + /// The identity transformation, permitted only under the compatible profile + /// (RFC 7636 Section 4.2). + Plain +}; + +/// @ingroup oauth +/// The result of verifying a PKCE code verifier against a stored code +/// challenge. Only `Match` and `NotUsed` let the exchange proceed. The +/// remaining outcomes each name a distinct pairing failure the token endpoint +/// rejects (RFC 7636 Section 4.6, RFC 9700 Section 2.1.1, OAuth 2.1 +/// Section 4.1.3). +enum class OAuthPKCEOutcome : std::uint8_t { + /// The verifier corresponds to the challenge. + Match, + /// Neither a challenge nor a verifier is present, so PKCE was not used. The + /// caller decides separately whether its profile required it. + NotUsed, + /// A challenge is stored but no verifier was presented (OAuth 2.1 + /// Section 4.1.3). + MissingVerifier, + /// A verifier was presented but no challenge is stored, which signals a + /// possible authorization code injection (RFC 9700 Section 2.1.1). + MissingChallenge, + /// Both are present, well formed, and permitted, but the verifier does not + /// correspond to the challenge (RFC 7636 Section 4.6). + Mismatch, + /// The stored method is `plain` under the strict profile (RFC 9700 + /// Section 2.1.1). + MethodNotAllowed, + /// The presented verifier is not a valid code verifier (RFC 7636 + /// Section 4.1). + MalformedVerifier, + /// The stored challenge violates the code challenge syntax, either the wrong + /// length for its method or a character outside the allowed set (RFC 7636 + /// Section 4.2). A syntactically valid challenge that simply does not + /// correspond to the verifier is a `Mismatch`, not this. + MalformedChallenge +}; + +/// @ingroup oauth +/// The wire value for a PKCE challenge method (RFC 7636 Section 4.3). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_pkce_method_code( +/// sourcemeta::core::OAuthPKCEMethod::S256) == "S256"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_method_code(const OAuthPKCEMethod method) noexcept + -> std::string_view; + +/// @ingroup oauth +/// Map a PKCE challenge method wire value to its type, returning no value for +/// an unrecognized method (RFC 7636 Section 4.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_pkce_method("S256").has_value()); +/// assert(!sourcemeta::core::to_oauth_pkce_method("plaintext").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_pkce_method(const std::string_view value) noexcept + -> std::optional; + +/// @ingroup oauth +/// Mint a new PKCE code verifier, the base64url encoding of 32 +/// cryptographically random octets (RFC 7636 Section 4.1). The 43 bytes are not +/// null terminated, so pass them onward as a view of `data()` and `size()` +/// rather than as a C string. The result is secret material, so wipe it once +/// the exchange completes. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto verifier{sourcemeta::core::oauth_pkce_verifier()}; +/// const std::string_view view{verifier.data(), verifier.size()}; +/// assert(view.size() == 43); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_verifier() -> std::array; + +/// @ingroup oauth +/// Derive the S256 code challenge for a code verifier, the base64url encoding +/// of its SHA-256 digest (RFC 7636 Section 4.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto challenge{sourcemeta::core::oauth_pkce_challenge( +/// "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")}; +/// assert((std::string_view{challenge.data(), challenge.size()} == +/// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_challenge(const std::string_view verifier) + -> std::array; + +/// @ingroup oauth +/// Verify a presented code verifier against a stored code challenge and its +/// method, under the given profile, returning the pairing outcome. An empty +/// verifier or challenge means the value is absent. The final verifier against +/// challenge comparison is constant time over their bytes once their lengths +/// match. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto outcome{sourcemeta::core::oauth_pkce_verify( +/// "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", +/// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", +/// sourcemeta::core::OAuthPKCEMethod::S256, +/// sourcemeta::core::OAuthProfile::Strict)}; +/// assert(outcome == sourcemeta::core::OAuthPKCEOutcome::Match); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_verify(const std::string_view verifier, + const std::string_view challenge, + const OAuthPKCEMethod method, const OAuthProfile profile) + -> OAuthPKCEOutcome; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_profile.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_profile.h new file mode 100644 index 000000000..8538920bd --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_profile.h @@ -0,0 +1,35 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PROFILE_H_ +#define SOURCEMETA_CORE_OAUTH_PROFILE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include // std::uint8_t + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The behavioural profile a parser or validator runs under. `Strict` applies +/// the OAuth 2.1 and RFC 9700 hardening, and `Compatible` relaxes it for +/// RFC 6749 interoperability where a specification still permits the older +/// behaviour. For example: +/// +/// ```cpp +/// #include +/// +/// const auto profile{sourcemeta::core::OAuthProfile::Strict}; +/// ``` +enum class OAuthProfile : std::uint8_t { + /// The default, selecting the OAuth 2.1 and RFC 9700 hardening. Each function + /// that takes a profile applies the part of that hardening it governs. Today + /// that is the PKCE method check, where the strict profile rejects `plain`. + Strict, + /// Selects the RFC 6749 behaviours the strict profile refuses, for + /// interoperability with deployments that predate the hardening. + Compatible +}; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_random.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_random.h new file mode 100644 index 000000000..c09430dba --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_random.h @@ -0,0 +1,33 @@ +#ifndef SOURCEMETA_CORE_OAUTH_RANDOM_H_ +#define SOURCEMETA_CORE_OAUTH_RANDOM_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include // std::array + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Mint a random token, the base64url encoding of 32 cryptographically random +/// octets, for an unguessable but non-confidential value such as a `state` or +/// `nonce` (RFC 6749 Section 10.10). The 43 bytes are not null terminated, so +/// pass them onward as a view of `data()` and `size()` rather than as a C +/// string. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto token{sourcemeta::core::oauth_random_token()}; +/// const std::string_view view{token.data(), token.size()}; +/// assert(view.size() == 43); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_random_token() -> std::array; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_registration.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_registration.h new file mode 100644 index 000000000..7ba075eed --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_registration.h @@ -0,0 +1,400 @@ +#ifndef SOURCEMETA_CORE_OAUTH_REGISTRATION_H_ +#define SOURCEMETA_CORE_OAUTH_REGISTRATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#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 oauth +/// A dynamic client registration metadata document (RFC 7591 Section 2), owning +/// its JSON. The same document is exchanged in both roles: a client's +/// registration request (RFC 7591 Section 3.1) and the server's registration +/// response (RFC 7591 Section 3.2.1), so the response-only members carry a +/// value only when read over a response. The document is validated on +/// construction to reject a `jwks` and `jwks_uri` present together, and a +/// `grant_types`, `response_types`, or `token_endpoint_auth_method` present +/// with the wrong type, since each of those carries a default an accessor would +/// otherwise substitute (RFC 7591 Section 2). Accessors apply the specification +/// defaults, and any member +/// without a typed accessor, such as an internationalized name (RFC 7591 +/// Section 2.2), is reachable through the underlying document. A string +/// accessor returns a view into the owned document, valid for the lifetime of +/// this object, so a view taken before the object is moved from must not be +/// used afterward. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json(R"JSON({ +/// "client_id": "s6BhdRkqt3", +/// "redirect_uris": [ "https://client.example.org/callback" ], +/// "token_endpoint_auth_method": "client_secret_basic" +/// })JSON")}; +/// const auto metadata{ +/// sourcemeta::core::OAuthClientMetadata::from(std::move(document))}; +/// assert(metadata.has_value()); +/// assert(metadata.value().client_id().value() == "s6BhdRkqt3"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthClientMetadata { +public: + /// Construct and validate a client registration document, throwing when it is + /// invalid. The document is moved in. + explicit OAuthClientMetadata(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 (RFC 7591 Section 2). + [[nodiscard]] auto has_redirect_uri(const std::string_view value) const + -> bool; + + /// The requested token endpoint authentication method, defaulting to + /// `client_secret_basic` when absent (RFC 7591 Section 2). + [[nodiscard]] auto token_endpoint_auth_method() const -> std::string_view; + + /// Whether a grant type is registered, defaulting to the authorization code + /// grant when absent (RFC 7591 Section 2). + [[nodiscard]] auto supports_grant_type(const std::string_view value) const + -> bool; + + /// Whether a response type is registered, defaulting to the code response + /// type when absent (RFC 7591 Section 2). + [[nodiscard]] auto supports_response_type(const std::string_view value) const + -> bool; + + /// The human-readable client name (RFC 7591 Section 2). + [[nodiscard]] auto client_name() const -> std::optional; + + /// The client information page (RFC 7591 Section 2). + [[nodiscard]] auto client_uri() const -> std::optional; + + /// The client logo location (RFC 7591 Section 2). + [[nodiscard]] auto logo_uri() const -> std::optional; + + /// The space-separated scopes the client may request (RFC 7591 Section 2). + [[nodiscard]] auto scope() const -> std::optional; + + /// Whether a contact is listed (RFC 7591 Section 2). + [[nodiscard]] auto has_contact(const std::string_view value) const -> bool; + + /// The terms of service page (RFC 7591 Section 2). + [[nodiscard]] auto tos_uri() const -> std::optional; + + /// The privacy policy page (RFC 7591 Section 2). + [[nodiscard]] auto policy_uri() const -> std::optional; + + /// The JWK Set document location for the client's keys (RFC 7591 Section 2). + [[nodiscard]] auto jwks_uri() const -> std::optional; + + /// The client software identifier, stable across instances (RFC 7591 + /// Section 2). + [[nodiscard]] auto software_id() const -> std::optional; + + /// The client software version (RFC 7591 Section 2). + [[nodiscard]] auto software_version() const + -> std::optional; + + /// The software statement asserting the metadata, echoed unmodified in a + /// response (RFC 7591 Section 2.3). + [[nodiscard]] auto software_statement() const + -> std::optional; + + /// The issued client identifier, present only in a registration response + /// (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_id() const -> std::optional; + + /// The issued client secret, present only in a response to a confidential + /// client (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_secret() const -> std::optional; + + /// The time the client identifier was issued, present only in a response + /// (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_id_issued_at() const + -> std::optional; + + /// The time the client secret expires, zero meaning it never expires, present + /// only in a response that issued a secret (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_secret_expires_at() const + -> std::optional; + + /// The registration access token for managing the registration, present only + /// in a response when management is offered (RFC 7592 Section 3). + [[nodiscard]] auto registration_access_token() const + -> std::optional; + + /// The registration management location, present only in a response when + /// management is offered (RFC 7592 Section 3). + [[nodiscard]] auto registration_client_uri() const + -> std::optional; + + /// The underlying document, for reaching members without a typed accessor. + [[nodiscard]] auto data() const -> const JSON &; + +private: + JSON data_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// The client metadata a registration request carries (RFC 7591 Section 2), +/// each field a non-owning view. An empty scalar and a zero-element array are +/// omitted. +struct OAuthClientRegistrationConfig { + /// The redirection URIs the client registers for redirect-based flows + /// (RFC 7591 Section 2). + std::span redirect_uris; + /// The requested token endpoint authentication method, omitted to take the + /// `client_secret_basic` default (RFC 7591 Section 2). + std::string_view token_endpoint_auth_method; + /// The grant types the client will use (RFC 7591 Section 2). + std::span grant_types; + /// The response types the client will use (RFC 7591 Section 2). + std::span response_types; + /// The human-readable client name (RFC 7591 Section 2). + std::string_view client_name; + /// The client information page (RFC 7591 Section 2). + std::string_view client_uri; + /// The client logo location (RFC 7591 Section 2). + std::string_view logo_uri; + /// The space-separated scopes the client may request (RFC 7591 Section 2). + std::string_view scope; + /// The ways to contact those responsible for the client (RFC 7591 Section 2). + std::span contacts; + /// The terms of service page (RFC 7591 Section 2). + std::string_view tos_uri; + /// The privacy policy page (RFC 7591 Section 2). + std::string_view policy_uri; + /// The JWK Set document location for the client's keys, mutually exclusive + /// with an inline key set (RFC 7591 Section 2). + std::string_view jwks_uri; + /// The client's JWK Set carried inline for a client that cannot host one, + /// mutually exclusive with its location, a non-owning pointer left null when + /// absent (RFC 7591 Section 2). + const JSON *jwks{nullptr}; + /// The client software identifier (RFC 7591 Section 2). + std::string_view software_id; + /// The client software version (RFC 7591 Section 2). + std::string_view software_version; + /// The software statement asserting the metadata (RFC 7591 Section 2.3). + std::string_view software_statement; +}; + +/// @ingroup oauth +/// Build a dynamic client registration request body (RFC 7591 Section 3.1), +/// returning no value when a present `client_uri`, `logo_uri`, `tos_uri`, +/// `policy_uri`, `jwks_uri`, or redirection URI is not a valid URI, or an +/// inline key set is not a JSON object or is given together with its location. +/// Each present scalar and each non-empty array is emitted. The caller +/// serializes the object as the `application/json` request body. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array redirect_uris{ +/// {"https://client.example.org/callback"}}; +/// sourcemeta::core::OAuthClientRegistrationConfig config; +/// config.redirect_uris = redirect_uris; +/// config.client_name = "My Example Client"; +/// const auto body{ +/// sourcemeta::core::oauth_make_registration_request(config)}; +/// assert(body.has_value()); +/// assert(body.value().at("client_name").to_string() == "My Example Client"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_request(const OAuthClientRegistrationConfig &config) + -> std::optional; + +/// @ingroup oauth +/// Build a dynamic client registration error response body (RFC 7591 +/// Section 3.2.2). The error is the wire error code, and the description is +/// emitted only when present. The caller serializes the object as the +/// `application/json` response body. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto body{sourcemeta::core::oauth_make_registration_error_response( +/// sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthRegistrationError::InvalidClientMetadata), +/// "The grant types are inconsistent with the response types")}; +/// assert(body.at("error").to_string() == "invalid_client_metadata"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_error_response(const std::string_view error, + const std::string_view error_description) + -> JSON; + +/// @ingroup oauth +/// Whether the registered grant types and response types are mutually +/// consistent (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 the explicitly registered lists are compared, so a document that +/// omits either list registers no value that could contradict the other, and +/// the server surfaces an inconsistency as `invalid_client_metadata` (RFC 7591 +/// Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json(R"JSON({ +/// "grant_types": [ "authorization_code" ], +/// "response_types": [ "code" ] +/// })JSON")}; +/// const auto metadata{ +/// sourcemeta::core::OAuthClientMetadata::from(std::move(document))}; +/// assert(metadata.has_value()); +/// assert(sourcemeta::core::oauth_registration_grant_response_consistent( +/// metadata.value())); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_registration_grant_response_consistent( + const OAuthClientMetadata &metadata) -> bool; + +/// @ingroup oauth +/// Flatten the claims of a trusted software statement onto a registration +/// record (RFC 7591 Section 3.1.1), returning false when the record or the +/// claims are not a JSON object. A statement claim takes precedence over a +/// directly supplied value of the same name, so it overwrites it, while the +/// JSON Web Token structural claims (RFC 7519 Section 4.1) and the statement +/// member itself are left out since they describe the statement rather than the +/// client. Pass the claims a trusted statement was verified to carry. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto record{sourcemeta::core::parse_json(R"JSON({ +/// "client_name": "Requested Name" +/// })JSON")}; +/// const auto claims{sourcemeta::core::parse_json(R"JSON({ +/// "iss": "https://statement-issuer.example", +/// "client_name": "Attested Name" +/// })JSON")}; +/// sourcemeta::core::oauth_apply_software_statement_claims(record, claims); +/// assert(record.at("client_name").to_string() == "Attested Name"); +/// assert(!record.defines("iss")); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_apply_software_statement_claims(JSON &metadata, const JSON &claims) + -> bool; + +/// @ingroup oauth +/// The values an authorization server assigns to a client at registration +/// (RFC 7591 Section 3.2.1) and for its management (RFC 7592 Section 3), each a +/// non-owning view. The client identifier is REQUIRED, a secret and its +/// expiry are emitted together, and the management pair is emitted when +/// registration management is offered. +struct OAuthClientRegistrationResult { + /// The issued client identifier (RFC 7591 Section 3.2.1), REQUIRED. + std::string_view client_id; + /// The issued client secret, present for a confidential client (RFC 7591 + /// Section 3.2.1). + std::string_view client_secret; + /// The time the client identifier was issued (RFC 7591 Section 3.2.1). + std::optional client_id_issued_at{}; + /// The time the client secret expires, zero meaning it never expires, + /// REQUIRED alongside a secret (RFC 7591 Section 3.2.1). + std::optional client_secret_expires_at{}; + /// The registration access token for managing the registration, REQUIRED + /// together with the management location when management is offered (RFC 7592 + /// Section 3). + std::string_view registration_access_token; + /// The registration management location, REQUIRED together with the access + /// token when management is offered (RFC 7592 Section 3). + std::string_view registration_client_uri; +}; + +/// @ingroup oauth +/// Build a dynamic client registration response body (RFC 7591 Section 3.2.1 +/// and RFC 7592 Section 3) by returning the registered metadata with the +/// server-assigned values overlaid, or no value when the client identifier is +/// empty, only one of the secret and its expiry is given, a time is negative, +/// only one of the management access token and location is given, or the +/// management location is not a valid URI. The server-assigned members come +/// only from the assigned values, never from the accepted record. The caller +/// serializes the object as the `application/json` response body. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto metadata{sourcemeta::core::parse_json(R"JSON({ +/// "redirect_uris": [ "https://client.example.org/callback" ] +/// })JSON")}; +/// sourcemeta::core::OAuthClientRegistrationResult result; +/// result.client_id = "s6BhdRkqt3"; +/// const auto body{ +/// sourcemeta::core::oauth_make_registration_response(metadata, result)}; +/// assert(body.has_value()); +/// assert(body.value().at("client_id").to_string() == "s6BhdRkqt3"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_response(const JSON &metadata, + const OAuthClientRegistrationResult &result) + -> std::optional; + +/// @ingroup oauth +/// Build a dynamic client registration update request body (RFC 7592 +/// Section 2.2), a full replacement of the client's metadata that MUST carry +/// the current client identifier and MAY carry the current secret to match +/// against, returning no value when the client identifier is empty or a URL +/// field is not a valid URI. Every metadata field is emitted as in a +/// registration request, and the four server-assigned members are excluded +/// since the request MUST NOT carry them. An omitted field is a request to +/// delete it, since the update replaces rather than augments. The caller +/// serializes the object as the `application/json` request body. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array redirect_uris{ +/// {"https://client.example.org/callback"}}; +/// sourcemeta::core::OAuthClientRegistrationConfig config; +/// config.redirect_uris = redirect_uris; +/// config.client_name = "My New Example"; +/// const auto body{sourcemeta::core::oauth_make_registration_update_request( +/// config, "s6BhdRkqt3", "")}; +/// assert(body.has_value()); +/// assert(body.value().at("client_id").to_string() == "s6BhdRkqt3"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_update_request( + const OAuthClientRegistrationConfig &config, + const std::string_view client_id, const std::string_view client_secret) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_revocation.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_revocation.h new file mode 100644 index 000000000..48d3f0c80 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_revocation.h @@ -0,0 +1,120 @@ +#ifndef SOURCEMETA_CORE_OAUTH_REVOCATION_H_ +#define SOURCEMETA_CORE_OAUTH_REVOCATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include // std::uint8_t +#include // std::function +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The `access_token` token type hint (RFC 7009 Section 2.1), reused by token +/// introspection (RFC 7662 Section 2.1). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_HINT_ACCESS_TOKEN{ + "access_token"}; +/// @ingroup oauth +/// The `refresh_token` token type hint (RFC 7009 Section 2.1), reused by token +/// introspection (RFC 7662 Section 2.1). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_HINT_REFRESH_TOKEN{ + "refresh_token"}; + +/// @ingroup oauth +/// A non-owning view of a token revocation or introspection request (RFC 7009 +/// Section 2.1, RFC 7662 Section 2.1). The token borrows from the input or the +/// decode arena, and an absent hint is an empty view. +struct OAuthTokenLookupRequest { + /// The token to act on (RFC 7009 Section 2.1). + std::string_view token; + /// The hint about the token type, one of the hint vocabulary values, empty + /// when absent (RFC 7009 Section 2.1). + std::string_view token_type_hint; +}; + +/// @ingroup oauth +/// Append a token revocation request body (RFC 7009 Section 2.1) to the sink. +/// The `token` is required, and the `token_type_hint` is emitted only when +/// present. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink. The token is secret, so the sink +/// is a wiping string, and it is appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_revocation_request( +/// "45ghiukldjahdnhzdauz", "", body); +/// assert(body == "token=45ghiukldjahdnhzdauz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_revocation_request(const std::string_view token, + const std::string_view token_type_hint, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Parse a token revocation request body (RFC 7009 Section 2.1) into the +/// result, returning whether it is well formed. The token is required, a +/// duplicated parameter fails (RFC 6749 Section 3.2), and every other +/// parameter, such as the client authentication ones, is passed to the +/// callback. The token is secret, so it is decoded into a wiping arena the +/// caller owns and reuses, and which must not alias the body. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthTokenLookupRequest request; +/// assert(sourcemeta::core::oauth_parse_revocation_request( +/// "token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token", storage, +/// request, [](std::string_view, std::string_view) {})); +/// assert(request.token == "45ghiukldjahdnhzdauz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_revocation_request( + const std::string_view body, SecureString &storage, + OAuthTokenLookupRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// The outcome a client draws from a token revocation response (RFC 7009 +/// Section 2.2). +enum class OAuthRevocationOutcome : std::uint8_t { + /// The revocation succeeded, which an unknown token also produces (RFC 7009 + /// Section 2.2). + Success, + /// The server is temporarily unable to respond and the request may be + /// retried, honoring any `Retry-After` (RFC 7009 Section 2.2.1). + Retry, + /// The request failed, and the body carries a token endpoint error code + /// (RFC 7009 Section 2.2.1). + Error +}; + +/// @ingroup oauth +/// Map a revocation response status code to its outcome: a 200 is a success, +/// including for an unknown token (RFC 7009 Section 2.2), a 503 is retryable +/// (RFC 7009 Section 2.2.1), and any other status is an error. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_revocation_outcome(HTTP_STATUS_OK) == +/// sourcemeta::core::OAuthRevocationOutcome::Success); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_revocation_outcome(const HTTPStatus status) noexcept + -> OAuthRevocationOutcome; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token.h new file mode 100644 index 000000000..bf55a7f86 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token.h @@ -0,0 +1,266 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TOKEN_H_ +#define SOURCEMETA_CORE_OAUTH_TOKEN_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include + +#include // std::chrono::seconds +#include // std::function +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append an authorization code grant token request body (RFC 6749 +/// Section 4.1.3) to the sink. The `code` is required, the `redirect_uri` is +/// emitted only when the authorization request carried one, and the +/// `code_verifier` is emitted only when PKCE is in use (RFC 7636 Section 4.5). +/// No `client_id` is emitted, so the caller composes a client authentication +/// builder into the same sink. The body carries secrets, so the sink is a +/// wiping string, and it is appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_code( +/// "SplxlOBeZQQYbYS6WxSbIA", "https://client.example/cb", "", {}, body); +/// assert(body == "grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA" +/// "&redirect_uri=https%3A%2F%2Fclient.example%2Fcb"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_code( + const std::string_view code, const std::string_view redirect_uri, + const std::string_view code_verifier, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// Append a refresh token grant token request body (RFC 6749 Section 6) to the +/// sink. The `refresh_token` is required, and the requested `scope` is emitted +/// only when present and must not exceed the original grant. No `client_id` is +/// emitted, so the caller composes a client authentication builder into the +/// same sink. The body carries secrets, so the sink is a wiping string, and it +/// is appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_refresh("tGzv3JOkF0XG5Qx2TlKWIA", +/// "read", {}, body); +/// assert(body == "grant_type=refresh_token" +/// "&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA&scope=read"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_refresh( + const std::string_view refresh_token, const std::string_view scope, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// Append a client credentials grant token request body (RFC 6749 +/// Section 4.4.2) to the sink. The requested `scope` is emitted only when +/// present. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink. The sink is appended to and never +/// cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_client_credentials("read", {}, +/// body); +/// assert(body == "grant_type=client_credentials&scope=read"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_client_credentials( + const std::string_view scope, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// A non-owning view of a token request at the authorization server (RFC 6749 +/// Section 4.1.3, Section 4.4.2, Section 6). Every field borrows from the input +/// or the decode arena, and an absent parameter is an empty view. The grant +/// type is surfaced as its raw value so a server can tell a missing grant from +/// an unsupported one. +struct OAuthTokenRequest { + /// The grant type (RFC 6749 Section 4.1.3). + std::string_view grant_type; + /// The authorization code, for the authorization code grant (RFC 6749 + /// Section 4.1.3). + std::string_view code; + /// The redirection endpoint echoed for the authorization code grant (RFC 6749 + /// Section 4.1.3), surfaced unconditionally. + std::string_view redirect_uri; + /// The PKCE code verifier (RFC 7636 Section 4.5). + std::string_view code_verifier; + /// The refresh token, for the refresh grant (RFC 6749 Section 6). + std::string_view refresh_token; + /// The space-delimited requested scope (RFC 6749 Section 3.3). + std::string_view scope; +}; + +/// @ingroup oauth +/// Parse the body of a token request at the authorization server (RFC 6749 +/// Section 4.1.3) into the result, returning whether it is well formed. Each +/// recognized value is form-decoded into the storage arena and viewed there. +/// The body carries the request's secrets, the authorization code, the code +/// verifier, the refresh token, and the client authentication parameters, so +/// the arena is a wiping string, which the caller owns, should clear between +/// independent parses, and must outlive the result. A duplicated recognized +/// parameter is a failure (RFC 6749 Section 3.2). Every repeatable `resource` +/// and `audience` and every unrecognized parameter, including the client +/// authentication parameters, is passed to the callback with its decoded value +/// rather than stored on the result. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthTokenRequest request; +/// assert(sourcemeta::core::oauth_parse_token_request( +/// "grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA", storage, +/// request, [](std::string_view, std::string_view) {})); +/// assert(request.grant_type == "authorization_code"); +/// assert(request.code == "SplxlOBeZQQYbYS6WxSbIA"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_token_request( + const std::string_view body, SecureString &storage, + OAuthTokenRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// A non-owning view over a token endpoint success response (RFC 6749 +/// Section 5.1) held in a caller-owned JSON value. The value must outlive the +/// view, and the token accessors return views into it, which are secret and +/// must not be logged. Extension members such as an OpenID Connect `id_token` +/// are reached through the underlying document. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json( +/// R"JSON({"access_token":"2YotnFZFEjr1zCsicMWpAA","token_type":"Bearer", +/// "expires_in":3600})JSON")}; +/// const sourcemeta::core::OAuthTokenResponse response{document}; +/// assert(response.is_bearer_token_type()); +/// assert(response.access_token().value() == "2YotnFZFEjr1zCsicMWpAA"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthTokenResponse { +public: + /// Construct a view over a token response document, which is borrowed. + explicit OAuthTokenResponse(const JSON &data); + + /// The issued access token (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto access_token() const -> std::optional; + + /// The access token type (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto token_type() const -> std::optional; + + /// Whether the token type is `Bearer`, matched case insensitively (RFC 6749 + /// Section 5.1). + [[nodiscard]] auto is_bearer_token_type() const -> bool; + + /// The access token lifetime in seconds (RFC 6749 Section 5.1), or no value + /// when absent or not a non-negative integer. + [[nodiscard]] auto expires_in() const -> std::optional; + + /// The issued refresh token (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto refresh_token() const -> std::optional; + + /// The granted scope (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto scope() const -> std::optional; + + /// Whether the granted scope contains a value, comparing the space-delimited + /// unordered set (RFC 6749 Section 3.3). + [[nodiscard]] auto has_scope(const std::string_view value) const -> bool; + + /// The underlying document, for reaching extension members such as an OpenID + /// Connect `id_token`. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; +}; + +/// @ingroup oauth +/// The token a server grants, for building a token endpoint success response +/// (RFC 6749 Section 5.1). Every field borrows from the caller, an empty scalar +/// is omitted, and the two scope fields decide whether a scope is emitted. +struct OAuthTokenGrant { + /// The issued access token (RFC 6749 Section 5.1). + std::string_view access_token; + /// The token type, such as `Bearer` (RFC 6749 Section 7.1). + std::string_view token_type; + /// The lifetime of the access token, omitted when absent (RFC 6749 + /// Section 5.1). + std::optional expires_in; + /// The issued refresh token, omitted when absent (RFC 6749 Section 5.1). + std::string_view refresh_token; + /// The granted scope (RFC 6749 Section 3.3). A scope has at least one token, + /// so an empty granted scope is not representable and is not emitted. A + /// server that would grant no scopes denies the request instead of issuing a + /// token with an empty scope. + std::string_view scope; + /// The scope the client requested, used to decide whether the granted scope + /// must be emitted (RFC 6749 Section 5.1). + std::string_view requested_scope; +}; + +/// @ingroup oauth +/// Build a token endpoint success response document (RFC 6749 Section 5.1). The +/// access token and token type are always emitted, the lifetime and refresh +/// token when present, and the scope only when it differs from the requested +/// scope, which is when it is REQUIRED (RFC 6749 Section 5.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthTokenGrant grant; +/// grant.access_token = "2YotnFZFEjr1zCsicMWpAA"; +/// grant.token_type = "Bearer"; +/// const auto response{sourcemeta::core::oauth_make_token_response(grant)}; +/// assert(response.at("token_type").to_string() == "Bearer"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_token_response(const OAuthTokenGrant &grant) -> JSON; + +/// @ingroup oauth +/// Build a token endpoint error response document (RFC 6749 Section 5.2). The +/// error code is always emitted, and the description and URI when present. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto response{sourcemeta::core::oauth_make_token_error_response( +/// "invalid_grant", "", "")}; +/// assert(response.at("error").to_string() == "invalid_grant"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_token_error_response(const std::string_view error, + const std::string_view error_description, + const std::string_view error_uri) -> JSON; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token_exchange.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token_exchange.h new file mode 100644 index 000000000..fb90552e2 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token_exchange.h @@ -0,0 +1,140 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TOKEN_EXCHANGE_H_ +#define SOURCEMETA_CORE_OAUTH_TOKEN_EXCHANGE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The `access_token` token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_ACCESS_TOKEN{ + "urn:ietf:params:oauth:token-type:access_token"}; +/// @ingroup oauth +/// The `refresh_token` token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_REFRESH_TOKEN{ + "urn:ietf:params:oauth:token-type:refresh_token"}; +/// @ingroup oauth +/// The `id_token` token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_ID_TOKEN{ + "urn:ietf:params:oauth:token-type:id_token"}; +/// @ingroup oauth +/// The SAML 1.1 token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_SAML1{ + "urn:ietf:params:oauth:token-type:saml1"}; +/// @ingroup oauth +/// The SAML 2.0 token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_SAML2{ + "urn:ietf:params:oauth:token-type:saml2"}; +/// @ingroup oauth +/// The JWT token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_JWT{ + "urn:ietf:params:oauth:token-type:jwt"}; + +/// @ingroup oauth +/// The parameters of a token exchange request (RFC 8693 Section 2.1). Every +/// field is a non-owning view, an empty scalar is omitted, and the spans carry +/// the repeatable audience and resource indicator values, each emitted under +/// its fixed parameter name. +struct OAuthTokenExchangeRequest { + /// The security token that represents the subject, REQUIRED (RFC 8693 + /// Section 2.1). + std::string_view subject_token; + /// The type of the subject token, REQUIRED (RFC 8693 Section 2.1). + std::string_view subject_token_type; + /// The security token that represents the acting party, emitted only + /// together with its type (RFC 8693 Section 2.1). + std::string_view actor_token; + /// The type of the actor token, emitted only together with the actor token. + std::string_view actor_token_type; + /// The requested type for the issued token (RFC 8693 Section 2.1). + std::string_view requested_token_type; + /// The space-delimited requested scope (RFC 6749 Section 3.3). + std::string_view scope; + /// The repeatable logical audience names of the target service, each emitted + /// as an `audience` parameter (RFC 8693 Section 2.1). + std::span audiences; + /// The repeatable resource indicators, each an absolute URI without a + /// fragment emitted as a `resource` parameter (RFC 8707 Section 2). + std::span resources; +}; + +/// @ingroup oauth +/// Whether a token exchange request is well formed (RFC 8693 Section 2.1): the +/// subject token and its type are present, and the actor token and its type are +/// either both present or both absent. A malformed request is rejected as +/// `invalid_request` (RFC 8693 Section 2.2.2), so a server applies this after +/// collecting the parameters. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthTokenExchangeRequest request; +/// request.subject_token = "eyJ..."; +/// request.subject_token_type = +/// sourcemeta::core::OAUTH_TOKEN_TYPE_ACCESS_TOKEN; +/// assert(sourcemeta::core::oauth_token_exchange_valid(request)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_token_exchange_valid(const OAuthTokenExchangeRequest &request) + -> bool; + +/// @ingroup oauth +/// Append a token exchange request body (RFC 8693 Section 2.1) to the sink, +/// returning whether it is well formed. The `grant_type` is the token exchange +/// URN, the subject token and its type are required, the actor token and its +/// type are emitted only together, and the repeatable audiences and resources +/// follow. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink. The body carries a secret token, +/// so the sink is a wiping string, and it is appended to and never cleared. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthTokenExchangeRequest request; +/// request.subject_token = "accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC"; +/// request.subject_token_type = +/// sourcemeta::core::OAUTH_TOKEN_TYPE_ACCESS_TOKEN; +/// sourcemeta::core::SecureString body; +/// assert(sourcemeta::core::oauth_build_token_request_exchange(request, body)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_exchange( + const OAuthTokenExchangeRequest &request, SecureString &sink) -> bool; + +/// @ingroup oauth +/// The type of the token a token exchange response issued (RFC 8693 +/// Section 2.2.1), or no value when absent. The result borrows from the +/// response, which must outlive it. It is REQUIRED on a successful response, so +/// its absence marks a malformed one. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json(R"JSON({ +/// "access_token": "eyJ...", "token_type": "Bearer", +/// "issued_token_type": "urn:ietf:params:oauth:token-type:access_token" +/// })JSON")}; +/// assert(sourcemeta::core::oauth_issued_token_type(document).value() == +/// sourcemeta::core::OAUTH_TOKEN_TYPE_ACCESS_TOKEN); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_issued_token_type(const JSON &response) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_transaction.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_transaction.h new file mode 100644 index 000000000..13dc7efb7 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_transaction.h @@ -0,0 +1,137 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TRANSACTION_H_ +#define SOURCEMETA_CORE_OAUTH_TRANSACTION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::array +#include // std::uint8_t +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The secrets minted for one authorization code flow, each the base64url +/// encoding of 32 random octets. Both are secret and are serialized by the +/// caller, such as +/// into a sealed cookie, and wiped once the flow completes. The 43 bytes of +/// each are not null terminated. +struct OAuthTransactionSecrets { + /// The cross-site request forgery token (RFC 6749 Section 10.12). + std::array state; + /// The PKCE code verifier (RFC 7636 Section 4.1). + std::array code_verifier; +}; + +/// @ingroup oauth +/// A non-owning view of the state a client retains between an authorization +/// request and its callback. Every field borrows from the caller and must +/// outlive any use of this struct. +struct OAuthTransaction { + /// The state sent with the request, compared against the callback. + std::string_view state; + /// The PKCE code verifier, carried through to the token request. + std::string_view code_verifier; + /// The issuer identifier the request was sent to (RFC 9207 Section 2). + std::string_view issuer; + /// The redirection URI the request was sent with (RFC 8252 Section 8.10). + std::string_view redirect_uri; +}; + +/// @ingroup oauth +/// Whether the authorization server is known to support the `iss` response +/// parameter (RFC 9207 Section 2.4). This governs how a callback missing `iss` +/// is treated. +enum class OAuthIssuerSupport : std::uint8_t { + /// The server supports `iss`, so a callback missing it is rejected. + Supported, + /// The server is known not to support `iss`, so a present `iss` is ignored. + NotSupported, + /// Support is unknown, so `iss` is validated only when present. + Unknown +}; + +/// @ingroup oauth +/// The reason a callback was rejected, in the order the checks run. The first +/// failing check decides the outcome (RFC 9700 Section 4). +enum class OAuthCallbackError : std::uint8_t { + /// The state is missing or does not match, a possible cross-site request + /// forgery (RFC 6749 Section 10.12). + State, + /// The callback arrived on a URI other than the one the request was sent with + /// (RFC 8252 Section 8.10). + ReceivedURI, + /// The `iss` is missing when required or does not match the expected issuer, + /// a possible mix-up attack (RFC 9207 Section 2.4). + Issuer, + /// The authorization server returned an error rather than a code (RFC 6749 + /// Section 4.1.2.1). + Declined, + /// The response carries neither an error nor a code. + MissingCode +}; + +/// @ingroup oauth +/// Mint the secrets for a new authorization code flow, a random `state` and a +/// PKCE code verifier (RFC 7636 Section 4.1, RFC 6749 Section 10.12). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto secrets{sourcemeta::core::oauth_transaction_mint()}; +/// assert(secrets.state.size() == 43); +/// assert(secrets.code_verifier.size() == 43); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_transaction_mint() -> OAuthTransactionSecrets; + +/// @ingroup oauth +/// Validate an authorization response against the retained transaction, +/// returning the authorization code through `code` on success or the first +/// failing check otherwise (RFC 9700 Section 4). The checks run in order: the +/// state in constant time, the received URI when one is supplied, the issuer, +/// whether the server declined, and finally the presence of a code. Pass an +/// empty `received_uri` to skip that check. The state check always defends +/// against cross-site request forgery. Guaranteed mix-up defense needs a +/// non-empty `received_uri` (RFC 8252 Section 8.10) or an `issuer_support` of +/// `Supported`, which makes a missing `iss` fatal (RFC 9207 Section 2.4). With +/// `Unknown` the `iss` is validated only when present, so a server that omits +/// it is not caught, and with `NotSupported` it is ignored entirely. A caller +/// talking to more than one authorization server must therefore supply one of +/// those two. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// sourcemeta::core::oauth_parse_authorization_response( +/// "code=SplxlOBeZQQYbYS6WxSbIA&state=xyz", storage, response); +/// const sourcemeta::core::OAuthTransaction transaction{ +/// .state = "xyz", .code_verifier = "", .issuer = "", .redirect_uri = ""}; +/// std::string_view code; +/// const auto error{sourcemeta::core::oauth_transaction_check( +/// transaction, response, sourcemeta::core::OAuthIssuerSupport::Unknown, +/// "", code)}; +/// assert(!error.has_value()); +/// assert(code == "SplxlOBeZQQYbYS6WxSbIA"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_transaction_check(const OAuthTransaction &transaction, + const OAuthAuthorizationResponse &response, + const OAuthIssuerSupport issuer_support, + const std::string_view received_uri, + std::string_view &code) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_assertion.cc b/vendor/core/src/core/oauth/oauth_assertion.cc new file mode 100644 index 000000000..c61d2b717 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_assertion.cc @@ -0,0 +1,273 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "oauth_encode.h" + +#include // std::ranges::find_if, std::clamp +#include // std::chrono::seconds, std::chrono::duration_cast +#include // std::int64_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +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)}; + +auto to_epoch_seconds(const std::chrono::system_clock::time_point point) + -> std::int64_t { + return static_cast( + std::chrono::duration_cast(point.time_since_epoch()) + .count()); +} + +auto map_verification_error(const JWTVerificationError error) + -> OAuthAssertionError { + switch (error) { + case JWTVerificationError::AlgorithmNotAllowed: + return OAuthAssertionError::UnsupportedAlgorithm; + case JWTVerificationError::UnknownKey: + return OAuthAssertionError::UnknownKey; + case JWTVerificationError::Signature: + return OAuthAssertionError::Signature; + case JWTVerificationError::Type: + return OAuthAssertionError::Malformed; + case JWTVerificationError::Issuer: + return OAuthAssertionError::Issuer; + case JWTVerificationError::Subject: + return OAuthAssertionError::Subject; + case JWTVerificationError::Audience: + return OAuthAssertionError::Audience; + case JWTVerificationError::Expiration: + return OAuthAssertionError::Expired; + case JWTVerificationError::NotBefore: + return OAuthAssertionError::NotYetValid; + case JWTVerificationError::IssuedAt: + return OAuthAssertionError::IssuedInFuture; + } + + std::unreachable(); +} + +// RFC 7519 Section 4.1.3: the audience is a string or an array of strings, so +// an array carrying a non-string element is a malformed claim the assertion is +// rejected for (RFC 7523 Section 3 check 10) rather than accepted because +// another element happens to match +auto audience_is_well_formed(const JWT &token) -> bool { + if (!token.payload().is_object()) { + return false; + } + + const auto *audience{token.payload().try_at("aud"sv, HASH_AUD)}; + if (audience == nullptr || audience->is_string()) { + return true; + } + + if (!audience->is_array()) { + return false; + } + + for (const auto &element : audience->as_array()) { + if (!element.is_string()) { + return false; + } + } + + return true; +} + +// The shared verification of a parsed JWT bearer assertion (RFC 7523 +// Section 3): find the audience the assertion targets, delegate the signature +// and time checks to the JSON Web Token verifier, and reject a replayed +// identifier +auto verify_assertion( + const JWT &token, const std::string_view expected_issuer, + const std::optional expected_subject, + const std::span expected_audiences, + const JWKS &keys, const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional { + if (!audience_is_well_formed(token)) { + return OAuthAssertionError::Audience; + } + + // RFC 7523 Section 3 check 3 and RFC 3986 Section 6.2.1: the audience is + // matched by Simple String Comparison, so an accepted value is compared to + // the string or array claim without any normalization + const auto match{std::ranges::find_if( + expected_audiences, [&token](const std::string_view audience) -> bool { + return token.has_audience(audience); + })}; + if (match == expected_audiences.end()) { + return OAuthAssertionError::Audience; + } + + const auto error{jwt_verify(token, keys, options.allowed_algorithms, + expected_issuer, *match, now, options.clock_skew, + expected_subject, std::nullopt)}; + if (error.has_value()) { + return map_verification_error(error.value()); + } + + // RFC 7523 Section 3 check 7: an identifier is tracked for as long as the + // assertion would be accepted, scoped to the audience it was issued for. The + // clock skew is added to the remaining lifetime so the entry outlives the + // grace window in which the assertion is still accepted past its expiration + if (options.replay_store != nullptr) { + const auto identifier{token.token_id()}; + const auto expiration{token.expires_at()}; + if (identifier.has_value() && expiration.has_value()) { + // The skew is clamped to the same non-negative bounded range the claim + // check applies, so a negative value cannot shorten the window below the + // acceptance window and a large one cannot overflow the store's expiry + 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}; + const auto window{remaining > std::chrono::seconds{0} + ? remaining + : std::chrono::seconds{0}}; + // The audience is keyed verbatim, since assertion audiences are compared + // by Simple String Comparison and must not be URL-normalized (RFC 7523 + // Section 3, RFC 3986 Section 6.2.1) + if (!options.replay_store->check_and_insert(identifier.value(), *match, + now, window, false)) { + return OAuthAssertionError::Replay; + } + } + } + + return std::nullopt; +} + +} // namespace + +auto oauth_build_assertion(const std::string_view issuer, + const std::string_view subject, + const std::string_view audience, + const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, + 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); + // The key identifier lets the server select the verifying key (RFC 7515 + // Section 4.1.4) + const auto key_id{key.key_id()}; + if (key_id.has_value()) { + header.assign_assume_new("kid", JSON{key_id.value()}, HASH_KID); + } + + auto payload{JSON::make_object()}; + payload.assign_assume_new("iss", JSON{issuer}, HASH_ISS); + payload.assign_assume_new("sub", JSON{subject}, HASH_SUB); + payload.assign_assume_new("aud", JSON{audience}, HASH_AUD); + payload.assign_assume_new("exp", JSON{to_epoch_seconds(now + lifetime)}, + HASH_EXP); + payload.assign_assume_new("iat", JSON{to_epoch_seconds(now)}, HASH_IAT); + const auto identifier{oauth_random_token()}; + payload.assign_assume_new( + "jti", JSON{std::string_view{identifier.data(), identifier.size()}}, + HASH_JTI); + + return jwt_sign(header, payload, key); +} + +auto oauth_build_client_assertion( + const std::string_view client_id, const std::string_view audience, + const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, const JWKPrivate &key, + const JWSAlgorithm algorithm) -> std::optional { + // RFC 7521 Section 5.2: a self-issued client authentication assertion has the + // client identifier as both its issuer and its subject + return oauth_build_assertion(client_id, client_id, audience, lifetime, now, + key, algorithm); +} + +auto oauth_client_assertion(const std::string_view assertion, + SecureString &sink) -> void { + oauth_append_form_parameter(sink, "client_assertion_type", + OAUTH_CLIENT_ASSERTION_TYPE_JWT_BEARER); + oauth_append_form_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); + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } +} + +auto oauth_verify_client_assertion( + const std::string_view assertion, + const std::span expected_audiences, + const std::string_view request_client_id, const JWKS &keys, + const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional { + const auto token{JWT::from(assertion)}; + if (!token.has_value()) { + return OAuthAssertionError::Malformed; + } + + const auto subject{token.value().subject()}; + if (!subject.has_value()) { + return OAuthAssertionError::Subject; + } + + // RFC 7521 Section 5.2: the issuer and subject are both the client + // identifier, and RFC 7521 Section 4.2: a presented client_id must identify + // the same client as the assertion subject + const auto expected_subject{request_client_id.empty() ? subject.value() + : request_client_id}; + return verify_assertion(token.value(), subject.value(), expected_subject, + expected_audiences, keys, now, options); +} + +auto oauth_verify_assertion_grant( + const std::string_view assertion, const std::string_view expected_issuer, + const std::span expected_audiences, + const JWKS &keys, const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional { + const auto token{JWT::from(assertion)}; + if (!token.has_value()) { + return OAuthAssertionError::Malformed; + } + + // RFC 7523 Section 3 check 2: a subject is REQUIRED, though for a grant it + // identifies the accessor rather than being constrained to a known value + if (!token.value().subject().has_value()) { + return OAuthAssertionError::Subject; + } + + return verify_assertion(token.value(), expected_issuer, std::nullopt, + expected_audiences, keys, now, options); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_authorization.cc b/vendor/core/src/core/oauth/oauth_authorization.cc new file mode 100644 index 000000000..695fe8e46 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_authorization.cc @@ -0,0 +1,398 @@ +#include + +#include +#include + +#include "oauth_authorization_parse.h" +#include "oauth_decode.h" +#include "oauth_syntax.h" + +#include // std::function +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +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; +}; + +// Split an "http://" URI into its host and the remainder after the authority, +// discarding the port, without constructing a URI (RFC 3986 Section 3.2) +auto split_http_authority(const std::string_view value) + -> std::optional { + static constexpr std::string_view prefix{"http://"}; + if (!value.starts_with(prefix)) { + return std::nullopt; + } + + const auto after{value.substr(prefix.size())}; + // RFC 3986 Section 3.2: the authority ends at the next "/", "?", or "#", or + // the end of the URI, so the query and fragment are part of the compared + // remainder rather than folded into the authority + const auto path_position{after.find_first_of("/?#")}; + auto authority{after.substr(0, path_position)}; + const auto rest{path_position == std::string_view::npos + ? std::string_view{} + : after.substr(path_position)}; + + // RFC 3986 Section 3.2.1: userinfo is delimited from the host by an "@". A + // loopback redirect never carries userinfo, and allowing it invites host + // confusion, since a redirect like "http://127.0.0.1:2@evil/cb" has its real + // host after the "@" yet its authority begins with the loopback literal, so + // an authority with userinfo does not qualify for the loopback exception + if (authority.find('@') != std::string_view::npos) { + return std::nullopt; + } + + std::string_view host; + if (authority.starts_with('[')) { + const auto close{authority.find(']')}; + if (close == std::string_view::npos) { + return std::nullopt; + } + + host = authority.substr(0, close + 1); + } else { + host = authority.substr(0, authority.find(':')); + } + + return HttpAuthority{.host = host, .rest = rest}; +} + +} // namespace + +auto oauth_build_authorization_url(const std::string_view endpoint, + const OAuthAuthorizationRequest &request, + std::string &sink) -> void { + // A lower bound covering the endpoint, the known keys and separators, and the + // raw value lengths, over which percent-escaping only ever grows + sink.reserve(sink.size() + endpoint.size() + request.client_id.size() + + request.redirect_uri.size() + request.scope.size() + + request.state.size() + request.code_challenge.size() + + 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' + : '&'; + } + + // 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"); + + if (!request.client_id.empty()) { + oauth_append_parameter(sink, separator, "client_id", request.client_id); + } + + if (!request.redirect_uri.empty()) { + oauth_append_parameter(sink, separator, "redirect_uri", + request.redirect_uri); + } + + if (!request.scope.empty()) { + oauth_append_parameter(sink, separator, "scope", request.scope); + } + + if (!request.state.empty()) { + oauth_append_parameter(sink, separator, "state", request.state); + } + + if (!request.code_challenge.empty()) { + oauth_append_parameter(sink, separator, "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); + } + } + + if (!request.request_uri.empty()) { + oauth_append_parameter(sink, separator, "request_uri", request.request_uri); + } + + if (!request.dpop_jkt.empty()) { + oauth_append_parameter(sink, separator, "dpop_jkt", request.dpop_jkt); + } + + for (const auto &resource : request.resources) { + oauth_append_parameter(sink, separator, resource.name, resource.value); + } + + for (const auto ¶meter : request.extra) { + oauth_append_parameter(sink, separator, parameter.name, parameter.value); + } +} + +auto oauth_parse_authorization_request( + const std::string_view query, std::string &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool { + return oauth_parse_authorization_into(query, storage, result, on_other, + false); +} + +auto oauth_redirect_uri_matches(const std::string_view registered, + const std::string_view presented, + const OAuthProfile profile) -> bool { + // RFC 6749 Section 3.1.2.3: the default is an exact match of the two URIs + if (registered == presented) { + return true; + } + + const auto registered_parts{split_http_authority(registered)}; + const auto presented_parts{split_http_authority(presented)}; + if (!registered_parts.has_value() || !presented_parts.has_value()) { + return false; + } + + // RFC 8252 Section 7.3: for a loopback http redirect the client may vary the + // port, so the two match when only the port differs. RFC 8252 Section 8.3 and + // OAuth 2.1 Section 8.4.2 keep "localhost" out of the loopback set under the + // strict profile + const auto host{registered_parts.value().host}; + const bool loopback{ + host == "127.0.0.1" || host == "[::1]" || + (profile == OAuthProfile::Compatible && host == "localhost")}; + return loopback && host == presented_parts.value().host && + registered_parts.value().rest == presented_parts.value().rest; +} + +auto oauth_is_private_use_scheme(const std::string_view scheme) noexcept + -> bool { + // RFC 3986 Section 3.1: "scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." + // )". A reverse domain name has no empty label, so a leading or trailing + // period is rejected here, and an interior empty label below + if (scheme.empty() || !is_alpha(scheme.front()) || scheme.back() == '.') { + return false; + } + + // RFC 8252 Section 7.1 and Section 8.4: a private-use scheme is a reverse + // domain name, so it must contain at least one period and no empty label + bool has_period{false}; + char previous{'\0'}; + for (const auto character : scheme) { + if (!is_alphanum(character) && character != '+' && character != '-' && + character != '.') { + return false; + } + + if (character == '.') { + if (previous == '.') { + return false; + } + + has_period = true; + } + + previous = character; + } + + return has_period; +} + +auto oauth_build_authorization_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> 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", and appending the query after one would place the parameters + // inside the fragment rather than the query + if (redirect_uri.find('#') != std::string_view::npos) { + return false; + } + + if (!response.iss.empty() && !oauth_is_issuer_identifier(response.iss)) { + return false; + } + + 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); + // 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); + } + + if (!response.iss.empty()) { + oauth_append_parameter(sink, separator, "iss", response.iss); + } + + return true; +} + +auto oauth_build_authorization_error_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> 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", and appending the query after one would place the parameters + // inside the fragment rather than the query + if (redirect_uri.find('#') != std::string_view::npos) { + return false; + } + + if (!response.iss.empty() && !oauth_is_issuer_identifier(response.iss)) { + return false; + } + + 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); + if (!response.error_description.empty()) { + oauth_append_parameter(sink, separator, "error_description", + response.error_description); + } + + if (!response.error_uri.empty()) { + oauth_append_parameter(sink, separator, "error_uri", response.error_uri); + } + + if (!response.state.empty()) { + oauth_append_parameter(sink, separator, "state", response.state); + } + + if (!response.iss.empty()) { + oauth_append_parameter(sink, separator, "iss", response.iss); + } + + return true; +} + +auto oauth_parse_authorization_response(const std::string_view query, + std::string &storage, + OAuthAuthorizationResponse &result) + -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1) + storage.reserve(storage.size() + query.size()); + const URI::Query parsed{query}; + bool has_code{false}; + bool has_state{false}; + bool has_iss{false}; + bool has_error{false}; + bool has_error_description{false}; + bool has_error_uri{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, the same rule + // the request parsers apply, and a malformed escape fails the parse + std::string_view name; + if (!oauth_form_decode_into(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + // RFC 6749 Section 3.1: "Request and response parameters MUST NOT be + // included more than once", so a duplicate is malformed, while an + // unrecognized parameter is ignored (RFC 6749 Section 4.1.2) + if (name == "code") { + if (has_code) { + return false; + } + + has_code = true; + if (!oauth_form_decode_into(value, storage, result.code)) { + return false; + } + } else if (name == "state") { + if (has_state) { + return false; + } + + has_state = true; + if (!oauth_form_decode_into(value, storage, result.state)) { + return false; + } + } else if (name == "iss") { + if (has_iss) { + return false; + } + + has_iss = true; + if (!oauth_form_decode_into(value, storage, result.iss)) { + return false; + } + } else if (name == "error") { + if (has_error) { + return false; + } + + has_error = true; + if (!oauth_form_decode_into(value, storage, result.error)) { + return false; + } + } else if (name == "error_description") { + if (has_error_description) { + return false; + } + + has_error_description = true; + if (!oauth_form_decode_into(value, storage, result.error_description)) { + return false; + } + } else if (name == "error_uri") { + if (has_error_uri) { + return false; + } + + has_error_uri = true; + if (!oauth_form_decode_into(value, storage, result.error_uri)) { + return false; + } + } + } + + return true; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_authorization_parse.h b/vendor/core/src/core/oauth/oauth_authorization_parse.h new file mode 100644 index 000000000..ded057749 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_authorization_parse.h @@ -0,0 +1,147 @@ +#ifndef SOURCEMETA_CORE_OAUTH_AUTHORIZATION_PARSE_H_ +#define SOURCEMETA_CORE_OAUTH_AUTHORIZATION_PARSE_H_ + +#include +#include +#include + +#include "oauth_decode.h" + +#include // std::function +#include // std::string_view +#include // std::is_same_v + +namespace sourcemeta::core { + +// Decode one value into the arena, borrowing when the arena is an ordinary +// string and always appending when it is a wiping one, so a body that carries +// secrets keeps its decoded values in wiping storage +template +inline auto oauth_authorization_decode(const std::string_view value, + Storage &storage, + std::string_view &result) -> bool { + if constexpr (std::is_same_v) { + return oauth_form_decode_into_secure(value, storage, result); + } else { + return oauth_form_decode_into(value, storage, result); + } +} + +template +inline auto oauth_authorization_assign_scalar(const std::string_view value, + Storage &storage, bool &seen, + std::string_view &field) -> bool { + // RFC 6749 Section 3.1: "Parameters sent without a value MUST be treated as + // if they were omitted", so an empty occurrence neither counts as a duplicate + // nor marks the parameter present + if (value.empty()) { + return true; + } + + // RFC 6749 Section 3.1: "Request and response parameters MUST NOT be included + // more than once", so a second occurrence of a recognized parameter fails + if (seen) { + return false; + } + + seen = true; + return oauth_authorization_decode(value, storage, field); +} + +// The shared authorization request parse (RFC 6749 Section 4.1.1). The storage +// type selects the arena posture, and reject_request_uri turns the presence of +// a request_uri parameter into a failure, which a pushed authorization request +// requires (RFC 9126 Section 2.1) +template +inline auto oauth_parse_authorization_into( + const std::string_view query, Storage &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other, + const bool reject_request_uri) -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1) + storage.reserve(storage.size() + query.size()); + const URI::Query parsed{query}; + bool has_response_type{false}; + bool has_client_id{false}; + bool has_redirect_uri{false}; + bool has_scope{false}; + bool has_state{false}; + bool has_code_challenge{false}; + bool has_code_challenge_method{false}; + bool has_request_uri{false}; + bool has_dpop_jkt{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_authorization_decode(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "response_type") { + valid = oauth_authorization_assign_scalar( + value, storage, has_response_type, result.response_type); + } else if (name == "client_id") { + valid = oauth_authorization_assign_scalar(value, storage, has_client_id, + result.client_id); + } else if (name == "redirect_uri") { + valid = oauth_authorization_assign_scalar( + value, storage, has_redirect_uri, result.redirect_uri); + } else if (name == "scope") { + valid = oauth_authorization_assign_scalar(value, storage, has_scope, + result.scope); + } else if (name == "state") { + valid = oauth_authorization_assign_scalar(value, storage, has_state, + result.state); + } else if (name == "code_challenge") { + valid = oauth_authorization_assign_scalar( + value, storage, has_code_challenge, result.code_challenge); + } else if (name == "code_challenge_method") { + valid = oauth_authorization_assign_scalar(value, storage, + has_code_challenge_method, + result.code_challenge_method); + } else if (name == "request_uri") { + // RFC 9126 Section 2.1: a pushed request MUST NOT provide request_uri, so + // its mere presence, whatever its value, fails the parse + if (reject_request_uri) { + return false; + } + + valid = oauth_authorization_assign_scalar(value, storage, has_request_uri, + result.request_uri); + } else if (name == "dpop_jkt") { + valid = oauth_authorization_assign_scalar(value, storage, has_dpop_jkt, + result.dpop_jkt); + } else { + // Repeatable resource indicators (RFC 8707) and extension parameters are + // surfaced with their decoded value rather than stored on the result + std::string_view decoded; + valid = oauth_authorization_decode(value, storage, decoded); + if (valid) { + on_other(name, decoded); + } + } + + if (!valid) { + return false; + } + } + + // RFC 7636 Section 4.3: "If the client does not send the + // code_challenge_method parameter ... the default code_challenge_method value + // is plain", which the strict profile must still see in order to reject + if (has_code_challenge && !has_code_challenge_method) { + result.code_challenge_method = "plain"; + } + + return true; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_bearer.cc b/vendor/core/src/core/oauth/oauth_bearer.cc new file mode 100644 index 000000000..09fd1c733 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_bearer.cc @@ -0,0 +1,261 @@ +#include + +#include +#include +#include + +#include // std::array +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::pair + +namespace sourcemeta::core { + +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)}; + +auto oauth_skip_ows(const std::string_view value, std::size_t position) noexcept + -> std::size_t { + while (position < value.size() && http_is_ows(value[position])) { + position += 1; + } + + return position; +} + +} // namespace + +auto oauth_bearer_header(const std::string_view token, std::string &sink) + -> bool { + if (!http_is_b64token(token)) { + return false; + } + + static constexpr std::string_view prefix{"Bearer "}; + sink.reserve(sink.size() + prefix.size() + token.size()); + sink.append(prefix); + sink.append(token); + return true; +} + +auto oauth_challenge_parameter(const std::string_view header, + const std::string_view scheme, + const std::string_view name) + -> std::optional { + // A local arena holds the unescaped bytes of a quoted value. The header is + // only ever read, never grown, so it can never alias the arena, and the + // matched value is returned by copy before the arena goes out of scope + std::string arena; + std::size_t position{0}; + // The scheme of the challenge currently being read. RFC 7235 shares the comma + // between the challenge list and each challenge's parameter list, so a token + // is a new scheme unless it is immediately followed by "=", in which case it + // is a parameter of the current challenge + std::string_view current_scheme{}; + // Whether the current challenge has already taken a parameter, and whether + // the next element must be preceded by a comma. RFC 9110 Section 5.6.1 + // separates list elements with commas, so a parameter or challenge that + // follows another without one is malformed rather than silently reassociated + bool scheme_has_parameter{false}; + bool separator_required{false}; + while (position < header.size()) { + position = oauth_skip_ows(header, position); + if (position >= header.size()) { + break; + } + + if (header[position] == ',') { + position += 1; + separator_required = false; + // A comma before any parameter ends an empty challenge, so a parameter + // that follows cannot attach to that scheme + if (!scheme_has_parameter) { + current_scheme = {}; + } + + continue; + } + + // The element is not a comma, so if one was required the header is + // malformed + if (separator_required) { + return std::nullopt; + } + + const std::size_t token_start{position}; + while (position < header.size() && http_is_tchar(header[position])) { + position += 1; + } + + if (position == token_start) { + return std::nullopt; + } + + const auto token{header.substr(token_start, position - token_start)}; + const auto after_token{position}; + const auto equals{oauth_skip_ows(header, position)}; + if (equals < header.size() && header[equals] == '=') { + if (current_scheme.empty()) { + return std::nullopt; + } + + position = oauth_skip_ows(header, equals + 1); + const bool match{equals_ignore_case(current_scheme, scheme) && + equals_ignore_case(token, name)}; + std::string_view value{}; + if (position < header.size() && header[position] == '"') { + arena.clear(); + const auto next{ + http_scan_quoted_string(header, position, arena, value)}; + if (!next.has_value()) { + return std::nullopt; + } + + position = next.value(); + } else { + const std::size_t value_start{position}; + while (position < header.size() && http_is_tchar(header[position])) { + position += 1; + } + + if (position == value_start) { + return std::nullopt; + } + + value = header.substr(value_start, position - value_start); + } + + scheme_has_parameter = true; + separator_required = true; + if (match) { + return std::string{value}; + } + + continue; + } + + // The token is a new scheme. It may be followed by a token68 credential, + // which is skipped whole so the scan reaches the next challenge + current_scheme = token; + scheme_has_parameter = false; + const auto separator{oauth_skip_ows(header, after_token)}; + if (separator > after_token && separator < header.size()) { + std::size_t blob{separator}; + while (blob < header.size() && http_is_b64token_char(header[blob])) { + blob += 1; + } + + while (blob < header.size() && header[blob] == '=') { + blob += 1; + } + + // A token68 must be a valid token68 (RFC 7235 Section 2.1) and must end + // the challenge, so a bare padding run is not one and a parameter cannot + // attach to the token68 form of a challenge + const auto terminator{oauth_skip_ows(header, blob)}; + if ((terminator >= header.size() || header[terminator] == ',') && + http_is_b64token(header.substr(separator, blob - separator))) { + position = blob; + current_scheme = {}; + separator_required = true; + } + } + } + + return std::nullopt; +} + +auto oauth_build_challenge(const std::string_view scheme, + const OAuthChallenge &challenge, std::string &sink) + -> bool { + // RFC 7235 Section 2.1: the scheme is a token + if (!http_is_token(scheme)) { + return false; + } + + const std::array, 7> parameters{ + {{"realm", challenge.realm}, + {"scope", challenge.scope}, + {"error", challenge.error}, + {"error_description", challenge.error_description}, + {"error_uri", challenge.error_uri}, + {"resource_metadata", challenge.resource_metadata}, + {"algs", challenge.algs}}}; + + // The challenge is rendered into a scratch buffer so that a later unencodable + // value leaves the sink untouched. A challenge with no parameter is a bare + // scheme, which RFC 7235 Section 2.1 permits and RFC 9449 Section 7.1 uses + // for a DPoP challenge with no parameters + std::string rendered; + bool first{true}; + for (const auto &[name, value] : parameters) { + if (value.empty()) { + continue; + } + + // RFC 7235 Section 4.1: a space separates the scheme from the first + // parameter, and a comma the parameters from each other + rendered.append(first ? " " : ", "); + rendered.append(name); + rendered.push_back('='); + if (!http_encode_quoted_string(value, rendered)) { + return false; + } + + first = false; + } + + sink.reserve(sink.size() + scheme.size() + rendered.size()); + sink.append(scheme); + sink.append(rendered); + return true; +} + +auto oauth_has_audience(const JSON &claims, const std::string_view audience) + -> bool { + // A resource identifier is an absolute URI and never empty (RFC 8707 + // Section 2), so an empty query cannot be a legitimate audience even if a + // token were to carry an empty one + if (audience.empty() || !claims.is_object()) { + return false; + } + + const auto *member{claims.try_at("aud"sv, HASH_AUD)}; + if (member == nullptr) { + return false; + } + + // RFC 9068 Section 4 and RFC 7662 Section 2.2: the audience is a single + // string or an array of strings, compared by code points (RFC 3986 + // Section 6.2.1) + if (member->is_string()) { + return member->to_string() == audience; + } + + return member->is_array() && member->contains(audience); +} + +auto oauth_is_dpop_bound(const JSON &claims) -> bool { + if (!claims.is_object()) { + return false; + } + + const auto *confirmation{claims.try_at("cnf"sv, HASH_CNF)}; + if (confirmation == nullptr || !confirmation->is_object()) { + return false; + } + + // RFC 9449 Section 6.1: a DPoP-bound token carries the JWK thumbprint of its + // proof-of-possession key as the "jkt" confirmation member + const auto *thumbprint{confirmation->try_at("jkt"sv, HASH_JKT)}; + return thumbprint != nullptr && thumbprint->is_string(); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_client_authentication.cc b/vendor/core/src/core/oauth/oauth_client_authentication.cc new file mode 100644 index 000000000..b2f272755 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_client_authentication.cc @@ -0,0 +1,206 @@ +#include + +#include +#include +#include + +#include "oauth_decode.h" +#include "oauth_encode.h" + +#include // std::size_t +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +auto assign_secure_scalar(const std::string_view value, SecureString &storage, + bool &seen, std::string_view &field) -> bool { + // RFC 6749 Section 3.2: "Parameters sent without a value MUST be treated as + // if they were omitted", so an empty occurrence neither counts as a duplicate + // nor marks the parameter present + if (value.empty()) { + return true; + } + + // RFC 6749 Section 3.2: a recognized parameter must not appear twice + if (seen) { + return false; + } + + seen = true; + return oauth_form_decode_into_secure(value, storage, field); +} + +} // namespace + +auto oauth_client_secret_basic(const std::string_view client_id, + const std::string_view client_secret, + SecureString &sink) -> void { + // RFC 6749 Section 2.3.1: each half is percent-encoded before it is joined + // with a colon, so a colon inside the identifier or secret cannot be mistaken + // for the delimiter, and only then is the pair Base64 encoded + SecureString credential; + credential.reserve(client_id.size() + client_secret.size() + 1); + URI::escape(client_id, credential); + credential.push_back(':'); + URI::escape(client_secret, credential); + + sink.append("Basic "); + base64_encode(std::string_view{credential}, sink); +} + +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); +} + +auto oauth_client_id_only(const std::string_view client_id, SecureString &sink) + -> void { + oauth_append_form_parameter(sink, "client_id", client_id); +} + +auto oauth_parse_client_authentication(const std::string_view authorization, + const std::string_view body, + SecureString &storage, + OAuthClientCredentials &credentials) + -> bool { + credentials = {}; + credentials.method = OAuthClientAuthenticationMethod::None; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view. Every decoded value is at most + // its raw length, so the two inputs bound the total + storage.reserve(storage.size() + authorization.size() + body.size()); + + bool basic_present{false}; + std::string_view basic_id; + std::string_view basic_secret; + const auto scheme_end{authorization.find(' ')}; + if (scheme_end != std::string_view::npos && + equals_ignore_case(authorization.substr(0, scheme_end), "Basic")) { + basic_present = true; + // RFC 7235 Section 2.1: the scheme is followed by one or more spaces before + // the credential + auto token_start{scheme_end}; + while (token_start < authorization.size() && + authorization[token_start] == ' ') { + token_start += 1; + } + + // RFC 6749 Section 2.3.1: the credential is the Base64 of the percent + // encoded identifier and secret joined by a colon, so a decode failure or a + // missing colon is a malformed request. The decoded credential is secret, + // so it lands in a local wiping string + SecureString credential; + if (!base64_decode(authorization.substr(token_start), credential)) { + return false; + } + + const std::string_view decoded{credential}; + const auto colon{decoded.find(':')}; + if (colon == std::string_view::npos || + !oauth_form_decode_into_secure(decoded.substr(0, colon), storage, + basic_id) || + !oauth_form_decode_into_secure(decoded.substr(colon + 1), storage, + basic_secret)) { + return false; + } + } + + bool has_client_id{false}; + bool has_client_secret{false}; + bool has_assertion{false}; + bool has_assertion_type{false}; + std::string_view body_client_id; + std::string_view body_client_secret; + std::string_view body_assertion; + std::string_view body_assertion_type; + const URI::Query parsed{body}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_form_decode_into_secure(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "client_id") { + valid = + assign_secure_scalar(value, storage, has_client_id, body_client_id); + } else if (name == "client_secret") { + valid = assign_secure_scalar(value, storage, has_client_secret, + body_client_secret); + } else if (name == "client_assertion") { + valid = + assign_secure_scalar(value, storage, has_assertion, body_assertion); + } else if (name == "client_assertion_type") { + valid = assign_secure_scalar(value, storage, has_assertion_type, + body_assertion_type); + } + + if (!valid) { + return false; + } + } + + // RFC 6749 Section 2.3 and RFC 7521 Section 4.2.1: a request must use exactly + // one authentication mechanism, and the assertion parameters count as one, so + // presenting more than one is rejected. The caller chooses the error code, + // which RFC 7521 Section 4.2.1 makes invalid_client when the collision + // involves the assertion mechanism and RFC 6749 Section 5.2 makes + // invalid_request otherwise. A bare body client_id is identification, not a + // mechanism (RFC 6749 Section 3.2.1) + const bool assertion_present{has_assertion || has_assertion_type}; + const auto mechanisms{static_cast(basic_present) + + static_cast(has_client_secret) + + static_cast(assertion_present)}; + if (mechanisms > 1) { + return false; + } + + // RFC 6749 Section 5.2: a Basic username that conflicts with a body client_id + // is a malformed request, so the two must identify the same client + if (basic_present && has_client_id && basic_id != body_client_id) { + return false; + } + + // RFC 7521 Section 4.2: the assertion mechanism needs both parameters + if (assertion_present && !(has_assertion && has_assertion_type)) { + return false; + } + + // RFC 6749 Section 2.3.1: client_secret_post carries both the identifier and + // the secret, so a body secret with no client_id is an incomplete mechanism. + // A Basic secret has its own identifier, and a Basic and body secret together + // are already rejected as two mechanisms above + if (has_client_secret && !has_client_id) { + return false; + } + + if (basic_present) { + credentials.method = OAuthClientAuthenticationMethod::Basic; + credentials.client_id = basic_id; + credentials.client_secret = basic_secret; + } else if (has_client_secret) { + credentials.method = OAuthClientAuthenticationMethod::Post; + credentials.client_id = body_client_id; + credentials.client_secret = body_client_secret; + } else if (assertion_present) { + credentials.method = OAuthClientAuthenticationMethod::Assertion; + credentials.client_id = body_client_id; + credentials.assertion = body_assertion; + credentials.assertion_type = body_assertion_type; + } else if (has_client_id) { + credentials.method = OAuthClientAuthenticationMethod::Public; + credentials.client_id = body_client_id; + } + + return true; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_decode.h b/vendor/core/src/core/oauth/oauth_decode.h new file mode 100644 index 000000000..9765f8772 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_decode.h @@ -0,0 +1,67 @@ +#ifndef SOURCEMETA_CORE_OAUTH_DECODE_H_ +#define SOURCEMETA_CORE_OAUTH_DECODE_H_ + +#include +#include + +#include // assert +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +// Decode one "application/x-www-form-urlencoded" value against the caller's +// arena following design convention 1: borrow from the input when it carries no +// escape, otherwise append the decoded bytes to the arena and view them there. +// The arena MUST be reserved up front to at least the total raw length of every +// value that will be decoded into it, since decoding only shrinks, so that no +// append reallocates and a prior borrowed view into it stays valid. Reserving +// the whole input length once satisfies this +inline auto oauth_form_decode_into(const std::string_view value, + std::string &arena, std::string_view &result) + -> bool { + if (value.find_first_of("+%") == std::string_view::npos) { + result = value; + return true; + } + + // The caller must have reserved enough headroom for the raw value, since + // decoding only shrinks, so this append never reallocates and a prior + // borrowed view into the arena stays valid. The assert catches a violation + // loudly under test, and the guard fails closed rather than dangle a view if + // the contract is ever broken in a release build + assert(arena.capacity() - arena.size() >= value.size()); + if (arena.capacity() - arena.size() < value.size()) { + return false; + } + + const auto base{arena.size()}; + if (!URI::unescape_form(value, arena)) { + return false; + } + + result = std::string_view{arena}.substr(base); + return true; +} + +// Decode one "application/x-www-form-urlencoded" value into a wiping arena, for +// a request whose values are secret such as a token. Unlike the borrowing +// variant it always appends, so the decoded view lands in the wiping arena +// rather than the caller's buffer, and the value never aliases the arena. The +// arena MUST be reserved up front to at least the total raw length so no append +// reallocates and a prior view into it stays valid +inline auto oauth_form_decode_into_secure(const std::string_view value, + SecureString &arena, + std::string_view &result) -> bool { + const auto base{arena.size()}; + if (!URI::unescape_form(value, arena)) { + return false; + } + + result = std::string_view{arena}.substr(base); + return true; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_device.cc b/vendor/core/src/core/oauth/oauth_device.cc new file mode 100644 index 000000000..a6f1d65f4 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_device.cc @@ -0,0 +1,285 @@ +#include + +#include +#include +#include + +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::array +#include // std::chrono::seconds, std::chrono::steady_clock +#include // std::size_t +#include // std::int64_t, std::uint8_t +#include // std::numeric_limits +#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; + +// RFC 8628 Section 6.1: the recommended base-20 user code alphabet, the +// uppercase letters A to Z with the vowels and Y removed so a code cannot form +// a word +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{ + 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)}; + +auto normalize_user_code(const std::string_view value) -> SecureString { + SecureString result; + result.reserve(value.size()); + for (const auto character : value) { + if (!is_alphanum(character)) { + continue; + } + + // The alphabet is uppercase, so a lowercase ASCII letter is folded up + if (character >= 'a' && character <= 'z') { + result.push_back(static_cast(character - ('a' - 'A'))); + } else { + result.push_back(character); + } + } + + return result; +} + +} // namespace + +auto oauth_build_device_authorization_request( + const std::string_view client_id, const std::string_view scope, + const std::span resources, std::string &sink) + -> void { + if (!client_id.empty()) { + oauth_append_form_parameter(sink, "client_id", client_id); + } + + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } + + for (const auto &resource : resources) { + oauth_append_form_parameter(sink, resource.name, resource.value); + } +} + +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", + "urn:ietf:params:oauth:grant-type:device_code"); + oauth_append_form_parameter(sink, "device_code", device_code); + for (const auto &resource : resources) { + oauth_append_form_parameter(sink, resource.name, resource.value); + } +} + +OAuthDeviceAuthorizationResponse::OAuthDeviceAuthorizationResponse( + const JSON &data) + : data_{&data} {} + +auto OAuthDeviceAuthorizationResponse::device_code() const + -> std::optional { + return oauth_json_string_member(*this->data_, "device_code"sv, + HASH_DEVICE_CODE); +} + +auto OAuthDeviceAuthorizationResponse::user_code() const + -> std::optional { + return oauth_json_string_member(*this->data_, "user_code"sv, HASH_USER_CODE); +} + +auto OAuthDeviceAuthorizationResponse::verification_uri() const + -> std::optional { + return oauth_json_string_member(*this->data_, "verification_uri"sv, + HASH_VERIFICATION_URI); +} + +auto OAuthDeviceAuthorizationResponse::verification_uri_complete() const + -> std::optional { + return oauth_json_string_member(*this->data_, "verification_uri_complete"sv, + HASH_VERIFICATION_URI_COMPLETE); +} + +auto OAuthDeviceAuthorizationResponse::expires_in() const + -> std::optional { + // RFC 8628 Section 3.2: expires_in is a REQUIRED positive lifetime, so a zero + // or negative value is malformed and reported as absent rather than surfacing + // as an immediate expiry to a poller + const auto value{ + oauth_json_seconds_member(*this->data_, "expires_in"sv, HASH_EXPIRES_IN)}; + return (value.has_value() && value.value() > std::chrono::seconds{0}) + ? value + : std::nullopt; +} + +auto OAuthDeviceAuthorizationResponse::interval() const + -> std::chrono::seconds { + // RFC 8628 Section 3.2: absent interval means the default, and a non-positive + // interval is malformed, so it also falls back to the default rather than + // driving overly aggressive polling + const auto value{ + oauth_json_seconds_member(*this->data_, "interval"sv, HASH_INTERVAL)}; + return (value.has_value() && value.value() > std::chrono::seconds{0}) + ? value.value() + : DEFAULT_INTERVAL; +} + +auto OAuthDeviceAuthorizationResponse::data() const -> const JSON & { + return *this->data_; +} + +OAuthDevicePoller::OAuthDevicePoller( + const std::chrono::seconds interval, const std::chrono::seconds lifetime, + const std::chrono::steady_clock::time_point start) noexcept + : interval_{interval > std::chrono::seconds{0} ? interval + : DEFAULT_INTERVAL}, + lifetime_{lifetime}, start_{start} {} + +auto OAuthDevicePoller::interval() const noexcept -> std::chrono::seconds { + return this->interval_; +} + +auto OAuthDevicePoller::expired( + const std::chrono::steady_clock::time_point now) const noexcept -> bool { + // A time before the start has not elapsed anything, and truncating its + // negative sub-second difference to zero would otherwise read as expired for + // a zero lifetime + if (now < this->start_) { + return false; + } + + // Comparing the elapsed duration against the lifetime, rather than a + // start-plus-lifetime deadline, keeps a large lifetime from overflowing + return std::chrono::duration_cast(now - this->start_) >= + this->lifetime_; +} + +auto OAuthDevicePoller::observe(const OAuthTokenError error) noexcept + -> OAuthDevicePollDecision { + switch (error) { + case OAuthTokenError::SlowDown: + // RFC 8628 Section 3.5: slow_down permanently increases the interval by + // five seconds, saturating rather than overflowing a maximal interval + if (this->interval_ < + std::chrono::seconds{ + std::numeric_limits::max() - 5}) { + this->interval_ += std::chrono::seconds{5}; + } else { + this->interval_ = std::chrono::seconds{ + std::numeric_limits::max()}; + } + + return OAuthDevicePollDecision::Continue; + case OAuthTokenError::AuthorizationPending: + return OAuthDevicePollDecision::Continue; + case OAuthTokenError::UseDPoPNonce: + // RFC 9449 Section 8: the server requires a nonce, because the proof + // carried none or a stale one, and now supplies it, so the proof is + // re-minted with the nonce and the poll retried without growing the + // interval + return OAuthDevicePollDecision::RetryWithNonce; + case OAuthTokenError::AccessDenied: + return OAuthDevicePollDecision::Denied; + case OAuthTokenError::ExpiredToken: + return OAuthDevicePollDecision::Expired; + default: + return OAuthDevicePollDecision::Error; + } +} + +auto oauth_make_device_authorization_response( + const std::string_view device_code, const std::string_view user_code, + const std::string_view verification_uri, + const std::string_view verification_uri_complete, + const std::chrono::seconds expires_in, const std::chrono::seconds interval) + -> std::optional { + // RFC 8628 Section 3.2: device_code, user_code, verification_uri, and a + // positive expires_in lifetime are REQUIRED, so a missing one yields no + // document rather than an unusable response + if (device_code.empty() || user_code.empty() || verification_uri.empty() || + expires_in <= std::chrono::seconds{0}) { + return std::nullopt; + } + + auto response{JSON::make_object()}; + response.assign_assume_new("device_code", JSON{device_code}, + HASH_DEVICE_CODE); + response.assign_assume_new("user_code", JSON{user_code}, HASH_USER_CODE); + response.assign_assume_new("verification_uri", JSON{verification_uri}, + HASH_VERIFICATION_URI); + if (!verification_uri_complete.empty()) { + response.assign_assume_new("verification_uri_complete", + JSON{verification_uri_complete}, + HASH_VERIFICATION_URI_COMPLETE); + } + + response.assign_assume_new( + "expires_in", JSON{static_cast(expires_in.count())}, + HASH_EXPIRES_IN); + // RFC 8628 Section 3.2: the interval is emitted only when it is positive and + // differs from the default the client would otherwise assume, so a client + // never receives a zero or negative polling delay + if (interval > std::chrono::seconds{0} && interval != DEFAULT_INTERVAL) { + response.assign_assume_new( + "interval", JSON{static_cast(interval.count())}, + HASH_INTERVAL); + } + + return response; +} + +auto oauth_device_user_code() -> std::array { + std::array result{}; + const auto alphabet{static_cast(USER_CODE_ALPHABET.size())}; + // Reject bytes in the biased tail so each character is drawn uniformly from + // the alphabet, preserving the RFC 8628 Section 6.1 entropy of the code + const auto limit{static_cast(256U / alphabet * alphabet)}; + std::size_t filled{0}; + while (filled < result.size()) { + std::array entropy{}; + random_bytes(entropy); + for (const auto byte : entropy) { + if (byte >= limit) { + continue; + } + + result[filled] = USER_CODE_ALPHABET[byte % alphabet]; + filled += 1; + if (filled == result.size()) { + break; + } + } + } + + return result; +} + +auto oauth_device_user_code_matches(const std::string_view presented, + const std::string_view stored) -> bool { + const auto normalized_presented{normalize_user_code(presented)}; + const auto normalized_stored{normalize_user_code(stored)}; + // A code has content, so an empty normalized value on either side, from an + // empty or punctuation-only input, never matches, since secure_equals treats + // two empty strings as equal + if (normalized_presented.empty() || normalized_stored.empty()) { + return false; + } + + return secure_equals(normalized_presented, normalized_stored); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_dpop.cc b/vendor/core/src/core/oauth/oauth_dpop.cc new file mode 100644 index 000000000..367bcfb61 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_dpop.cc @@ -0,0 +1,433 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "oauth_json.h" +#include "oauth_syntax.h" + +#include // std::ranges::find, std::ranges::all_of, std::ranges::count_if +#include // std::array +#include // std::chrono::seconds, std::chrono::duration_cast +#include // std::int64_t, std::uint8_t +#include // std::scoped_lock +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move +#include // std::erase_if + +namespace sourcemeta::core { + +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)}; + +// 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)}; + +// 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 +// same request compares equal on both sides +auto dpop_normalize_target(const std::string_view url) + -> std::optional { + auto uri{oauth_try_parse_uri(url)}; + if (!uri.has_value()) { + return std::nullopt; + } + + // RFC 9449 Section 4.3 recommends the syntax and scheme normalization of + // RFC 3986 Sections 6.2.2 and 6.2.3, and Section 4.2 excludes the query and + // fragment. RFC 9110 Section 4.2.1 gives an "http" or "https" target URI no + // userinfo, so it is dropped rather than signed into the proof or compared + uri->canonicalize(); + uri->query(""); + uri->userinfo(""); + if (!uri->scheme().has_value() || !uri->host().has_value()) { + return std::nullopt; + } + + // RFC 3986 Section 6.2.3: an authority with an empty path is equivalent to + // one with a path of "/", a scheme-based normalization the general + // canonicalizer leaves to the caller + if (!uri->path().has_value() || uri->path().value().empty()) { + uri->path(std::string{"/"}); + } + + return uri->recompose_without_fragment(); +} + +// RFC 9449 Section 4.2 and Section 7: the base64url-encoded SHA-256 hash of the +// access token, the value bound into a proof through the hash claim +auto dpop_access_token_hash(const std::string_view access_token) + -> std::string { + const auto digest{sha256_digest(access_token)}; + return base64url_encode(std::string_view{ + reinterpret_cast(digest.data()), digest.size()}); +} + +// RFC 9449 Section 4.3 check 7: the public key MUST NOT contain a private key, +// whose presence is marked by any private JSON Web Key member +auto dpop_has_private_material(const JSON &key) -> bool { + return key.try_at("d"sv, HASH_D) != nullptr || + key.try_at("p"sv, HASH_P) != nullptr || + key.try_at("q"sv, HASH_Q) != nullptr || + key.try_at("dp"sv, HASH_DP) != nullptr || + key.try_at("dq"sv, HASH_DQ) != nullptr || + key.try_at("qi"sv, HASH_QI) != nullptr || + key.try_at("oth"sv, HASH_OTH) != nullptr || + key.try_at("k"sv, HASH_K) != nullptr; +} + +} // namespace + +OAuthDPoPProofer::OAuthDPoPProofer(JWKPrivate key, const JWSAlgorithm algorithm) + : key_{std::move(key)}, algorithm_{algorithm} {} + +auto OAuthDPoPProofer::proof(const std::string_view server, + const std::string_view method, + const std::string_view url, + const std::string_view access_token, + const std::chrono::system_clock::time_point now, + std::string &sink) -> bool { + // RFC 9449 Section 4.2: a DPoP proof MUST be signed with an asymmetric + // algorithm, so a symmetric one yields no proof rather than an invalid one + if (!jws_algorithm_is_asymmetric(this->algorithm_)) { + return false; + } + + auto target{dpop_normalize_target(url)}; + if (!target.has_value()) { + return false; + } + + auto public_key{this->key_.public_jwk()}; + if (!public_key.has_value()) { + return false; + } + + auto header{JSON::make_object()}; + header.assign_assume_new("typ", JSON{"dpop+jwt"}, HASH_TYP); + header.assign_assume_new("alg", JSON{jws_algorithm_name(this->algorithm_)}, + HASH_ALG); + header.assign_assume_new("jwk", std::move(public_key.value()), HASH_JWK); + + auto payload{JSON::make_object()}; + const auto identifier{oauth_random_token()}; + payload.assign_assume_new( + "jti", JSON{std::string_view{identifier.data(), identifier.size()}}, + HASH_JTI); + payload.assign_assume_new("htm", JSON{method}, HASH_HTM); + payload.assign_assume_new("htu", JSON{std::move(target.value())}, HASH_HTU); + payload.assign_assume_new( + "iat", + JSON{static_cast( + std::chrono::duration_cast( + now.time_since_epoch()) + .count())}, + HASH_IAT); + if (!access_token.empty()) { + payload.assign_assume_new("ath", JSON{dpop_access_token_hash(access_token)}, + HASH_ATH); + } + + { + const std::scoped_lock lock{this->mutex_}; + const auto iterator{this->nonces_.find(server)}; + if (iterator != this->nonces_.end()) { + payload.assign_assume_new("nonce", JSON{iterator->second}, HASH_NONCE); + } + } + + const auto token{jwt_sign(header, payload, this->key_)}; + if (!token.has_value()) { + return false; + } + + sink.append(token.value()); + return true; +} + +auto OAuthDPoPProofer::observe(const std::string_view server, + const std::string_view nonce) -> void { + // RFC 9449 Section 8.1: a nonce is a non-empty string of the nonce character + // set, so a malformed one is dropped rather than echoed into a later proof + if (!oauth_is_valid_dpop_nonce(nonce)) { + return; + } + + const std::scoped_lock lock{this->mutex_}; + this->nonces_.insert_or_assign(std::string{server}, std::string{nonce}); +} + +auto OAuthDPoPProofer::thumbprint() const -> std::optional { + return this->key_.thumbprint(); +} + +auto oauth_dpop_confirmation(const std::string_view thumbprint) -> JSON { + auto confirmation{JSON::make_object()}; + confirmation.assign_assume_new("jkt", JSON{thumbprint}, HASH_JKT); + return confirmation; +} + +auto oauth_dpop_proof_thumbprint(const std::string_view proof) + -> std::optional { + const auto token{JWT::from(proof)}; + if (!token.has_value()) { + return std::nullopt; + } + + const auto &header{token.value().header()}; + if (!header.is_object()) { + return std::nullopt; + } + + const auto *key{header.try_at("jwk"sv, HASH_JWK)}; + if (key == nullptr || !key->is_object()) { + return std::nullopt; + } + + // RFC 9449 Section 4.3 check 7: a proof whose embedded key carries private + // material is invalid, so no thumbprint is derived to bind or match against + if (dpop_has_private_material(*key)) { + return std::nullopt; + } + + const auto parsed{JWK::from(*key)}; + if (!parsed.has_value()) { + return std::nullopt; + } + + return parsed.value().thumbprint(); +} + +auto oauth_dpop_verify(const std::string_view proof, + const std::string_view method, + const std::string_view url, + const std::chrono::system_clock::time_point now, + const OAuthDPoPVerifyOptions &options) + -> std::optional { + // RFC 9449 Section 4.3 check 1: exactly one DPoP header field + if (options.proof_count != 1) { + return OAuthDPoPError::ProofCount; + } + + // Check 2: a single well-formed JSON Web Token + const auto token{JWT::from(proof)}; + if (!token.has_value()) { + return OAuthDPoPError::Malformed; + } + + 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") { + return OAuthDPoPError::UnexpectedType; + } + + // Check 5: an asymmetric algorithm, not none, within local policy + const auto algorithm{parsed.algorithm()}; + if (!algorithm.has_value() || + !jws_algorithm_is_asymmetric(algorithm.value())) { + return OAuthDPoPError::UnsupportedAlgorithm; + } + + if (!options.allowed_algorithms.empty() && + std::ranges::find(options.allowed_algorithms, algorithm.value()) == + options.allowed_algorithms.end()) { + return OAuthDPoPError::UnsupportedAlgorithm; + } + + // Check 3: all required header parameters and claims are present + const auto &header{parsed.header()}; + const auto *key{header.is_object() ? header.try_at("jwk"sv, HASH_JWK) + : nullptr}; + if (key == nullptr || !key->is_object()) { + return OAuthDPoPError::MissingClaim; + } + + const auto identifier{parsed.token_id()}; + const auto method_claim{ + oauth_json_string_member(parsed.payload(), "htm"sv, HASH_HTM)}; + const auto target_claim{ + oauth_json_string_member(parsed.payload(), "htu"sv, HASH_HTU)}; + const auto issued{parsed.issued_at()}; + if (!identifier.has_value() || !method_claim.has_value() || + !target_claim.has_value() || !issued.has_value()) { + return OAuthDPoPError::MissingClaim; + } + + // Check 7: the embedded key carries no private material + if (dpop_has_private_material(*key)) { + return OAuthDPoPError::PrivateKey; + } + + // Check 6: the signature verifies with the embedded key + const auto public_key{JWK::from(*key)}; + if (!public_key.has_value() || + !jwt_verify_signature(parsed, public_key.value())) { + return OAuthDPoPError::Signature; + } + + // Check 8: the method claim matches the request method + if (method_claim.value() != method) { + return OAuthDPoPError::MethodMismatch; + } + + // Check 9: the target claim matches the request target + const auto normalized_claim{dpop_normalize_target(target_claim.value())}; + const auto normalized_request{dpop_normalize_target(url)}; + if (!normalized_claim.has_value() || !normalized_request.has_value() || + normalized_claim.value() != normalized_request.value()) { + return OAuthDPoPError::TargetMismatch; + } + + // Check 10: the nonce claim matches the nonce the server issued + const auto nonce_claim{ + oauth_json_string_member(parsed.payload(), "nonce"sv, HASH_NONCE)}; + if (options.expected_nonce.has_value()) { + if (!nonce_claim.has_value()) { + return OAuthDPoPError::MissingNonce; + } + + if (!secure_equals(nonce_claim.value(), options.expected_nonce.value())) { + return OAuthDPoPError::NonceMismatch; + } + } + + // Check 11: the creation time is within the acceptable window + if (issued.value() < now - options.past_window || + issued.value() > now + options.future_window) { + return OAuthDPoPError::Expired; + } + + // Check 12: the access token hash and the key binding, when a token is + // presented + if (options.access_token.has_value()) { + const auto hash_claim{ + oauth_json_string_member(parsed.payload(), "ath"sv, HASH_ATH)}; + if (!hash_claim.has_value()) { + return OAuthDPoPError::AccessTokenMismatch; + } + + const auto expected{dpop_access_token_hash(options.access_token.value())}; + if (!secure_equals(hash_claim.value(), expected)) { + return OAuthDPoPError::AccessTokenMismatch; + } + + // RFC 9449 Section 7.1: a protected resource MUST confirm the proof key + // matches the key the token is bound to, so presenting a token without a + // binding to check against fails closed rather than skipping that + // confirmation + if (!options.bound_thumbprint.has_value()) { + return OAuthDPoPError::KeyMismatch; + } + } + + if (options.bound_thumbprint.has_value()) { + const auto proof_thumbprint{public_key.value().thumbprint()}; + if (!proof_thumbprint.has_value() || + !secure_equals(proof_thumbprint.value(), + options.bound_thumbprint.value())) { + return OAuthDPoPError::KeyMismatch; + } + } + + return std::nullopt; +} + +auto OAuthDPoPReplayStore::check_and_insert( + const std::string_view identifier, const std::string_view target, + const std::chrono::system_clock::time_point now, + const std::chrono::seconds window, const bool normalize_target) -> bool { + // RFC 9449 Section 11.1: the identifier is tracked in the context of the + // target, so the digest covers both and one store safely spans targets. A + // DPoP target is normalized the same way the verifier compares the htu claim + // (Section 4.3 check 9), so a replay to an equivalent but differently spelled + // target is still detected rather than admitted as a fresh identifier, while + // a target compared verbatim, such as an assertion audience, is keyed as is + const auto normalized{normalize_target ? dpop_normalize_target(target) + : std::nullopt}; + const std::string_view effective{ + normalized.has_value() ? std::string_view{normalized.value()} : target}; + std::string material; + material.reserve(identifier.size() + 1 + effective.size()); + material.append(identifier); + material.push_back('\0'); + material.append(effective); + const auto digest{sha256_digest(material)}; + + const std::scoped_lock lock{this->mutex_}; + std::erase_if(this->entries_, [now](const Entry &entry) -> bool { + return entry.expiry <= now; + }); + + for (const auto &existing : this->entries_) { + if (existing.digest == digest) { + return false; + } + } + + // RFC 9449 Section 11.1: once the expired entries are pruned every remaining + // one still guards against a replay, so a full store fails closed rather than + // evict a live entry. Evicting one would drop its guard and let that proof be + // replayed, so the new proof is rejected until a slot frees on expiry. The + // capacity bounds the memory an attacker minting distinct proofs can consume + if (this->entries_.size() >= this->capacity_) { + return false; + } + + this->entries_.push_back(Entry{.digest = digest, .expiry = now + window}); + return true; +} + +auto OAuthDPoPReplayStore::size( + const std::chrono::system_clock::time_point now) const -> std::size_t { + const std::scoped_lock lock{this->mutex_}; + return static_cast( + std::ranges::count_if(this->entries_, [now](const Entry &entry) -> bool { + return entry.expiry > now; + })); +} + +auto oauth_is_valid_dpop_nonce(const std::string_view value) noexcept -> bool { + if (value.empty()) { + return false; + } + + // RFC 6749 Appendix A: NQCHAR = %x21 / %x23-5B / %x5D-7E, the printable ASCII + // set excluding the space, the double quote, and the backslash + return std::ranges::all_of(value, [](const char character) -> bool { + const auto byte{static_cast(character)}; + return byte == 0x21 || (byte >= 0x23 && byte <= 0x5B) || + (byte >= 0x5D && byte <= 0x7E); + }); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_encode.h b/vendor/core/src/core/oauth/oauth_encode.h new file mode 100644 index 000000000..0903d6cdf --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_encode.h @@ -0,0 +1,30 @@ +#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_error.cc b/vendor/core/src/core/oauth/oauth_error.cc new file mode 100644 index 000000000..05d4501d0 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_error.cc @@ -0,0 +1,221 @@ +#include + +#include // std::optional, std::nullopt +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oauth_error_code(const OAuthAuthorizationError error) noexcept + -> std::string_view { + switch (error) { + case OAuthAuthorizationError::InvalidRequest: + return "invalid_request"; + case OAuthAuthorizationError::UnauthorizedClient: + return "unauthorized_client"; + case OAuthAuthorizationError::AccessDenied: + return "access_denied"; + case OAuthAuthorizationError::UnsupportedResponseType: + return "unsupported_response_type"; + case OAuthAuthorizationError::InvalidScope: + return "invalid_scope"; + case OAuthAuthorizationError::ServerError: + return "server_error"; + case OAuthAuthorizationError::TemporarilyUnavailable: + return "temporarily_unavailable"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthTokenError error) noexcept + -> std::string_view { + switch (error) { + case OAuthTokenError::InvalidRequest: + return "invalid_request"; + case OAuthTokenError::InvalidClient: + return "invalid_client"; + case OAuthTokenError::InvalidGrant: + return "invalid_grant"; + case OAuthTokenError::UnauthorizedClient: + return "unauthorized_client"; + case OAuthTokenError::UnsupportedGrantType: + return "unsupported_grant_type"; + case OAuthTokenError::InvalidScope: + return "invalid_scope"; + case OAuthTokenError::AuthorizationPending: + return "authorization_pending"; + case OAuthTokenError::SlowDown: + return "slow_down"; + case OAuthTokenError::AccessDenied: + return "access_denied"; + case OAuthTokenError::ExpiredToken: + return "expired_token"; + case OAuthTokenError::InvalidTarget: + return "invalid_target"; + case OAuthTokenError::InvalidDPoPProof: + return "invalid_dpop_proof"; + case OAuthTokenError::UseDPoPNonce: + return "use_dpop_nonce"; + case OAuthTokenError::UnsupportedTokenType: + return "unsupported_token_type"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthBearerError error) noexcept + -> std::string_view { + switch (error) { + case OAuthBearerError::InvalidRequest: + return "invalid_request"; + case OAuthBearerError::InvalidToken: + return "invalid_token"; + case OAuthBearerError::InsufficientScope: + return "insufficient_scope"; + case OAuthBearerError::InvalidDPoPProof: + return "invalid_dpop_proof"; + case OAuthBearerError::UseDPoPNonce: + return "use_dpop_nonce"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthRegistrationError error) noexcept + -> std::string_view { + switch (error) { + case OAuthRegistrationError::InvalidRedirectURI: + return "invalid_redirect_uri"; + case OAuthRegistrationError::InvalidClientMetadata: + return "invalid_client_metadata"; + case OAuthRegistrationError::InvalidSoftwareStatement: + return "invalid_software_statement"; + case OAuthRegistrationError::UnapprovedSoftwareStatement: + return "unapproved_software_statement"; + } + + std::unreachable(); +} + +auto to_oauth_authorization_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthAuthorizationError::InvalidRequest; + } else if (code == "unauthorized_client") { + return OAuthAuthorizationError::UnauthorizedClient; + } else if (code == "access_denied") { + return OAuthAuthorizationError::AccessDenied; + } else if (code == "unsupported_response_type") { + return OAuthAuthorizationError::UnsupportedResponseType; + } else if (code == "invalid_scope") { + return OAuthAuthorizationError::InvalidScope; + } else if (code == "server_error") { + return OAuthAuthorizationError::ServerError; + } else if (code == "temporarily_unavailable") { + return OAuthAuthorizationError::TemporarilyUnavailable; + } else { + return std::nullopt; + } +} + +auto to_oauth_token_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthTokenError::InvalidRequest; + } else if (code == "invalid_client") { + return OAuthTokenError::InvalidClient; + } else if (code == "invalid_grant") { + return OAuthTokenError::InvalidGrant; + } else if (code == "unauthorized_client") { + return OAuthTokenError::UnauthorizedClient; + } else if (code == "unsupported_grant_type") { + return OAuthTokenError::UnsupportedGrantType; + } else if (code == "invalid_scope") { + return OAuthTokenError::InvalidScope; + } else if (code == "authorization_pending") { + return OAuthTokenError::AuthorizationPending; + } else if (code == "slow_down") { + return OAuthTokenError::SlowDown; + } else if (code == "access_denied") { + return OAuthTokenError::AccessDenied; + } else if (code == "expired_token") { + return OAuthTokenError::ExpiredToken; + } else if (code == "invalid_target") { + return OAuthTokenError::InvalidTarget; + } else if (code == "invalid_dpop_proof") { + return OAuthTokenError::InvalidDPoPProof; + } else if (code == "use_dpop_nonce") { + return OAuthTokenError::UseDPoPNonce; + } else if (code == "unsupported_token_type") { + return OAuthTokenError::UnsupportedTokenType; + } else { + return std::nullopt; + } +} + +auto to_oauth_bearer_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthBearerError::InvalidRequest; + } else if (code == "invalid_token") { + return OAuthBearerError::InvalidToken; + } else if (code == "insufficient_scope") { + return OAuthBearerError::InsufficientScope; + } else if (code == "invalid_dpop_proof") { + return OAuthBearerError::InvalidDPoPProof; + } else if (code == "use_dpop_nonce") { + return OAuthBearerError::UseDPoPNonce; + } else { + return std::nullopt; + } +} + +auto to_oauth_registration_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_redirect_uri") { + return OAuthRegistrationError::InvalidRedirectURI; + } else if (code == "invalid_client_metadata") { + return OAuthRegistrationError::InvalidClientMetadata; + } else if (code == "invalid_software_statement") { + return OAuthRegistrationError::InvalidSoftwareStatement; + } else if (code == "unapproved_software_statement") { + return OAuthRegistrationError::UnapprovedSoftwareStatement; + } else { + return std::nullopt; + } +} + +auto oauth_token_error_status(const OAuthTokenError error, + const bool authenticated_via_header) noexcept + -> HTTPStatus { + // RFC 6749 Section 5.2: "If the client attempted to authenticate via the + // 'Authorization' request header field, the authorization server MUST respond + // with an HTTP 401 (Unauthorized) status code and include the + // WWW-Authenticate response header field" + if (error == OAuthTokenError::InvalidClient && authenticated_via_header) { + return HTTP_STATUS_UNAUTHORIZED; + } + + return HTTP_STATUS_BAD_REQUEST; +} + +auto oauth_bearer_error_status(const OAuthBearerError error) noexcept + -> HTTPStatus { + // RFC 6750 Section 3.1 SHOULD-level status mapping, with the DPoP resource + // codes accompanying a 401 (RFC 9449 Section 7) + switch (error) { + case OAuthBearerError::InvalidRequest: + return HTTP_STATUS_BAD_REQUEST; + case OAuthBearerError::InvalidToken: + case OAuthBearerError::InvalidDPoPProof: + case OAuthBearerError::UseDPoPNonce: + return HTTP_STATUS_UNAUTHORIZED; + case OAuthBearerError::InsufficientScope: + return HTTP_STATUS_FORBIDDEN; + } + + std::unreachable(); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_introspection.cc b/vendor/core/src/core/oauth/oauth_introspection.cc new file mode 100644 index 000000000..285e8a9fc --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_introspection.cc @@ -0,0 +1,120 @@ +#include + +#include +#include + +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::chrono::seconds +#include // std::optional, std::nullopt +#include // std::string_view + +namespace sourcemeta::core { + +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)}; + +} // 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); + if (!token_type_hint.empty()) { + oauth_append_form_parameter(sink, "token_type_hint", token_type_hint); + } +} + +OAuthIntrospectionResponse::OAuthIntrospectionResponse(const JSON &data) + : data_{&data}, active_{[&data]() -> bool { + if (!data.is_object()) { + return false; + } + + // RFC 7662 Section 2.2: active is a REQUIRED boolean, so a missing or + // non-boolean value is treated as inactive rather than trusted + const auto *member{data.try_at("active"sv, HASH_ACTIVE)}; + return member != nullptr && member->is_boolean() && + member->to_boolean(); + }()} {} + +auto OAuthIntrospectionResponse::active() const noexcept -> bool { + return this->active_; +} + +auto OAuthIntrospectionResponse::scope() const + -> std::optional { + return oauth_json_string_member(*this->data_, "scope"sv, HASH_SCOPE); +} + +auto OAuthIntrospectionResponse::client_id() const + -> std::optional { + return oauth_json_string_member(*this->data_, "client_id"sv, HASH_CLIENT_ID); +} + +auto OAuthIntrospectionResponse::username() const + -> std::optional { + return oauth_json_string_member(*this->data_, "username"sv, HASH_USERNAME); +} + +auto OAuthIntrospectionResponse::token_type() const + -> std::optional { + return oauth_json_string_member(*this->data_, "token_type"sv, + HASH_TOKEN_TYPE); +} + +auto OAuthIntrospectionResponse::subject() const + -> std::optional { + return oauth_json_string_member(*this->data_, "sub"sv, HASH_SUB); +} + +auto OAuthIntrospectionResponse::issuer() const + -> std::optional { + return oauth_json_string_member(*this->data_, "iss"sv, HASH_ISS); +} + +auto OAuthIntrospectionResponse::jti() const + -> std::optional { + return oauth_json_string_member(*this->data_, "jti"sv, HASH_JTI); +} + +auto OAuthIntrospectionResponse::expiration() const + -> std::optional { + return oauth_json_seconds_member(*this->data_, "exp"sv, HASH_EXP); +} + +auto OAuthIntrospectionResponse::issued_at() const + -> std::optional { + return oauth_json_seconds_member(*this->data_, "iat"sv, HASH_IAT); +} + +auto OAuthIntrospectionResponse::not_before() const + -> std::optional { + return oauth_json_seconds_member(*this->data_, "nbf"sv, HASH_NBF); +} + +auto OAuthIntrospectionResponse::data() const -> const JSON & { + return *this->data_; +} + +auto oauth_make_introspection_inactive() -> JSON { + auto response{JSON::make_object()}; + response.assign_assume_new("active", JSON{false}, HASH_ACTIVE); + return response; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_json.h b/vendor/core/src/core/oauth/oauth_json.h new file mode 100644 index 000000000..d08f7b6bc --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_json.h @@ -0,0 +1,55 @@ +#ifndef SOURCEMETA_CORE_OAUTH_JSON_H_ +#define SOURCEMETA_CORE_OAUTH_JSON_H_ + +#include + +#include // std::chrono::seconds +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::string_view + +namespace sourcemeta::core { + +// Read a string member of a JSON object as a view into it, or no value when the +// object, the member, or its type is absent +inline auto oauth_json_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()}; +} + +// Read an integer member of a JSON object as a duration in seconds, rejecting a +// negative value as malformed, and one past the range of the duration so a +// bad lifetime or interval cannot flow to a caller as a usable or narrowed +// duration +inline auto oauth_json_seconds_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_integer() || member->to_integer() < 0 || + member->to_integer() > + std::numeric_limits::max()) { + return std::nullopt; + } + + return std::chrono::seconds{member->to_integer()}; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_metadata.cc b/vendor/core/src/core/oauth/oauth_metadata.cc new file mode 100644 index 000000000..ec1a9b87e --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_metadata.cc @@ -0,0 +1,588 @@ +#include + +#include +#include +#include + +#include "oauth_syntax.h" + +#include // std::ranges::find +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view +#include // std::move + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_ISSUER{JSON::Object::hash("issuer"sv)}; +const 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{ + JSON::Object::hash("registration_endpoint"sv)}; +const auto HASH_PAR_ENDPOINT{ + JSON::Object::hash("pushed_authorization_request_endpoint"sv)}; +const auto HASH_REQUIRE_PAR{ + JSON::Object::hash("require_pushed_authorization_requests"sv)}; +const auto HASH_REVOCATION_ENDPOINT{ + JSON::Object::hash("revocation_endpoint"sv)}; +const 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{ + 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{ + JSON::Object::hash("code_challenge_methods_supported"sv)}; +const auto HASH_TOKEN_AUTH_METHODS{ + JSON::Object::hash("token_endpoint_auth_methods_supported"sv)}; +const auto HASH_TOKEN_AUTH_ALGS{ + JSON::Object::hash("token_endpoint_auth_signing_alg_values_supported"sv)}; +const 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{ + JSON::Object::hash("authorization_servers"sv)}; +const 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{ + JSON::Object::hash("dpop_bound_access_tokens_required"sv)}; +const auto HASH_RESOURCE_SIGNING_ALGS{ + JSON::Object::hash("resource_signing_alg_values_supported"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()}; +} + +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()) { + throw OAuthMetadataParseError{}; + } + + const auto *issuer_member{data.try_at("issuer"sv, HASH_ISSUER)}; + if (issuer_member == nullptr || !issuer_member->is_string()) { + throw OAuthMetadataParseError{}; + } + + // RFC 8414 Section 3.3: the issuer in the document must be identical by code + // points to the one the document was retrieved for, the impersonation defense + const auto issuer_value{std::string_view{issuer_member->to_string()}}; + if (issuer_value != issuer || !oauth_is_issuer_identifier(issuer_value)) { + throw OAuthMetadataParseError{}; + } + + // RFC 8414 Section 2: response_types_supported is REQUIRED, and Section 3.2: + // "Claims with zero elements MUST be omitted from the response", so a + // present but empty array is a malformed document + const auto *response_types{ + data.try_at("response_types_supported"sv, HASH_RESPONSE_TYPES)}; + if (response_types == nullptr || !response_types->is_array() || + response_types->empty()) { + throw OAuthMetadataParseError{}; + } + + // 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")) { + const auto *algorithms{ + data.try_at("token_endpoint_auth_signing_alg_values_supported"sv, + HASH_TOKEN_AUTH_ALGS)}; + if (algorithms == nullptr || !algorithms->is_array() || + algorithms->empty() || algorithms->contains("none")) { + throw OAuthMetadataParseError{}; + } + } + + return std::move(data); +} + +auto validated_resource_metadata(JSON &&data, const std::string_view resource) + -> JSON { + if (!data.is_object()) { + throw OAuthMetadataParseError{}; + } + + const auto *resource_member{data.try_at("resource"sv, HASH_RESOURCE)}; + if (resource_member == nullptr || !resource_member->is_string()) { + throw OAuthMetadataParseError{}; + } + + // RFC 9728 Section 3.3: the resource in the document must be a valid resource + // identifier and identical by code points to the one the document was + // retrieved for, the impersonation defense + const auto resource_value{std::string_view{resource_member->to_string()}}; + if (resource_value != resource || + !oauth_is_resource_identifier(resource_value)) { + throw OAuthMetadataParseError{}; + } + + // RFC 9728 Section 2: "none" MUST NOT appear in the resource signing + // algorithms, and Section 3.2 forbids a zero-element array + const auto *algorithms{data.try_at("resource_signing_alg_values_supported"sv, + HASH_RESOURCE_SIGNING_ALGS)}; + if (algorithms != nullptr && + (!algorithms->is_array() || algorithms->empty() || + algorithms->contains("none"))) { + throw OAuthMetadataParseError{}; + } + + return std::move(data); +} + +} // namespace + +auto oauth_well_known_url(const std::string_view identifier, + const OAuthWellKnownKind kind, std::string &sink) + -> bool { + static constexpr std::string_view scheme{"https://"}; + // Reject a missing scheme or a fragment, then require a non-empty host so an + // authority such as ":443" with no host is not accepted (RFC 3986 + // Section 3.2) + if (!identifier.starts_with(scheme) || + identifier.find('#') != std::string_view::npos) { + return false; + } + + const auto parsed{oauth_try_parse_uri(identifier)}; + if (!parsed.has_value() || !parsed->host().has_value() || + parsed->host().value().empty()) { + return false; + } + + const bool protected_resource{kind == OAuthWellKnownKind::ProtectedResource}; + const auto query_position{identifier.find('?')}; + // RFC 8414 Section 2: an issuer carries no query, unlike a protected resource + if (query_position != std::string_view::npos && !protected_resource) { + return false; + } + + std::string_view suffix; + switch (kind) { + case OAuthWellKnownKind::AuthorizationServer: + suffix = "oauth-authorization-server"; + break; + case OAuthWellKnownKind::ProtectedResource: + suffix = "oauth-protected-resource"; + break; + case OAuthWellKnownKind::OpenIDConfigurationInserted: + case OAuthWellKnownKind::OpenIDConfigurationAppended: + suffix = "openid-configuration"; + break; + } + + static constexpr std::string_view infix{"/.well-known/"}; + if (kind == OAuthWellKnownKind::OpenIDConfigurationAppended) { + // RFC 8414 Section 5: the legacy OpenID Connect form appends the well-known + // string after the path rather than inserting it + auto base{identifier}; + if (base.ends_with('/')) { + base.remove_suffix(1); + } + + sink.reserve(sink.size() + base.size() + infix.size() + suffix.size()); + sink.append(base); + sink.append(infix); + sink.append(suffix); + return true; + } + + std::string_view query; + auto without_query{identifier}; + if (query_position != std::string_view::npos) { + query = identifier.substr(query_position); + without_query = identifier.substr(0, query_position); + } + + // RFC 8414 Section 3.1: the well-known string is inserted between the host + // and the path, with any terminating slash on the path removed first + auto authority{without_query}; + std::string_view path; + const auto path_position{without_query.find('/', scheme.size())}; + if (path_position != std::string_view::npos) { + authority = without_query.substr(0, path_position); + path = without_query.substr(path_position); + } + + if (path.ends_with('/')) { + path.remove_suffix(1); + } + + sink.reserve(sink.size() + authority.size() + infix.size() + suffix.size() + + path.size() + query.size()); + sink.append(authority); + sink.append(infix); + sink.append(suffix); + sink.append(path); + sink.append(query); + return true; +} + +OAuthServerMetadata::OAuthServerMetadata(JSON &&data, + const std::string_view issuer) + : data_{validated_server_metadata(std::move(data), issuer)} {} + +auto OAuthServerMetadata::from(JSON &&data, const std::string_view issuer) + -> std::optional { + try { + return OAuthServerMetadata{std::move(data), issuer}; + } catch (const OAuthMetadataParseError &) { + return std::nullopt; + } +} + +auto OAuthServerMetadata::issuer() const -> std::string_view { + return string_member(this->data_, "issuer"sv, HASH_ISSUER).value(); +} + +auto OAuthServerMetadata::authorization_endpoint() const + -> std::optional { + return string_member(this->data_, "authorization_endpoint"sv, + HASH_AUTHORIZATION_ENDPOINT); +} + +auto OAuthServerMetadata::token_endpoint() const + -> std::optional { + return string_member(this->data_, "token_endpoint"sv, HASH_TOKEN_ENDPOINT); +} + +auto OAuthServerMetadata::registration_endpoint() const + -> std::optional { + return string_member(this->data_, "registration_endpoint"sv, + HASH_REGISTRATION_ENDPOINT); +} + +auto OAuthServerMetadata::revocation_endpoint() const + -> std::optional { + return string_member(this->data_, "revocation_endpoint"sv, + HASH_REVOCATION_ENDPOINT); +} + +auto OAuthServerMetadata::introspection_endpoint() const + -> std::optional { + return string_member(this->data_, "introspection_endpoint"sv, + HASH_INTROSPECTION_ENDPOINT); +} + +auto OAuthServerMetadata::jwks_uri() const -> std::optional { + return string_member(this->data_, "jwks_uri"sv, HASH_JWKS_URI); +} + +auto OAuthServerMetadata::pushed_authorization_request_endpoint() const + -> std::optional { + return string_member(this->data_, "pushed_authorization_request_endpoint"sv, + HASH_PAR_ENDPOINT); +} + +auto OAuthServerMetadata::require_pushed_authorization_requests() const + -> bool { + if (!this->data_.is_object()) { + return false; + } + + const auto *member{this->data_.try_at( + "require_pushed_authorization_requests"sv, HASH_REQUIRE_PAR)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +auto OAuthServerMetadata::authorization_response_iss_parameter_supported() const + -> bool { + if (!this->data_.is_object()) { + return false; + } + + const auto *member{this->data_.try_at( + "authorization_response_iss_parameter_supported"sv, HASH_ISS_SUPPORTED)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +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); +} + +auto OAuthServerMetadata::supports_grant_type( + const std::string_view value) const -> bool { + const auto *member{ + this->data_.is_object() + ? this->data_.try_at("grant_types_supported"sv, HASH_GRANT_TYPES) + : nullptr}; + if (member == nullptr || !member->is_array()) { + // RFC 8414 Section 2: the default is the authorization code and implicit + // grants + return value == "authorization_code" || value == "implicit"; + } + + return member->contains(value); +} + +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); +} + +auto OAuthServerMetadata::supports_token_endpoint_auth_method( + const std::string_view value) const -> bool { + const auto *member{ + this->data_.is_object() + ? this->data_.try_at("token_endpoint_auth_methods_supported"sv, + HASH_TOKEN_AUTH_METHODS) + : nullptr}; + if (member == nullptr || !member->is_array()) { + // RFC 8414 Section 2: the default is client_secret_basic + return value == "client_secret_basic"; + } + + return member->contains(value); +} + +auto OAuthServerMetadata::data() const -> const JSON & { return this->data_; } + +OAuthResourceMetadata::OAuthResourceMetadata(JSON &&data, + const std::string_view resource) + : data_{validated_resource_metadata(std::move(data), resource)} {} + +auto OAuthResourceMetadata::from(JSON &&data, const std::string_view resource) + -> std::optional { + try { + return OAuthResourceMetadata{std::move(data), resource}; + } catch (const OAuthMetadataParseError &) { + return std::nullopt; + } +} + +auto OAuthResourceMetadata::resource() const -> std::string_view { + return string_member(this->data_, "resource"sv, HASH_RESOURCE).value(); +} + +auto OAuthResourceMetadata::first_authorization_server() const + -> std::optional { + if (!this->data_.is_object()) { + return std::nullopt; + } + + const auto *member{this->data_.try_at("authorization_servers"sv, + HASH_AUTHORIZATION_SERVERS)}; + if (member == nullptr || !member->is_array()) { + return std::nullopt; + } + + for (const auto &element : member->as_array()) { + if (element.is_string()) { + return std::string_view{element.to_string()}; + } + } + + return std::nullopt; +} + +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); +} + +auto OAuthResourceMetadata::jwks_uri() const + -> std::optional { + return string_member(this->data_, "jwks_uri"sv, HASH_JWKS_URI); +} + +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); +} + +auto OAuthResourceMetadata::supports_scope(const std::string_view value) const + -> bool { + return array_member_contains(this->data_, "scopes_supported"sv, + HASH_SCOPES_SUPPORTED, value); +} + +auto OAuthResourceMetadata::dpop_bound_access_tokens_required() const -> bool { + if (!this->data_.is_object()) { + return false; + } + + const auto *member{this->data_.try_at("dpop_bound_access_tokens_required"sv, + HASH_DPOP_BOUND_REQUIRED)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +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(); +} + +} // namespace + +auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) + -> std::optional { + // RFC 8414 Section 2: issuer and a non-empty response_types_supported are + // REQUIRED, and the issuer must be a valid issuer identifier + if (!oauth_is_issuer_identifier(config.issuer) || + config.response_types_supported.empty()) { + return std::nullopt; + } + + // RFC 8414 Section 2: the authorization endpoint is REQUIRED once a response + // type is advertised, and the token endpoint unless the only grant type is + // the implicit one, and every advertised URL is an https location. A present + // scalar that is not a valid https URL, or a missing required endpoint, would + // yield an unusable discovery document + const bool token_endpoint_needed{ + config.grant_types_supported.empty() || + std::ranges::any_of(config.grant_types_supported, + [](const std::string_view grant) -> bool { + return grant != "implicit"; + })}; + const bool token_endpoint_required_and_valid{ + token_endpoint_needed + ? oauth_is_resource_identifier(config.token_endpoint) + : config.token_endpoint.empty() || + oauth_is_resource_identifier(config.token_endpoint)}; + if (!oauth_is_resource_identifier(config.authorization_endpoint) || + !token_endpoint_required_and_valid || + (!config.registration_endpoint.empty() && + !oauth_is_resource_identifier(config.registration_endpoint)) || + (!config.pushed_authorization_request_endpoint.empty() && + !oauth_is_resource_identifier( + config.pushed_authorization_request_endpoint)) || + (!config.jwks_uri.empty() && + !oauth_is_resource_identifier(config.jwks_uri))) { + return std::nullopt; + } + + // RFC 9126 Section 5: requiring pushed authorization requests without + // advertising the endpoint to submit them to yields a document a client + // cannot comply with + if (config.require_pushed_authorization_requests && + config.pushed_authorization_request_endpoint.empty()) { + return std::nullopt; + } + + // RFC 8414 Section 2: "none" MUST NOT appear in the signing algorithm list + // unconditionally, the list is REQUIRED alongside the JWT authentication + // methods, and Section 3.2 forbids a zero-element array, so the document + // would otherwise be rejected by its own parser + if (span_contains(config.token_endpoint_auth_signing_alg_values_supported, + "none")) { + return std::nullopt; + } + + if ((span_contains(config.token_endpoint_auth_methods_supported, + "private_key_jwt") || + span_contains(config.token_endpoint_auth_methods_supported, + "client_secret_jwt")) && + config.token_endpoint_auth_signing_alg_values_supported.empty()) { + return std::nullopt; + } + + 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); + // 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) { + document.assign_assume_new("require_pushed_authorization_requests", + JSON{true}, HASH_REQUIRE_PAR); + } + + return document; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_metadata_provider.cc b/vendor/core/src/core/oauth/oauth_metadata_provider.cc new file mode 100644 index 000000000..fe8aea78c --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_metadata_provider.cc @@ -0,0 +1,209 @@ +#include + +#include +#include + +#include "oauth_ttl.h" + +#include // assert +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::make_shared, std::shared_ptr +#include // std::scoped_lock +#include // std::optional +#include // std::string +#include // std::move + +namespace sourcemeta::core { + +OAuthMetadataProvider::OAuthMetadataProvider(std::string issuer, + const OAuthWellKnownKind kind, + Fetcher fetcher) + : OAuthMetadataProvider{std::move(issuer), kind, std::move(fetcher), + Options{}} {} + +OAuthMetadataProvider::OAuthMetadataProvider(std::string issuer, + const OAuthWellKnownKind kind, + Fetcher fetcher, Options options) + : OAuthMetadataProvider{std::move(issuer), kind, std::move(fetcher), + options, Clock{&std::chrono::system_clock::now}} {} + +OAuthMetadataProvider::OAuthMetadataProvider(std::string issuer, + const OAuthWellKnownKind kind, + Fetcher fetcher, Options options, + Clock clock) + : issuer_{std::move(issuer)}, kind_{kind}, fetcher_{std::move(fetcher)}, + options_{options}, clock_{std::move(clock)} { + // A protected resource document is not authorization server metadata, so it + // would never validate and the provider would permanently return no value + assert(kind != OAuthWellKnownKind::ProtectedResource); +} + +auto OAuthMetadataProvider::install_locked( + const OAuthWellKnownKind kind, + const std::chrono::system_clock::time_point now) -> bool { + std::string url; + if (!oauth_well_known_url(this->issuer_, kind, url)) { + return false; + } + + std::optional fetched; + try { + fetched = this->fetcher_(url); + } catch (...) { + // A fetcher signals failure by returning no value, but a throwing transport + // is treated as just another failed retrieval, so a misbehaving fetcher can + // never escape a metadata lookup + return false; + } + + if (!fetched.has_value()) { + return false; + } + + auto document{try_parse_json(fetched.value().body)}; + if (!document.has_value()) { + return false; + } + + auto parsed{ + OAuthServerMetadata::from(std::move(document).value(), this->issuer_)}; + if (!parsed.has_value()) { + return false; + } + + this->metadata_ = + std::make_shared(std::move(parsed).value()); + this->next_refresh_ = now + oauth_clamp_ttl(fetched.value().max_age, + this->options_.fallback_ttl, + this->options_.minimum_ttl, + this->options_.maximum_ttl); + return true; +} + +auto OAuthMetadataProvider::fetch_and_install_locked( + const std::chrono::system_clock::time_point now) -> bool { + if (this->install_locked(this->kind_, now)) { + return true; + } + + // RFC 8414 Section 5: the inserted openid-configuration form is tried first + // and the legacy appended form only as a fallback + if (this->kind_ == OAuthWellKnownKind::OpenIDConfigurationInserted) { + return this->install_locked(OAuthWellKnownKind::OpenIDConfigurationAppended, + now); + } + + return false; +} + +auto OAuthMetadataProvider::metadata() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + // The refresh deadline starts at the epoch, so the first call always + // retrieves. Gating solely on it, rather than also on an absent document, + // means a failed retrieval backs off for the minimum lifetime instead of + // being retried on every call during an outage, whether or not a prior good + // document exists to serve meanwhile + if (now >= this->next_refresh_) { + if (!this->fetch_and_install_locked(now)) { + this->next_refresh_ = now + this->options_.minimum_ttl; + } + } + + return this->metadata_; +} + +auto OAuthMetadataProvider::refresh() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + this->fetch_and_install_locked(now); + return this->metadata_; +} + +OAuthResourceMetadataProvider::OAuthResourceMetadataProvider( + std::string resource, Fetcher fetcher) + : OAuthResourceMetadataProvider{std::move(resource), std::move(fetcher), + Options{}} {} + +OAuthResourceMetadataProvider::OAuthResourceMetadataProvider( + std::string resource, Fetcher fetcher, Options options) + : OAuthResourceMetadataProvider{std::move(resource), std::move(fetcher), + options, + Clock{&std::chrono::system_clock::now}} {} + +OAuthResourceMetadataProvider::OAuthResourceMetadataProvider( + std::string resource, Fetcher fetcher, Options options, Clock clock) + : resource_{std::move(resource)}, fetcher_{std::move(fetcher)}, + options_{options}, clock_{std::move(clock)} {} + +auto OAuthResourceMetadataProvider::fetch_and_install_locked( + const std::chrono::system_clock::time_point now) -> bool { + std::string url; + if (!oauth_well_known_url(this->resource_, + OAuthWellKnownKind::ProtectedResource, url)) { + return false; + } + + std::optional fetched; + try { + fetched = this->fetcher_(url); + } catch (...) { + // A fetcher signals failure by returning no value, but a throwing transport + // is treated as just another failed retrieval, so a misbehaving fetcher can + // never escape a metadata lookup + return false; + } + + if (!fetched.has_value()) { + return false; + } + + auto document{try_parse_json(fetched.value().body)}; + if (!document.has_value()) { + return false; + } + + auto parsed{OAuthResourceMetadata::from(std::move(document).value(), + this->resource_)}; + if (!parsed.has_value()) { + return false; + } + + this->metadata_ = + std::make_shared(std::move(parsed).value()); + this->next_refresh_ = now + oauth_clamp_ttl(fetched.value().max_age, + this->options_.fallback_ttl, + this->options_.minimum_ttl, + this->options_.maximum_ttl); + return true; +} + +auto OAuthResourceMetadataProvider::metadata() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + // The refresh deadline starts at the epoch, so the first call always + // retrieves. Gating solely on it, rather than also on an absent document, + // means a failed retrieval backs off for the minimum lifetime instead of + // being retried on every call during an outage, whether or not a prior good + // document exists to serve meanwhile + if (now >= this->next_refresh_) { + if (!this->fetch_and_install_locked(now)) { + this->next_refresh_ = now + this->options_.minimum_ttl; + } + } + + return this->metadata_; +} + +auto OAuthResourceMetadataProvider::refresh() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + this->fetch_and_install_locked(now); + return this->metadata_; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_par.cc b/vendor/core/src/core/oauth/oauth_par.cc new file mode 100644 index 000000000..3d9a4bd5a --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_par.cc @@ -0,0 +1,204 @@ +#include + +#include +#include +#include +#include +#include + +#include "oauth_authorization_parse.h" +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::chrono::seconds +#include // std::int64_t +#include // std::function +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +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)}; + +// RFC 9126 Section 2.2: the recommended request URI form, a URN whose final +// segment is the random reference +constexpr std::string_view REQUEST_URI_PREFIX{ + "urn:ietf:params:oauth:request_uri:"}; + +} // namespace + +auto oauth_build_par_request(const OAuthAuthorizationRequest &request, + SecureString &sink) -> void { + // 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", + 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); + } + + if (!request.scope.empty()) { + oauth_append_form_parameter(sink, "scope", request.scope); + } + + if (!request.state.empty()) { + oauth_append_form_parameter(sink, "state", request.state); + } + + if (!request.code_challenge.empty()) { + oauth_append_form_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", + request.code_challenge_method); + } + } + + if (!request.dpop_jkt.empty()) { + oauth_append_form_parameter(sink, "dpop_jkt", request.dpop_jkt); + } + + // RFC 9126 Section 2.1: a pushed request MUST NOT provide request_uri, so it + // is dropped even when a caller routes it through the repeatable or extension + // 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); + } + } + + for (const auto ¶meter : request.extra) { + if (parameter.name != "request_uri") { + oauth_append_form_parameter(sink, parameter.name, parameter.value); + } + } +} + +auto oauth_build_par_authorization_url(const std::string_view endpoint, + const std::string_view client_id, + const std::string_view request_uri, + std::string &sink) -> void { + // A lower bound over the endpoint, the two keys and separators, and the raw + // value lengths, over which percent-escaping only ever grows + sink.reserve(sink.size() + endpoint.size() + client_id.size() + + request_uri.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, and '&' continues + // one (RFC 6749 Section 3.1) + char separator{'?'}; + if (endpoint.find('?') != std::string_view::npos) { + separator = + (endpoint.empty() || endpoint.back() == '?' || endpoint.back() == '&') + ? '\0' + : '&'; + } + + if (separator != '\0') { + sink.push_back(separator); + } + + sink.append("client_id="); + URI::escape(client_id, sink); + sink.append("&request_uri="); + URI::escape(request_uri, sink); +} + +OAuthPARResponse::OAuthPARResponse(const JSON &data) : data_{&data} {} + +auto OAuthPARResponse::request_uri() const -> std::optional { + return oauth_json_string_member(*this->data_, "request_uri"sv, + HASH_REQUEST_URI); +} + +auto OAuthPARResponse::expires_in() const + -> std::optional { + // RFC 9126 Section 2.2: expires_in is a positive integer, so a zero or + // negative value is malformed and reported as absent + const auto value{ + oauth_json_seconds_member(*this->data_, "expires_in"sv, HASH_EXPIRES_IN)}; + return (value.has_value() && value.value() > std::chrono::seconds{0}) + ? value + : std::nullopt; +} + +auto OAuthPARResponse::data() const -> const JSON & { return *this->data_; } + +auto oauth_parse_par_request( + const std::string_view body, SecureString &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool { + // RFC 9126 Section 2.1: "The request_uri authorization request parameter is + // one exception, and it MUST NOT be provided", so the shared parse rejects it + // on presence, whatever its value, and the body decodes into wiping storage + // because it may carry a client secret + return oauth_parse_authorization_into(body, storage, result, on_other, true); +} + +auto oauth_par_request_uri() -> std::string { + const auto reference{oauth_random_token()}; + std::string result; + result.reserve(REQUEST_URI_PREFIX.size() + reference.size()); + result.append(REQUEST_URI_PREFIX); + result.append(reference.data(), reference.size()); + return result; +} + +auto oauth_make_par_response(const std::string_view request_uri, + const std::chrono::seconds expires_in) + -> std::optional { + // RFC 9126 Section 2.2: request_uri and a positive expires_in lifetime are + // REQUIRED, so a missing or non-positive one yields no document + if (request_uri.empty() || expires_in <= std::chrono::seconds{0}) { + return std::nullopt; + } + + auto response{JSON::make_object()}; + response.assign_assume_new("request_uri", JSON{request_uri}, + HASH_REQUEST_URI); + response.assign_assume_new( + "expires_in", JSON{static_cast(expires_in.count())}, + HASH_EXPIRES_IN); + return response; +} + +auto oauth_par_dpop_binding( + const std::string_view dpop_jkt, + const std::optional proof_thumbprint) + -> std::optional { + const bool has_jkt{!dpop_jkt.empty()}; + const bool has_proof{proof_thumbprint.has_value() && + !proof_thumbprint.value().empty()}; + if (has_jkt && has_proof) { + // RFC 9449 Section 10.1: with both mechanisms the request MUST be rejected + // when the thumbprint in dpop_jkt does not match the DPoP header key + if (!secure_equals(dpop_jkt, proof_thumbprint.value())) { + return std::nullopt; + } + + return dpop_jkt; + } + + if (has_jkt) { + return dpop_jkt; + } + + if (has_proof) { + return proof_thumbprint; + } + + return std::string_view{}; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_pkce.cc b/vendor/core/src/core/oauth/oauth_pkce.cc new file mode 100644 index 000000000..68b049e1b --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_pkce.cc @@ -0,0 +1,126 @@ +#include + +#include + +#include "oauth_syntax.h" + +#include // std::ranges::copy +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oauth_pkce_method_code(const OAuthPKCEMethod method) noexcept + -> std::string_view { + switch (method) { + case OAuthPKCEMethod::S256: + return "S256"; + case OAuthPKCEMethod::Plain: + return "plain"; + } + + std::unreachable(); +} + +auto to_oauth_pkce_method(const std::string_view value) noexcept + -> std::optional { + if (value == "S256") { + return OAuthPKCEMethod::S256; + } else if (value == "plain") { + return OAuthPKCEMethod::Plain; + } else { + return std::nullopt; + } +} + +auto oauth_pkce_verifier() -> std::array { + // The verifier is the base64url of 32 random octets, so both the entropy and + // the encoded form are secret. The scope guard wipes the entropy on every + // exit, including an exception while encoding. The caller owns the returned + // value and is responsible for wiping it in turn + std::string entropy(32, '\x00'); + const SecureStringScope entropy_scope{entropy}; + random_bytes( + std::span{reinterpret_cast(entropy.data()), + entropy.size()}); // NOLINT + SecureString encoded; + base64url_encode(entropy, encoded); + + std::array result{}; + std::ranges::copy(std::string_view{encoded}, result.begin()); + return result; +} + +auto oauth_pkce_challenge(const std::string_view verifier) + -> std::array { + const auto digest{sha256_digest(verifier)}; + const auto encoded{base64url_encode( + std::string_view{reinterpret_cast(digest.data()), // NOLINT + digest.size()})}; + + std::array result{}; + std::ranges::copy(encoded, result.begin()); + return result; +} + +auto oauth_pkce_verify(const std::string_view verifier, + const std::string_view challenge, + const OAuthPKCEMethod method, const OAuthProfile profile) + -> OAuthPKCEOutcome { + if (challenge.empty()) { + // RFC 9700 Section 2.1.1: a presented verifier with no bound challenge is + // rejected, since it signals a possible authorization code injection + return verifier.empty() ? OAuthPKCEOutcome::NotUsed + : OAuthPKCEOutcome::MissingChallenge; + } + + // OAuth 2.1 Section 4.1.3: iff the authorization request carried a challenge, + // the token request must carry a verifier + if (verifier.empty()) { + return OAuthPKCEOutcome::MissingVerifier; + } + + if (!oauth_is_pkce_verifier(verifier)) { + return OAuthPKCEOutcome::MalformedVerifier; + } + + switch (method) { + case OAuthPKCEMethod::Plain: + // RFC 9700 Section 2.1.1 hardening: the plain method is rejected under + // the strict profile + if (profile == OAuthProfile::Strict) { + return OAuthPKCEOutcome::MethodNotAllowed; + } + + if (!oauth_is_pkce_challenge(challenge)) { + return OAuthPKCEOutcome::MalformedChallenge; + } + + // RFC 7636 Section 4.6: the plain method compares the two values directly + return secure_equals(verifier, challenge) ? OAuthPKCEOutcome::Match + : OAuthPKCEOutcome::Mismatch; + case OAuthPKCEMethod::S256: { + // RFC 7636 Section 4.2: an S256 challenge is the base64url of a SHA-256 + // digest, so it is always exactly 43 characters + if (challenge.size() != 43 || !oauth_is_pkce_challenge(challenge)) { + return OAuthPKCEOutcome::MalformedChallenge; + } + + const auto computed{oauth_pkce_challenge(verifier)}; + return secure_equals(std::string_view{computed.data(), computed.size()}, + challenge) + ? OAuthPKCEOutcome::Match + : OAuthPKCEOutcome::Mismatch; + } + } + + std::unreachable(); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_random.cc b/vendor/core/src/core/oauth/oauth_random.cc new file mode 100644 index 000000000..dcc328712 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_random.cc @@ -0,0 +1,26 @@ +#include + +#include + +#include // std::ranges::copy +#include // std::array +#include // std::uint8_t +#include // std::string_view + +namespace sourcemeta::core { + +auto oauth_random_token() -> std::array { + // The token is not secret, so unlike the PKCE verifier the entropy needs no + // wiping storage, matching the state and nonce classification of the design + std::array entropy{}; + random_bytes(entropy); + const auto encoded{base64url_encode( + std::string_view{reinterpret_cast(entropy.data()), // NOLINT + entropy.size()})}; + + std::array result{}; + std::ranges::copy(encoded, result.begin()); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_registration.cc b/vendor/core/src/core/oauth/oauth_registration.cc new file mode 100644 index 000000000..18f4061c6 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_registration.cc @@ -0,0 +1,565 @@ +#include + +#include +#include + +#include "oauth_json.h" +#include "oauth_syntax.h" + +#include // std::chrono::seconds +#include // std::int64_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view +#include // std::move + +namespace sourcemeta::core { + +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{ + 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{ + JSON::Object::hash("client_id_issued_at"sv)}; +const auto HASH_CLIENT_SECRET_EXPIRES_AT{ + JSON::Object::hash("client_secret_expires_at"sv)}; +const auto HASH_REGISTRATION_ACCESS_TOKEN{ + JSON::Object::hash("registration_access_token"sv)}; +const 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); +} + +auto array_member_is_present(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_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) +auto array_member_contains_or_default(const JSON &data, + const JSON::StringView name, + const JSON::Object::hash_type hash, + const std::string_view value, + const std::string_view fallback) -> bool { + if (!data.is_object()) { + return value == fallback; + } + + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_array()) { + return value == fallback; + } + + return member->contains(value); +} + +auto validated_client_metadata(JSON &&data) -> JSON { + if (!data.is_object()) { + throw OAuthRegistrationParseError{}; + } + + // RFC 7591 Section 2: "The 'jwks_uri' and 'jwks' parameters MUST NOT both be + // present in the same request or response" + if (data.defines("jwks_uri"sv, HASH_JWKS_URI) && + data.defines("jwks"sv, HASH_JWKS)) { + throw OAuthRegistrationParseError{}; + } + + // RFC 7591 Section 2: the grant and response types are arrays of strings and + // the authentication method is a string. Each has a default accessor, so a + // member present with the wrong type, down to a non-string array element, is + // rejected here rather than silently read as its default or as fewer values + // than were registered + const auto *grant_types{data.try_at("grant_types"sv, HASH_GRANT_TYPES)}; + const auto *response_types{ + 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)) || + (auth_method != nullptr && !auth_method->is_string())) { + throw OAuthRegistrationParseError{}; + } + + 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 +// Section 3.2.1) +auto is_jwt_structural_claim(const std::string_view name) -> bool { + return name == "iss"sv || name == "sub"sv || name == "aud"sv || + name == "exp"sv || name == "nbf"sv || name == "iat"sv || + name == "jti"sv; +} + +} // namespace + +OAuthClientMetadata::OAuthClientMetadata(JSON &&data) + : data_{validated_client_metadata(std::move(data))} {} + +auto OAuthClientMetadata::from(JSON &&data) + -> std::optional { + try { + return OAuthClientMetadata{std::move(data)}; + } catch (const OAuthRegistrationParseError &) { + return std::nullopt; + } +} + +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); +} + +auto OAuthClientMetadata::token_endpoint_auth_method() const + -> std::string_view { + const auto method{oauth_json_string_member(this->data_, + "token_endpoint_auth_method"sv, + HASH_TOKEN_ENDPOINT_AUTH_METHOD)}; + // RFC 7591 Section 2: "If unspecified or omitted, the default is + // 'client_secret_basic'" + return method.value_or("client_secret_basic"sv); +} + +auto OAuthClientMetadata::supports_grant_type( + const std::string_view value) const -> bool { + // RFC 7591 Section 2: "If omitted, the default behavior is that the client + // will use only the 'authorization_code' Grant Type" + return array_member_contains_or_default(this->data_, "grant_types"sv, + HASH_GRANT_TYPES, value, + "authorization_code"sv); +} + +auto OAuthClientMetadata::supports_response_type( + const std::string_view value) const -> bool { + // RFC 7591 Section 2: "If omitted, the default is that the client will use + // only the 'code' response type" + return array_member_contains_or_default(this->data_, "response_types"sv, + HASH_RESPONSE_TYPES, value, "code"sv); +} + +auto OAuthClientMetadata::client_name() const + -> std::optional { + return oauth_json_string_member(this->data_, "client_name"sv, + HASH_CLIENT_NAME); +} + +auto OAuthClientMetadata::client_uri() const + -> std::optional { + return oauth_json_string_member(this->data_, "client_uri"sv, HASH_CLIENT_URI); +} + +auto OAuthClientMetadata::logo_uri() const -> std::optional { + return oauth_json_string_member(this->data_, "logo_uri"sv, HASH_LOGO_URI); +} + +auto OAuthClientMetadata::scope() const -> std::optional { + return oauth_json_string_member(this->data_, "scope"sv, HASH_SCOPE); +} + +auto OAuthClientMetadata::has_contact(const std::string_view value) const + -> bool { + return array_member_contains(this->data_, "contacts"sv, HASH_CONTACTS, value); +} + +auto OAuthClientMetadata::tos_uri() const -> std::optional { + return oauth_json_string_member(this->data_, "tos_uri"sv, HASH_TOS_URI); +} + +auto OAuthClientMetadata::policy_uri() const + -> std::optional { + return oauth_json_string_member(this->data_, "policy_uri"sv, HASH_POLICY_URI); +} + +auto OAuthClientMetadata::jwks_uri() const -> std::optional { + return oauth_json_string_member(this->data_, "jwks_uri"sv, HASH_JWKS_URI); +} + +auto OAuthClientMetadata::software_id() const + -> std::optional { + return oauth_json_string_member(this->data_, "software_id"sv, + HASH_SOFTWARE_ID); +} + +auto OAuthClientMetadata::software_version() const + -> std::optional { + return oauth_json_string_member(this->data_, "software_version"sv, + HASH_SOFTWARE_VERSION); +} + +auto OAuthClientMetadata::software_statement() const + -> std::optional { + return oauth_json_string_member(this->data_, "software_statement"sv, + HASH_SOFTWARE_STATEMENT); +} + +auto OAuthClientMetadata::client_id() const -> std::optional { + return oauth_json_string_member(this->data_, "client_id"sv, HASH_CLIENT_ID); +} + +auto OAuthClientMetadata::client_secret() const + -> std::optional { + return oauth_json_string_member(this->data_, "client_secret"sv, + HASH_CLIENT_SECRET); +} + +auto OAuthClientMetadata::client_id_issued_at() const + -> std::optional { + return oauth_json_seconds_member(this->data_, "client_id_issued_at"sv, + HASH_CLIENT_ID_ISSUED_AT); +} + +auto OAuthClientMetadata::client_secret_expires_at() const + -> std::optional { + return oauth_json_seconds_member(this->data_, "client_secret_expires_at"sv, + HASH_CLIENT_SECRET_EXPIRES_AT); +} + +auto OAuthClientMetadata::registration_access_token() const + -> std::optional { + return oauth_json_string_member(this->data_, "registration_access_token"sv, + HASH_REGISTRATION_ACCESS_TOKEN); +} + +auto OAuthClientMetadata::registration_client_uri() const + -> std::optional { + return oauth_json_string_member(this->data_, "registration_client_uri"sv, + HASH_REGISTRATION_CLIENT_URI); +} + +auto OAuthClientMetadata::data() const -> const JSON & { return this->data_; } + +auto oauth_make_registration_request( + const OAuthClientRegistrationConfig &config) -> std::optional { + // RFC 7591 Section 2: the client information and JWK Set locations MUST point + // to valid URIs, so a malformed one yields a request the server would reject + if ((!config.client_uri.empty() && + !oauth_try_parse_uri(config.client_uri).has_value()) || + (!config.logo_uri.empty() && + !oauth_try_parse_uri(config.logo_uri).has_value()) || + (!config.tos_uri.empty() && + !oauth_try_parse_uri(config.tos_uri).has_value()) || + (!config.policy_uri.empty() && + !oauth_try_parse_uri(config.policy_uri).has_value()) || + (!config.jwks_uri.empty() && + !oauth_try_parse_uri(config.jwks_uri).has_value())) { + return std::nullopt; + } + + // RFC 6749 Section 3.1.2: a redirection URI MUST be an absolute URI and MUST + // NOT include a fragment, so a malformed or fragment-bearing one yields a + // request the server would reject + for (const auto redirect_uri : config.redirect_uris) { + const auto parsed{oauth_try_parse_uri(redirect_uri)}; + if (!parsed.has_value() || parsed.value().fragment().has_value()) { + return std::nullopt; + } + } + + // RFC 7591 Section 2: the JWK Set is a JSON object, and it "MUST NOT" be + // present together with the JWK Set location + if (config.jwks != nullptr && + (!config.jwks->is_object() || !config.jwks_uri.empty())) { + return std::nullopt; + } + + auto document{JSON::make_object()}; + assign_array_member(document, "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); + + return document; +} + +auto oauth_make_registration_error_response( + const std::string_view error, const std::string_view error_description) + -> JSON { + auto response{JSON::make_object()}; + // RFC 7591 Section 3.2.2: error is REQUIRED and error_description is OPTIONAL + response.assign_assume_new("error", JSON{error}, HASH_ERROR); + if (!error_description.empty()) { + response.assign_assume_new("error_description", JSON{error_description}, + HASH_ERROR_DESCRIPTION); + } + + return response; +} + +auto oauth_registration_grant_response_consistent( + const OAuthClientMetadata &metadata) -> bool { + const auto &data{metadata.data()}; + const bool grant_types_present{ + data.is_object() && + array_member_is_present(data, "grant_types"sv, HASH_GRANT_TYPES)}; + 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)}; + + // 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 + // the explicitly registered lists are compared, so an omitted list, which + // the server fills with a default, registers no value that could contradict + // the other and a grant-only client that omits the response types stays + // consistent + if (response_types_present && + ((grants_authorization_code && !responds_code) || + (grants_implicit && !responds_token))) { + return false; + } + + if (grant_types_present && ((responds_code && !grants_authorization_code) || + (responds_token && !grants_implicit))) { + return false; + } + + return true; +} + +auto oauth_apply_software_statement_claims(JSON &metadata, const JSON &claims) + -> bool { + if (!metadata.is_object() || !claims.is_object()) { + return false; + } + + for (const auto &entry : claims.as_object()) { + // RFC 7591 Section 3.2.1: the statement is echoed unmodified, so a claim + // that happens to be named for it does not overwrite the echoed value + if (is_jwt_structural_claim(entry.first) || + entry.first == "software_statement"sv) { + continue; + } + + // RFC 7591 Section 3.1.1: a client metadata value conveyed in a trusted + // software statement takes precedence over the plain JSON value of the + // same name, so it overwrites rather than augments the record + metadata.assign(entry.first, entry.second); + } + + return true; +} + +auto oauth_make_registration_response( + const JSON &metadata, const OAuthClientRegistrationResult &result) + -> std::optional { + // RFC 7591 Section 3.2.1: the client identifier is REQUIRED, and the secret + // and its expiry are returned together, so one without the other yields a + // response this module's own reader would misread + if (!metadata.is_object() || result.client_id.empty() || + (result.client_secret.empty() != + !result.client_secret_expires_at.has_value())) { + return std::nullopt; + } + + // RFC 7591 Section 3.2.1: the issue and expiry times are a number of seconds + // since the epoch, so a negative value is not representable + if ((result.client_id_issued_at.has_value() && + result.client_id_issued_at.value() < std::chrono::seconds::zero()) || + (result.client_secret_expires_at.has_value() && + result.client_secret_expires_at.value() < + std::chrono::seconds::zero())) { + return std::nullopt; + } + + // RFC 7592 Section 3: the registration access token and its management + // location are both REQUIRED in a management response, so one without the + // other is not a well-formed response + if (result.registration_access_token.empty() != + result.registration_client_uri.empty()) { + return std::nullopt; + } + + // RFC 7592 Section 3: the registration management location is a fully + // qualified URL + if (!result.registration_client_uri.empty() && + !oauth_try_parse_uri(result.registration_client_uri).has_value()) { + return std::nullopt; + } + + auto response{metadata}; + // RFC 7591 Section 3.2.1: these members are provisioned by the server, so any + // value the accepted record carried for them is dropped and only the assigned + // value is returned, leaving no client-supplied value to survive + response.erase("client_id"sv); + response.erase("client_secret"sv); + response.erase("client_id_issued_at"sv); + response.erase("client_secret_expires_at"sv); + response.erase("registration_access_token"sv); + response.erase("registration_client_uri"sv); + + response.assign_assume_new(std::string{"client_id"}, JSON{result.client_id}, + HASH_CLIENT_ID); + if (!result.client_secret.empty()) { + response.assign_assume_new(std::string{"client_secret"}, + JSON{result.client_secret}, HASH_CLIENT_SECRET); + } + + if (result.client_id_issued_at.has_value()) { + response.assign_assume_new(std::string{"client_id_issued_at"}, + JSON{static_cast( + result.client_id_issued_at.value().count())}, + HASH_CLIENT_ID_ISSUED_AT); + } + + if (result.client_secret_expires_at.has_value()) { + response.assign_assume_new( + std::string{"client_secret_expires_at"}, + JSON{static_cast( + result.client_secret_expires_at.value().count())}, + HASH_CLIENT_SECRET_EXPIRES_AT); + } + + if (!result.registration_access_token.empty()) { + response.assign_assume_new(std::string{"registration_access_token"}, + JSON{result.registration_access_token}, + HASH_REGISTRATION_ACCESS_TOKEN); + } + + if (!result.registration_client_uri.empty()) { + response.assign_assume_new(std::string{"registration_client_uri"}, + JSON{result.registration_client_uri}, + HASH_REGISTRATION_CLIENT_URI); + } + + return response; +} + +auto oauth_make_registration_update_request( + const OAuthClientRegistrationConfig &config, + const std::string_view client_id, const std::string_view client_secret) + -> std::optional { + // RFC 7592 Section 2.2: the current client identifier MUST be present + if (client_id.empty()) { + return std::nullopt; + } + + auto document{oauth_make_registration_request(config)}; + if (!document.has_value()) { + return std::nullopt; + } + + // The registration request builder never writes these members, so they are + // known-new on the freshly built object and their precomputed hashes apply + document.value().assign_assume_new(std::string{"client_id"}, JSON{client_id}, + HASH_CLIENT_ID); + // RFC 7592 Section 2.2: the client MAY include its current secret to be + // matched against, but MUST NOT choose a new one, so an empty value is + // omitted rather than sent as a chosen secret + if (!client_secret.empty()) { + document.value().assign_assume_new(std::string{"client_secret"}, + JSON{client_secret}, HASH_CLIENT_SECRET); + } + + return document; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_revocation.cc b/vendor/core/src/core/oauth/oauth_revocation.cc new file mode 100644 index 000000000..c84157564 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_revocation.cc @@ -0,0 +1,102 @@ +#include + +#include +#include +#include + +#include "oauth_decode.h" +#include "oauth_encode.h" + +#include // std::function +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +auto assign_lookup_scalar(const std::string_view value, SecureString &storage, + bool &seen, std::string_view &field) -> bool { + // RFC 6749 Section 3.2: an empty value is treated as omitted, and a + // recognized parameter must not appear twice + if (value.empty()) { + return true; + } + + if (seen) { + return false; + } + + seen = true; + return oauth_form_decode_into_secure(value, storage, field); +} + +} // namespace + +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); + if (!token_type_hint.empty()) { + oauth_append_form_parameter(sink, "token_type_hint", token_type_hint); + } +} + +auto oauth_parse_revocation_request( + const std::string_view body, SecureString &storage, + OAuthTokenLookupRequest &result, + const std::function &on_other) + -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1) + storage.reserve(storage.size() + body.size()); + const URI::Query parsed{body}; + bool has_token{false}; + bool has_token_type_hint{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_form_decode_into_secure(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "token") { + valid = assign_lookup_scalar(value, storage, has_token, result.token); + } else if (name == "token_type_hint") { + valid = assign_lookup_scalar(value, storage, has_token_type_hint, + result.token_type_hint); + } else { + std::string_view decoded; + valid = oauth_form_decode_into_secure(value, storage, decoded); + if (valid) { + on_other(name, decoded); + } + } + + if (!valid) { + return false; + } + } + + // RFC 7009 Section 2.1: the token is REQUIRED + return has_token; +} + +auto oauth_revocation_outcome(const HTTPStatus status) noexcept + -> OAuthRevocationOutcome { + if (status.code == HTTP_STATUS_OK.code) { + return OAuthRevocationOutcome::Success; + } + + if (status.code == HTTP_STATUS_SERVICE_UNAVAILABLE.code) { + return OAuthRevocationOutcome::Retry; + } + + return OAuthRevocationOutcome::Error; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_syntax.h b/vendor/core/src/core/oauth/oauth_syntax.h new file mode 100644 index 000000000..27cb15cc2 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_syntax.h @@ -0,0 +1,77 @@ +#ifndef SOURCEMETA_CORE_OAUTH_SYNTAX_H_ +#define SOURCEMETA_CORE_OAUTH_SYNTAX_H_ + +#include +#include + +#include // std::ranges::all_of +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +// URI::is_uri validates the RFC 3986 grammar without constructing, but leaves +// the port unbounded while construction rejects a port above 32 bits, so the +// construction can still throw and is guarded rather than assumed to succeed +inline auto oauth_try_parse_uri(const std::string_view value) + -> std::optional { + if (!URI::is_uri(value)) { + return std::nullopt; + } + + try { + return URI{value}; + } catch (const URIParseError &) { + return std::nullopt; + } +} + +// RFC 8414 Section 2: an issuer is an https URL with a non-empty host (RFC 3986 +// Section 3.2) and no query or fragment, its scheme matched by code points to +// reject a non-canonical case +inline auto oauth_is_issuer_identifier(const std::string_view value) -> bool { + const auto uri{oauth_try_parse_uri(value)}; + return uri.has_value() && uri->scheme().has_value() && + uri->scheme().value() == "https" && uri->host().has_value() && + !uri->host().value().empty() && !uri->query().has_value() && + !uri->fragment().has_value(); +} + +// RFC 9728 Section 1.2 and RFC 8707 Section 2: a resource is an https URL with +// a non-empty host and no fragment, a query tolerated unlike an issuer +inline auto oauth_is_resource_identifier(const std::string_view value) -> bool { + const auto uri{oauth_try_parse_uri(value)}; + return uri.has_value() && uri->scheme().has_value() && + uri->scheme().value() == "https" && uri->host().has_value() && + !uri->host().value().empty() && !uri->fragment().has_value(); +} + +// RFC 3986 Section 2.3: "unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"", +// the character set RFC 7636 reuses for the PKCE verifier and challenge +inline auto oauth_is_unreserved(const char character) noexcept -> bool { + return is_alphanum(character) || character == '-' || character == '.' || + character == '_' || character == '~'; +} + +inline auto oauth_is_unreserved_string(const std::string_view value) noexcept + -> bool { + return std::ranges::all_of(value, oauth_is_unreserved); +} + +// RFC 7636 Section 4.1: "code-verifier = 43*128unreserved" +inline auto oauth_is_pkce_verifier(const std::string_view value) noexcept + -> bool { + return value.size() >= 43 && value.size() <= 128 && + oauth_is_unreserved_string(value); +} + +// RFC 7636 Section 4.2: "code-challenge = 43*128unreserved" +inline auto oauth_is_pkce_challenge(const std::string_view value) noexcept + -> bool { + return value.size() >= 43 && value.size() <= 128 && + oauth_is_unreserved_string(value); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_token.cc b/vendor/core/src/core/oauth/oauth_token.cc new file mode 100644 index 000000000..6f2fc2dfb --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_token.cc @@ -0,0 +1,313 @@ +#include + +#include +#include +#include + +#include "oauth_decode.h" +#include "oauth_encode.h" + +#include // std::chrono::seconds +#include // std::size_t +#include // std::int64_t +#include // std::function +#include // std::optional, std::nullopt +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +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)}; + +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()}; +} + +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); + } +} + +auto assign_token_scalar(const std::string_view value, SecureString &storage, + bool &seen, std::string_view &field) -> bool { + // RFC 6749 Section 3.2: "Parameters sent without a value MUST be treated as + // if they were omitted", so an empty occurrence neither counts as a duplicate + // nor marks the parameter present + if (value.empty()) { + return true; + } + + // RFC 6749 Section 3.2: a recognized parameter must not appear twice at the + // token endpoint + if (seen) { + return false; + } + + seen = true; + return oauth_form_decode_into_secure(value, storage, field); +} + +} // namespace + +auto oauth_build_token_request_code( + const std::string_view code, const std::string_view redirect_uri, + 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); + if (!redirect_uri.empty()) { + oauth_append_form_parameter(sink, "redirect_uri", redirect_uri); + } + + if (!code_verifier.empty()) { + oauth_append_form_parameter(sink, "code_verifier", code_verifier); + } + + oauth_append_resources(sink, resources); +} + +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); + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } + + oauth_append_resources(sink, resources); +} + +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"); + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } + + oauth_append_resources(sink, resources); +} + +OAuthTokenResponse::OAuthTokenResponse(const JSON &data) : data_{&data} {} + +auto OAuthTokenResponse::access_token() const + -> std::optional { + return string_member(*this->data_, "access_token"sv, HASH_ACCESS_TOKEN); +} + +auto OAuthTokenResponse::token_type() const -> std::optional { + return string_member(*this->data_, "token_type"sv, HASH_TOKEN_TYPE); +} + +auto OAuthTokenResponse::is_bearer_token_type() const -> bool { + const auto type{this->token_type()}; + // RFC 6749 Section 5.1: the token type value is compared case insensitively + return type.has_value() && equals_ignore_case(type.value(), "Bearer"); +} + +auto OAuthTokenResponse::expires_in() const + -> std::optional { + if (!this->data_->is_object()) { + return std::nullopt; + } + + const auto *member{this->data_->try_at("expires_in"sv, HASH_EXPIRES_IN)}; + if (member == nullptr || !member->is_integer()) { + return std::nullopt; + } + + const auto value{member->to_integer()}; + if (value < 0) { + return std::nullopt; + } + + return std::chrono::seconds{value}; +} + +auto OAuthTokenResponse::refresh_token() const + -> std::optional { + return string_member(*this->data_, "refresh_token"sv, HASH_REFRESH_TOKEN); +} + +auto OAuthTokenResponse::scope() const -> std::optional { + return string_member(*this->data_, "scope"sv, HASH_SCOPE); +} + +auto OAuthTokenResponse::has_scope(const std::string_view value) const -> bool { + const auto granted{this->scope()}; + if (!granted.has_value() || value.empty()) { + return false; + } + + // RFC 6749 Section 3.3: scope is a space-delimited, unordered set of tokens, + // scanned without allocation + const auto text{granted.value()}; + std::size_t position{0}; + while (position < text.size()) { + const auto next{text.find(' ', position)}; + const auto token{text.substr(position, next == std::string_view::npos + ? std::string_view::npos + : next - position)}; + if (token == value) { + return true; + } + + if (next == std::string_view::npos) { + break; + } + + position = next + 1; + } + + return false; +} + +auto OAuthTokenResponse::data() const -> const JSON & { return *this->data_; } + +auto oauth_parse_token_request( + const std::string_view body, SecureString &storage, + OAuthTokenRequest &result, + const std::function &on_other) + -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1). The + // token endpoint body carries the request's secrets, the authorization code, + // the PKCE verifier, the refresh token, and the client authentication + // parameters, so the arena is a wiping string (engineering invariant 1) + storage.reserve(storage.size() + body.size()); + const URI::Query parsed{body}; + bool has_grant_type{false}; + bool has_code{false}; + bool has_redirect_uri{false}; + bool has_code_verifier{false}; + bool has_refresh_token{false}; + bool has_scope{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_form_decode_into_secure(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "grant_type") { + valid = assign_token_scalar(value, storage, has_grant_type, + result.grant_type); + } else if (name == "code") { + valid = assign_token_scalar(value, storage, has_code, result.code); + } else if (name == "redirect_uri") { + valid = assign_token_scalar(value, storage, has_redirect_uri, + result.redirect_uri); + } else if (name == "code_verifier") { + valid = assign_token_scalar(value, storage, has_code_verifier, + result.code_verifier); + } else if (name == "refresh_token") { + valid = assign_token_scalar(value, storage, has_refresh_token, + result.refresh_token); + } else if (name == "scope") { + valid = assign_token_scalar(value, storage, has_scope, result.scope); + } else { + // Repeatable resource and audience indicators, and every other parameter + // including the client authentication ones, are surfaced with their + // decoded value rather than stored on the result + std::string_view decoded; + valid = oauth_form_decode_into_secure(value, storage, decoded); + if (valid) { + on_other(name, decoded); + } + } + + if (!valid) { + return false; + } + } + + return true; +} + +auto oauth_make_token_response(const OAuthTokenGrant &grant) -> JSON { + auto response{JSON::make_object()}; + // The keys are unique and their hashes are already computed, so the response + // is assembled without a per-key hash or duplicate scan + // RFC 6749 Section 5.1: access_token and token_type are REQUIRED + response.assign_assume_new("access_token", JSON{grant.access_token}, + HASH_ACCESS_TOKEN); + response.assign_assume_new("token_type", JSON{grant.token_type}, + HASH_TOKEN_TYPE); + // RFC 6749 Section 5.1: the lifetime is a number of seconds, so a negative + // duration is not representable and is omitted rather than emitted as a value + // this module's own response reader would reject + if (grant.expires_in.has_value() && + grant.expires_in.value() >= std::chrono::seconds{0}) { + response.assign_assume_new( + "expires_in", + JSON{static_cast(grant.expires_in.value().count())}, + HASH_EXPIRES_IN); + } + + if (!grant.refresh_token.empty()) { + response.assign_assume_new("refresh_token", JSON{grant.refresh_token}, + HASH_REFRESH_TOKEN); + } + + // RFC 6749 Section 5.1: the scope is OPTIONAL when identical to the requested + // scope and REQUIRED otherwise, so it is emitted only when it differs + if (!grant.scope.empty() && grant.scope != grant.requested_scope) { + response.assign_assume_new("scope", JSON{grant.scope}, HASH_SCOPE); + } + + return response; +} + +auto oauth_make_token_error_response(const std::string_view error, + const std::string_view error_description, + const std::string_view error_uri) -> JSON { + auto response{JSON::make_object()}; + // RFC 6749 Section 5.2: error is REQUIRED + response.assign_assume_new("error", JSON{error}, HASH_ERROR); + if (!error_description.empty()) { + response.assign_assume_new("error_description", JSON{error_description}, + HASH_ERROR_DESCRIPTION); + } + + if (!error_uri.empty()) { + response.assign_assume_new("error_uri", JSON{error_uri}, HASH_ERROR_URI); + } + + return response; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_token_exchange.cc b/vendor/core/src/core/oauth/oauth_token_exchange.cc new file mode 100644 index 000000000..6135fd42a --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_token_exchange.cc @@ -0,0 +1,79 @@ +#include + +#include +#include + +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::optional, std::nullopt +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const 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); + } +} + +} // namespace + +auto oauth_token_exchange_valid(const OAuthTokenExchangeRequest &request) + -> bool { + // RFC 8693 Section 2.1: the subject token and its type are REQUIRED, and the + // actor token and its type are used together or not at all + return !request.subject_token.empty() && + !request.subject_token_type.empty() && + request.actor_token.empty() == request.actor_token_type.empty(); +} + +auto oauth_build_token_request_exchange( + const OAuthTokenExchangeRequest &request, SecureString &sink) -> bool { + if (!oauth_token_exchange_valid(request)) { + return false; + } + + oauth_append_form_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", + 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", + request.actor_token_type); + } + + if (!request.requested_token_type.empty()) { + oauth_append_form_parameter(sink, "requested_token_type", + request.requested_token_type); + } + + if (!request.scope.empty()) { + oauth_append_form_parameter(sink, "scope", request.scope); + } + + // RFC 8693 Section 2.1 and RFC 8707 Section 2: each audience and resource is + // emitted under its fixed parameter name, so the caller supplies only values + oauth_append_repeated(sink, "audience", request.audiences); + oauth_append_repeated(sink, "resource", request.resources); + return true; +} + +auto oauth_issued_token_type(const JSON &response) + -> std::optional { + return oauth_json_string_member(response, "issued_token_type"sv, + HASH_ISSUED_TOKEN_TYPE); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_transaction.cc b/vendor/core/src/core/oauth/oauth_transaction.cc new file mode 100644 index 000000000..8c5d939f8 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_transaction.cc @@ -0,0 +1,69 @@ +#include + +#include +#include +#include + +#include // std::optional, std::nullopt +#include // std::string_view + +namespace sourcemeta::core { + +auto oauth_transaction_mint() -> OAuthTransactionSecrets { + return {.state = oauth_random_token(), + .code_verifier = oauth_pkce_verifier()}; +} + +auto oauth_transaction_check(const OAuthTransaction &transaction, + const OAuthAuthorizationResponse &response, + const OAuthIssuerSupport issuer_support, + const std::string_view received_uri, + std::string_view &code) + -> std::optional { + // RFC 6749 Section 10.12: the state binds the callback to this user agent, so + // it is checked first and in constant time to avoid leaking a match position, + // even though the value itself is not confidential + if (response.state.empty() || + !secure_equals(response.state, transaction.state)) { + return OAuthCallbackError::State; + } + + // RFC 8252 Section 8.10: when the caller knows the URI the response arrived + // on, it must be the one the request was sent with + if (!received_uri.empty() && received_uri != transaction.redirect_uri) { + return OAuthCallbackError::ReceivedURI; + } + + // RFC 9207 Section 2.4: the issuer defends against a mix-up attack + switch (issuer_support) { + case OAuthIssuerSupport::Supported: + if (response.iss.empty() || response.iss != transaction.issuer) { + return OAuthCallbackError::Issuer; + } + + break; + case OAuthIssuerSupport::Unknown: + if (!response.iss.empty() && response.iss != transaction.issuer) { + return OAuthCallbackError::Issuer; + } + + break; + case OAuthIssuerSupport::NotSupported: + break; + } + + // RFC 6749 Section 4.1.2.1: an authenticated error response is a decline, and + // it is distinguished from a missing code only after the checks above pass + if (!response.error.empty()) { + return OAuthCallbackError::Declined; + } + + if (response.code.empty()) { + return OAuthCallbackError::MissingCode; + } + + code = response.code; + return std::nullopt; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_ttl.h b/vendor/core/src/core/oauth/oauth_ttl.h new file mode 100644 index 000000000..0dd7e0dcc --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_ttl.h @@ -0,0 +1,28 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TTL_H_ +#define SOURCEMETA_CORE_OAUTH_TTL_H_ + +#include // std::max, std::min +#include // std::chrono::seconds +#include // std::optional + +namespace sourcemeta::core { + +// Clamp an advertised freshness lifetime into the honored range, or fall back +// when the transport advertised none. The upper and lower limits are applied +// as separate comparisons, so an inverted band whose minimum exceeds its +// maximum stays well defined with the minimum taking precedence +inline auto oauth_clamp_ttl(const std::optional max_age, + const std::chrono::seconds fallback, + const std::chrono::seconds minimum, + const std::chrono::seconds maximum) + -> std::chrono::seconds { + if (max_age.has_value()) { + return std::max(minimum, std::min(max_age.value(), maximum)); + } + + return fallback; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/regex/permissive.h b/vendor/core/src/core/regex/permissive.h index 3898fffb3..03a48b571 100644 --- a/vendor/core/src/core/regex/permissive.h +++ b/vendor/core/src/core/regex/permissive.h @@ -600,6 +600,30 @@ inline auto is_escaped(const std::string &pattern, std::size_t index) -> bool { return (count % 2) == 1; } +// Whether the closing brace at the given position terminates a bounded +// quantifier, as opposed to standing for a literal brace character +inline auto closes_brace_quantifier(const std::string &pattern, + std::size_t brace_position) -> bool { + auto cursor{brace_position}; + bool has_digits{false}; + while (cursor > 0 && is_digit(pattern[cursor - 1])) { + has_digits = true; + --cursor; + } + + if (cursor > 0 && pattern[cursor - 1] == ',') { + --cursor; + has_digits = false; + while (cursor > 0 && is_digit(pattern[cursor - 1])) { + has_digits = true; + --cursor; + } + } + + return has_digits && cursor > 0 && pattern[cursor - 1] == '{' && + !is_escaped(pattern, cursor - 1); +} + struct ShorthandExpansion { char escape; std::string_view inside_class; @@ -706,11 +730,12 @@ inline auto translate_permissive(const std::string &pattern) } } - // PCRE-only possessive quantifiers: *+ ++ ?+ + // PCRE-only possessive quantifiers: *+ ++ ?+ {n,m}+ if (current == '+' && position > 0 && !in_class) { const char prev = pattern[position - 1]; - if ((prev == '*' || prev == '+' || prev == '?') && - !is_escaped(pattern, position - 1)) { + if (!is_escaped(pattern, position - 1) && + (prev == '*' || prev == '+' || prev == '?' || + (prev == '}' && closes_brace_quantifier(pattern, position - 1)))) { ecma_valid = false; } } diff --git a/vendor/core/src/core/regex/regex.cc b/vendor/core/src/core/regex/regex.cc index c068a940c..e1191f0ff 100644 --- a/vendor/core/src/core/regex/regex.cc +++ b/vendor/core/src/core/regex/regex.cc @@ -35,6 +35,60 @@ auto compile_pcre2(const std::string &pattern, const std::uint32_t options) return RegexTypePCRE2{std::shared_ptr(pcre2_regex)}; } +auto compiles_with_ecma_options(const std::string &pattern) -> bool { + int pcre2_error_code{0}; + PCRE2_SIZE pcre2_error_offset{0}; + pcre2_code *pcre2_regex_raw{pcre2_compile( + reinterpret_cast(pattern.c_str()), pattern.size(), + // Capturing groups are kept enabled so that ECMA-262 numbered + // backreferences like (a)\1 compile and match, at a small tracking cost + PCRE2_UTF | PCRE2_UCP | PCRE2_DOTALL | PCRE2_DOLLAR_ENDONLY | + PCRE2_NEVER_BACKSLASH_C | PCRE2_MATCH_INVALID_UTF | + PCRE2_ALLOW_EMPTY_CLASS, + &pcre2_error_code, &pcre2_error_offset, nullptr)}; + + if (pcre2_regex_raw == nullptr) { + return false; + } + + pcre2_code_free(pcre2_regex_raw); + return true; +} + +auto lookbehinds_as_lookaheads(const std::string &pattern) -> std::string { + std::string result; + result.reserve(pattern.size()); + bool in_class{false}; + for (std::size_t position = 0; position < pattern.size(); ++position) { + const char current{pattern[position]}; + if (current == '\\' && position + 1 < pattern.size()) { + result += current; + result += pattern[position + 1]; + position += 1; + continue; + } + + if (current == '[') { + in_class = true; + } else if (current == ']') { + in_class = false; + } + + if (!in_class && current == '(' && position + 3 < pattern.size() && + pattern[position + 1] == '?' && pattern[position + 2] == '<' && + (pattern[position + 3] == '=' || pattern[position + 3] == '!')) { + result += "(?"; + result += pattern[position + 3]; + position += 3; + continue; + } + + result += current; + } + + return result; +} + } // namespace auto to_regex(const std::string_view pattern, const RegexDialect dialect) @@ -164,24 +218,18 @@ auto is_regex_ecma(const std::string_view pattern) -> bool { return false; } - int pcre2_error_code{0}; - PCRE2_SIZE pcre2_error_offset{0}; - pcre2_code *pcre2_regex_raw{pcre2_compile( - reinterpret_cast(translated.transformed.value().c_str()), - translated.transformed.value().size(), - // Capturing groups are kept enabled so that ECMA-262 numbered - // backreferences like (a)\1 compile and match, at a small tracking cost - PCRE2_UTF | PCRE2_UCP | PCRE2_DOTALL | PCRE2_DOLLAR_ENDONLY | - PCRE2_NEVER_BACKSLASH_C | PCRE2_MATCH_INVALID_UTF | - PCRE2_ALLOW_EMPTY_CLASS, - &pcre2_error_code, &pcre2_error_offset, nullptr)}; - - if (pcre2_regex_raw == nullptr) { - return false; + if (compiles_with_ecma_options(translated.transformed.value())) { + return true; } - pcre2_code_free(pcre2_regex_raw); - return true; + // ECMA-262 places no width restriction on lookbehind assertions, while + // PCRE2 only accepts lookbehinds whose maximum width is bounded. As + // lookbehind and lookahead bodies follow the same grammar, the syntax + // check is retried with every lookbehind turned into a lookahead + const auto rewritten{ + lookbehinds_as_lookaheads(translated.transformed.value())}; + return rewritten != translated.transformed.value() && + compiles_with_ecma_options(rewritten); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/uri/CMakeLists.txt b/vendor/core/src/core/uri/CMakeLists.txt index 586a592fe..3be0240c6 100644 --- a/vendor/core/src/core/uri/CMakeLists.txt +++ b/vendor/core/src/core/uri/CMakeLists.txt @@ -13,6 +13,6 @@ target_link_libraries(sourcemeta_core_uri target_link_libraries(sourcemeta_core_uri PRIVATE sourcemeta::core::ip) target_link_libraries(sourcemeta_core_uri - PRIVATE sourcemeta::core::text) + PUBLIC sourcemeta::core::text) target_link_libraries(sourcemeta_core_uri PRIVATE sourcemeta::core::unicode) diff --git a/vendor/core/src/core/uri/escape.cc b/vendor/core/src/core/uri/escape.cc index fadd35b4f..8d704ac4b 100644 --- a/vendor/core/src/core/uri/escape.cc +++ b/vendor/core/src/core/uri/escape.cc @@ -2,58 +2,12 @@ #include #include "escaping.h" -#include "grammar.h" -#include // std::size_t -#include // std::int8_t #include // std::string #include // std::string_view namespace sourcemeta::core { -auto URI::escape(const std::string_view input, std::string &output, - const bool maybe_encoded) -> void { - output.reserve(output.size() + input.size() * 3); - if (!maybe_encoded) { - for (const auto character : input) { - if (uri_is_unreserved(character)) { - output += character; - } else { - uri_percent_encode_byte(output, static_cast(character)); - } - } - - return; - } - - // Treat the input as possibly already encoded, decoding each octet before - // re-encoding so that a valid escape survives and a needlessly encoded - // unreserved octet is decoded - for (std::size_t position = 0; position < input.size();) { - const auto high{input[position] == URI_PERCENT && - position + 2 < input.size() - ? hex_digit_value(input[position + 1]) - : static_cast(-1)}; - const auto low{high < 0 ? static_cast(-1) - : hex_digit_value(input[position + 2])}; - - unsigned char byte{}; - if (low < 0) { - byte = static_cast(input[position]); - position += 1; - } else { - byte = static_cast((high << 4) | low); - position += 3; - } - - if (uri_is_unreserved(static_cast(byte))) { - output += static_cast(byte); - } else { - uri_percent_encode_byte(output, byte); - } - } -} - auto URI::escape(const std::string_view input, const bool maybe_encoded) -> std::string { std::string result; 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 526629900..a0e5b17ca 100644 --- a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h +++ b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h @@ -5,6 +5,8 @@ #include #endif +#include + // NOLINTBEGIN(misc-include-cleaner) #include // NOLINTEND(misc-include-cleaner) @@ -778,10 +780,11 @@ class SOURCEMETA_CORE_URI_EXPORT URI { [[nodiscard]] static auto escape(std::string_view input, bool maybe_encoded = false) -> std::string; - /// Percent-encode a string per RFC 3986, appending the result to an existing - /// string rather than allocating a new one, optionally treating the input as - /// possibly already encoded. The output must not alias the input. For - /// example: + /// Percent-encode a string per RFC 3986, appending the result to a string + /// like output sink rather than allocating a new string. Besides a + /// `std::string` the sink can be a wiping string for secret material. The + /// input can optionally be treated as possibly already encoded. The output + /// must not alias the input. For example: /// /// ```cpp /// #include @@ -792,8 +795,44 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// sourcemeta::core::URI::escape("foo bar", output); /// assert(output == "key=foo%20bar"); /// ``` - static auto escape(std::string_view input, std::string &output, - bool maybe_encoded = false) -> void; + template + static auto escape(const std::string_view input, Output &output, + const bool maybe_encoded = false) -> void { + output.reserve(output.size() + input.size() * 3); + for (std::size_t position = 0; position < input.size();) { + auto byte{static_cast(input[position])}; + std::size_t advance{1}; + // Treat the input as possibly already encoded by decoding each valid + // escape before re-encoding, so a valid escape survives and a needlessly + // encoded unreserved octet is decoded + if (maybe_encoded && input[position] == '%' && + position + 2 < input.size()) { + const auto high{hex_digit_value(input[position + 1])}; + const auto low{high < 0 ? static_cast(-1) + : hex_digit_value(input[position + 2])}; + if (low >= 0) { + byte = static_cast((high << 4) | low); + advance = 3; + } + } + + position += advance; + // RFC 3986 Section 2.3: the unreserved set passes through unescaped + if (is_alphanum(static_cast(byte)) || byte == '-' || byte == '.' || + byte == '_' || byte == '~') { + output.push_back(static_cast(byte)); + } else { + // RFC 3986 Section 2.1: percent-encode with uppercase hexadecimal + const auto high{static_cast((byte >> 4U) & 0x0FU)}; + const auto low{static_cast(byte & 0x0FU)}; + output.push_back('%'); + output.push_back( + static_cast(high < 10 ? '0' + high : 'A' + high - 10)); + output.push_back( + static_cast(low < 10 ? '0' + low : 'A' + low - 10)); + } + } + } /// Percent-decode every escape sequence in a string per RFC 3986, leaving /// malformed sequences untouched. For example: @@ -807,6 +846,56 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` [[nodiscard]] static auto unescape(std::string_view input) -> std::string; + /// Decode an "application/x-www-form-urlencoded" component (RFC 6749 Appendix + /// B and the HTML URL-encoded form syntax), appending the decoded bytes to + /// the output. Besides a `std::string` the sink can be a wiping string for a + /// secret such as a decoded client credential. Each "+" becomes a space and + /// each "%" followed by two hexadecimal digits becomes its octet. A "%" that + /// is not followed by two hexadecimal digits is rejected, returning false + /// with the output restored to its original contents. The output must not + /// alias the input. For example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// std::string output; + /// assert(sourcemeta::core::URI::unescape_form("a+b%2Fc", output)); + /// assert(output == "a b/c"); + /// ``` + template + [[nodiscard]] static auto unescape_form(const std::string_view input, + Output &output) -> bool { + const auto base{output.size()}; + output.reserve(base + input.size()); + for (std::size_t position = 0; position < input.size();) { + const auto character{input[position]}; + if (character == '+') { + output.push_back(' '); + position += 1; + } else if (character == '%') { + const auto high{position + 2 < input.size() + ? hex_digit_value(input[position + 1]) + : static_cast(-1)}; + const auto low{high < 0 ? static_cast(-1) + : hex_digit_value(input[position + 2])}; + if (low < 0) { + output.resize(base, '\0'); + return false; + } + + output.push_back(static_cast((high << 4) | low)); + position += 3; + } else { + output.push_back(character); + position += 1; + } + } + + return true; + } + /// Remove the "." and ".." segments from a URI path per RFC 3986 Section /// 5.2.4, preserving leading ".." segments in a relative path. For example: /// diff --git a/vendor/core/src/lang/numeric/big_coefficient.h b/vendor/core/src/lang/numeric/big_coefficient.h index ab250b3fb..1597427d2 100644 --- a/vendor/core/src/lang/numeric/big_coefficient.h +++ b/vendor/core/src/lang/numeric/big_coefficient.h @@ -197,8 +197,20 @@ class BigCoefficient { } if (this->words[0] != 0) { + // Each word holds a fixed slice of the whole number, so dividing by 10 + // must carry the remainder of every higher word into the word below while (this->words[0] % 10 == 0) { - this->words[0] /= 10; + std::uint64_t borrow = 0; + for (auto index = this->length; index > 0; index--) { + const auto word = this->words[index - 1]; + this->words[index - 1] = word / 10 + borrow * (BASE / 10); + borrow = word % 10; + } + + if (this->length > 1 && this->words[this->length - 1] == 0) { + this->length--; + } + total_stripped++; } } diff --git a/vendor/jsonbinpack/DEPENDENCIES b/vendor/jsonbinpack/DEPENDENCIES index 350c15c94..ca31f3077 100644 --- a/vendor/jsonbinpack/DEPENDENCIES +++ b/vendor/jsonbinpack/DEPENDENCIES @@ -1,4 +1,4 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core f8a406692eb8080c84cd016ec4968e391461b1c1 -blaze https://github.com/sourcemeta/blaze 22d0e8a03d1de54c44599426d8537386fcf2a617 +core https://github.com/sourcemeta/core 6bbc7a8850da0ad72cd2b2badfc33033a4e48be1 +blaze https://github.com/sourcemeta/blaze 49ff4cd2893d52970154a7eea5a729671a9f630a bootstrap https://github.com/twbs/bootstrap 1a6fdfae6be09b09eaced8f0e442ca6f7680a61e diff --git a/vendor/jsonbinpack/cmake/FindCore.cmake b/vendor/jsonbinpack/cmake/FindCore.cmake index 7885d48a7..edd0efe94 100644 --- a/vendor/jsonbinpack/cmake/FindCore.cmake +++ b/vendor/jsonbinpack/cmake/FindCore.cmake @@ -13,6 +13,7 @@ if(NOT Core_FOUND) set(SOURCEMETA_CORE_JSONRPC OFF CACHE BOOL "disable") 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_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/jsonschema/DEPENDENCIES b/vendor/jsonschema/DEPENDENCIES index 4ab598f16..499abbe15 100644 --- a/vendor/jsonschema/DEPENDENCIES +++ b/vendor/jsonschema/DEPENDENCIES @@ -1,5 +1,5 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 367f6ef45c8e8f05269d65d98dab155505f39ccb -jsonbinpack https://github.com/sourcemeta/jsonbinpack 183f333e0e032ebf0a397b5d66ed504c44a0a59f -blaze https://github.com/sourcemeta/blaze 18618eccba9609f8beb5563ce57b32ec551f39bf +core https://github.com/sourcemeta/core 6bbc7a8850da0ad72cd2b2badfc33033a4e48be1 +jsonbinpack https://github.com/sourcemeta/jsonbinpack aa6fb7ae6addfe7d7de1f7f49e8f1bbf9652f5ee +blaze https://github.com/sourcemeta/blaze 49ff4cd2893d52970154a7eea5a729671a9f630a ctrf https://github.com/ctrf-io/ctrf 93ea827d951390190171d37443bff169cf47c808 diff --git a/vendor/jsonschema/VERSION b/vendor/jsonschema/VERSION index e0228cf18..383210814 100644 --- a/vendor/jsonschema/VERSION +++ b/vendor/jsonschema/VERSION @@ -1 +1 @@ -16.1.0 +16.2.1 diff --git a/vendor/jsonschema/completion/jsonschema.bash b/vendor/jsonschema/completion/jsonschema.bash index 6aa230fbc..bfe9e883e 100644 --- a/vendor/jsonschema/completion/jsonschema.bash +++ b/vendor/jsonschema/completion/jsonschema.bash @@ -88,6 +88,9 @@ _jsonschema() { if [ "${command}" = "upgrade" ] then COMPREPLY=( $(compgen -W "draft4 draft6 draft7 2019-09 2020-12" -- "${current}") ) + elif [ "${command}" = "lint" ] + then + COMPREPLY=( $(compgen -f -X '!*.json' -X '!*.yaml' -X '!*.yml' -- "${current}") ) else COMPREPLY=( $(compgen -W "typescript" -- "${current}") ) fi @@ -142,7 +145,7 @@ _jsonschema() { fi ;; lint) - local options="--fix -f --extension -e --ignore -i --exclude -x --only -o --list -l --indentation -n" + local options="--fix -f --extension -e --ignore -i --exclude -x --only -o --list -l --indentation -n --rule -a --top-level-rule -t" if [[ ${current} == -* ]] then COMPREPLY=( $(compgen -W "${options} ${global_options}" -- "${current}") ) diff --git a/vendor/jsonschema/completion/jsonschema.zsh b/vendor/jsonschema/completion/jsonschema.zsh index d44305d68..43548624b 100644 --- a/vendor/jsonschema/completion/jsonschema.zsh +++ b/vendor/jsonschema/completion/jsonschema.zsh @@ -105,6 +105,8 @@ _jsonschema() { '(--only -o)'{--only,-o}'[Only run specific rule]:rule name:' \ '(--list -l)'{--list,-l}'[List all enabled rules]' \ '(--indentation -n)'{--indentation,-n}'[Specify indentation spaces]:spaces:(2 4 8)' \ + '(--rule -a)'{--rule,-a}'[Add a custom lint rule]:rule schema:_files -g "*.json *.yaml *.yml"' \ + '(--top-level-rule -t)'{--top-level-rule,-t}'[Add a custom lint rule that only runs against the document root]:rule schema:_files -g "*.json *.yaml *.yml"' \ '*:schema file:_files -g "*.json *.yaml *.yml"' ;; bundle) diff --git a/vendor/jsonschema/src/command_lint.cc b/vendor/jsonschema/src/command_lint.cc index 64dfe7208..07cd0a666 100644 --- a/vendor/jsonschema/src/command_lint.cc +++ b/vendor/jsonschema/src/command_lint.cc @@ -148,7 +148,8 @@ static auto load_rule(sourcemeta::blaze::SchemaTransformer &bundle, const std::filesystem::path &rule_path, const std::string_view dialect, const sourcemeta::blaze::SchemaResolver &custom_resolver, - const std::optional &tweaks) + const std::optional &tweaks, + const sourcemeta::blaze::SchemaRule::Scope scope) -> void { auto rule_schema{sourcemeta::core::read_yaml_or_json(rule_path)}; if (!rule_schema.defines("description")) { @@ -169,7 +170,7 @@ static auto load_rule(sourcemeta::blaze::SchemaTransformer &bundle, try { bundle.add( rule_schema, sourcemeta::blaze::schema_walker, custom_resolver, - sourcemeta::blaze::default_schema_compiler, dialect, tweaks); + sourcemeta::blaze::default_schema_compiler, dialect, tweaks, scope); } catch (const sourcemeta::blaze::SchemaRuleMissingNameError &error) { throw sourcemeta::core::FileError< sourcemeta::blaze::SchemaRuleMissingNameError>(rule_path, error); @@ -199,6 +200,31 @@ static auto load_rule(sourcemeta::blaze::SchemaTransformer &bundle, } } +static auto +load_rules_from_options(sourcemeta::blaze::SchemaTransformer &bundle, + std::unordered_set &rule_names, + const sourcemeta::core::Options &options, + const std::string_view option_name, + const sourcemeta::blaze::SchemaRule::Scope scope) + -> void { + for (const auto &rule_path_string : options.at(option_name)) { + const std::filesystem::path rule_path{ + std::filesystem::weakly_canonical(rule_path_string)}; + sourcemeta::jsonschema::LOG_VERBOSE(options) + << "Loading custom rule: " << rule_path.string() << "\n"; + const auto configuration_path{ + sourcemeta::jsonschema::find_configuration(rule_path)}; + const auto &configuration{sourcemeta::jsonschema::read_configuration( + options, configuration_path, rule_path)}; + const auto dialect{ + sourcemeta::jsonschema::default_dialect(options, configuration)}; + const auto &custom_resolver{sourcemeta::jsonschema::resolver( + options, options.contains("http"), dialect, configuration)}; + load_rule(bundle, rule_names, rule_path, dialect, custom_resolver, + sourcemeta::jsonschema::format_assertion_tweaks(options), scope); + } +} + auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) -> void { validate_http_headers(options); @@ -249,29 +275,24 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) const auto dialect{default_dialect(options, configuration)}; const auto &custom_resolver{ resolver(options, options.contains("http"), dialect, configuration)}; - for (const auto &rule_path : configuration.value().lint.rules) { + for (const auto &rule : configuration.value().lint.rules) { LOG_VERBOSE(options) << "Loading custom rule from configuration: " - << rule_path.string() << "\n"; - load_rule(bundle, rule_names, rule_path, dialect, custom_resolver, - sourcemeta::jsonschema::format_assertion_tweaks(options)); + << rule.path.string() << "\n"; + load_rule(bundle, rule_names, rule.path, dialect, custom_resolver, + sourcemeta::jsonschema::format_assertion_tweaks(options), + rule.top_level ? sourcemeta::blaze::SchemaRule::Scope::TopLevel + : sourcemeta::blaze::SchemaRule::Scope::All); } } if (options.contains("rule")) { - for (const auto &rule_path_string : options.at("rule")) { - const std::filesystem::path rule_path{ - std::filesystem::weakly_canonical(rule_path_string)}; - LOG_VERBOSE(options) << "Loading custom rule: " << rule_path.string() - << "\n"; - const auto configuration_path{find_configuration(rule_path)}; - const auto &configuration{ - read_configuration(options, configuration_path, rule_path)}; - const auto dialect{default_dialect(options, configuration)}; - const auto &custom_resolver{ - resolver(options, options.contains("http"), dialect, configuration)}; - load_rule(bundle, rule_names, rule_path, dialect, custom_resolver, - sourcemeta::jsonschema::format_assertion_tweaks(options)); - } + load_rules_from_options(bundle, rule_names, options, "rule", + sourcemeta::blaze::SchemaRule::Scope::All); + } + + if (options.contains("top-level-rule")) { + load_rules_from_options(bundle, rule_names, options, "top-level-rule", + sourcemeta::blaze::SchemaRule::Scope::TopLevel); } if (options.contains("only")) { diff --git a/vendor/jsonschema/src/command_test.cc b/vendor/jsonschema/src/command_test.cc index 33a84a370..9b4a86882 100644 --- a/vendor/jsonschema/src/command_test.cc +++ b/vendor/jsonschema/src/command_test.cc @@ -107,6 +107,7 @@ auto parse_test_suite(const sourcemeta::jsonschema::InputJSON &entry, auto report_as_text(const sourcemeta::core::Options &options) -> void { bool result{true}; + bool empty_test_suite{false}; const auto verbose{options.contains("verbose") || options.contains("debug")}; for (const auto &entry : sourcemeta::jsonschema::for_each_json(options)) { @@ -202,6 +203,7 @@ auto report_as_text(const sourcemeta::core::Options &options) -> void { } if (suite_result.total == 0) { + empty_test_suite = true; std::cout << " NO TESTS\n"; } else if (!verbose && suite_result.passed == suite_result.total) { std::cout << " PASS " << suite_result.passed << "/" << suite_result.total @@ -213,6 +215,13 @@ auto report_as_text(const sourcemeta::core::Options &options) -> void { throw sourcemeta::jsonschema::Fail{ sourcemeta::jsonschema::EXIT_EXPECTED_FAILURE}; } + + // An empty test suite likely means the author forgot to write the tests, + // so don't let it silently succeed + if (empty_test_suite) { + throw sourcemeta::jsonschema::Fail{ + sourcemeta::jsonschema::EXIT_OTHER_INPUT_ERROR}; + } } auto timestamp_to_unix_ms( @@ -234,6 +243,7 @@ auto duration_ms(const sourcemeta::blaze::TestTimestamp &start, auto report_as_ctrf(const sourcemeta::core::Options &options) -> void { bool result{true}; + bool empty_test_suite{false}; const auto system_ref{std::chrono::system_clock::now()}; const auto steady_ref{std::chrono::steady_clock::now()}; @@ -333,6 +343,10 @@ auto report_as_ctrf(const sourcemeta::core::Options &options) -> void { total_passed += suite_result.passed; total_failed += suite_result.total - suite_result.passed; + if (suite_result.total == 0) { + empty_test_suite = true; + } + if (suite_result.passed != suite_result.total) { result = false; } @@ -378,6 +392,13 @@ auto report_as_ctrf(const sourcemeta::core::Options &options) -> void { throw sourcemeta::jsonschema::Fail{ sourcemeta::jsonschema::EXIT_EXPECTED_FAILURE}; } + + // An empty test suite likely means the author forgot to write the tests, + // so don't let it silently succeed + if (empty_test_suite) { + throw sourcemeta::jsonschema::Fail{ + sourcemeta::jsonschema::EXIT_OTHER_INPUT_ERROR}; + } } } // namespace diff --git a/vendor/jsonschema/src/main.cc b/vendor/jsonschema/src/main.cc index 9a016de75..a6fd6f533 100644 --- a/vendor/jsonschema/src/main.cc +++ b/vendor/jsonschema/src/main.cc @@ -88,7 +88,8 @@ Global Options: [--keep-ordering/-k] [--extension/-e ] [--ignore/-i ] [--exclude/-x ] [--only/-o ] [--list/-l] [--indentation/-n ] - [--rule/-a ] [--format-assertion/-F] + [--rule/-a ] [--top-level-rule/-t ] + [--format-assertion/-F] Lint the input schemas and potentially fix the reported issues. The --fix/-f option is not supported when passing YAML schemas. @@ -97,6 +98,8 @@ Global Options: Use --keep-ordering/-k with --format to preserve key order. Use --list/-l to print a summary of all enabled rules. Use --rule/-a to add a custom lint rule defined as a JSON Schema. + Use --top-level-rule/-t to add a custom lint rule that only runs + against the document root. upgrade [--to/-t draft4|draft6|draft7|2019-09|2020-12] [--meta/-m] @@ -189,6 +192,7 @@ auto jsonschema_main(const std::string &program, const std::string &command, app.option("ignore", {"i"}); app.option("indentation", {"n"}); app.option("rule", {"a"}); + app.option("top-level-rule", {"t"}); app.parse(argc, argv, {.skip = 1}); sourcemeta::jsonschema::lint(app); return EXIT_SUCCESS;