From 1cf86437b2eb41e86e9f6a3258199d068d7e92b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 16:43:13 +0900 Subject: [PATCH 01/93] docs(dotnet-agent-identity): add the authority library intent The authority library holds what every Agent Identity authority must do the same way, so that DVLS and PSU cannot drift apart on the agent-facing protocol. The feature's shared intent is linked beside it. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/INTENT.agent-identity.md | 1 + dotnet/AgentIdentity/INTENT.md | 5 +++++ 2 files changed, 6 insertions(+) create mode 120000 dotnet/AgentIdentity/INTENT.agent-identity.md create mode 100644 dotnet/AgentIdentity/INTENT.md diff --git a/dotnet/AgentIdentity/INTENT.agent-identity.md b/dotnet/AgentIdentity/INTENT.agent-identity.md new file mode 120000 index 0000000000..56d20dc7a1 --- /dev/null +++ b/dotnet/AgentIdentity/INTENT.agent-identity.md @@ -0,0 +1 @@ +../../docs/agent-identity/INTENT.agent-identity.md \ No newline at end of file diff --git a/dotnet/AgentIdentity/INTENT.md b/dotnet/AgentIdentity/INTENT.md new file mode 100644 index 0000000000..12ea229df2 --- /dev/null +++ b/dotnet/AgentIdentity/INTENT.md @@ -0,0 +1,5 @@ +This library holds everything every authority must do the same way under `docs/agent-identity/CONTRACT.md`, and nothing about storage, hosting or management, so that DVLS and PSU cannot drift apart on the agent-facing protocol. +The feature's shared intent is `INTENT.agent-identity.md` beside this file. + +- An authority must use the identity produced by the signature check. + Code that acts for an identity takes it as a type only that check can create; an identity is never taken from a value the agent or a caller sent. From 49ac9278066ff660379a97095bcf7b91effbdd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 17:14:03 +0900 Subject: [PATCH 02/93] build(dotnet-agent-identity): scaffold the authority library Add the Devolutions.AgentIdentity library, for the agent-facing protocol rules that every Agent Identity authority shares, and its test project, laid out like utils/dotnet. The library references Devolutions.Agent.Channel, which becomes its package dependency, and its build fails on any warning, including a missing doc comment. The test project links docs/agent-identity/test-vectors.json into its output so that vector changes apply without a copy. build.ps1 builds and packs the library alone. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/.editorconfig | 363 ++++++++++++++++++ dotnet/AgentIdentity/AgentIdentity.sln | 28 ++ .../Devolutions.AgentIdentity.Tests.csproj | 27 ++ .../Devolutions.AgentIdentity.Tests/Usings.cs | 1 + .../Devolutions.AgentIdentity.csproj | 31 ++ dotnet/AgentIdentity/build.ps1 | 17 + 6 files changed, 467 insertions(+) create mode 100644 dotnet/AgentIdentity/.editorconfig create mode 100644 dotnet/AgentIdentity/AgentIdentity.sln create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Usings.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj create mode 100644 dotnet/AgentIdentity/build.ps1 diff --git a/dotnet/AgentIdentity/.editorconfig b/dotnet/AgentIdentity/.editorconfig new file mode 100644 index 0000000000..1ca7ccd914 --- /dev/null +++ b/dotnet/AgentIdentity/.editorconfig @@ -0,0 +1,363 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = lf +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case diff --git a/dotnet/AgentIdentity/AgentIdentity.sln b/dotnet/AgentIdentity/AgentIdentity.sln new file mode 100644 index 0000000000..a9d43aed33 --- /dev/null +++ b/dotnet/AgentIdentity/AgentIdentity.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Devolutions.AgentIdentity", "Devolutions.AgentIdentity\Devolutions.AgentIdentity.csproj", "{A1DC1136-5E93-4B2C-8147-1008ECD4204B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Devolutions.AgentIdentity.Tests", "Devolutions.AgentIdentity.Tests\Devolutions.AgentIdentity.Tests.csproj", "{60CED46A-EE0C-4C84-82BA-60DE4D518EC4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1DC1136-5E93-4B2C-8147-1008ECD4204B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1DC1136-5E93-4B2C-8147-1008ECD4204B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1DC1136-5E93-4B2C-8147-1008ECD4204B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1DC1136-5E93-4B2C-8147-1008ECD4204B}.Release|Any CPU.Build.0 = Release|Any CPU + {60CED46A-EE0C-4C84-82BA-60DE4D518EC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60CED46A-EE0C-4C84-82BA-60DE4D518EC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60CED46A-EE0C-4C84-82BA-60DE4D518EC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60CED46A-EE0C-4C84-82BA-60DE4D518EC4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj new file mode 100644 index 0000000000..78fa2d10b2 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + true + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Usings.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Usings.cs new file mode 100644 index 0000000000..c802f4480b --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj new file mode 100644 index 0000000000..bf3a2836f2 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + true + true + Devolutions.AgentIdentity + Library + Devolutions Inc. + Devolutions Inc. + Devolutions Agent Identity + Agent-facing protocol rules shared by every Devolutions Agent Identity authority + © Devolutions Inc. All rights reserved. + Devolutions.AgentIdentity + 2026.10.20 + MIT OR Apache-2.0 + https://github.com/Devolutions/devolutions-gateway.git + git + true + snupkg + + + + + + + + + + diff --git a/dotnet/AgentIdentity/build.ps1 b/dotnet/AgentIdentity/build.ps1 new file mode 100644 index 0000000000..4d02b1ba83 --- /dev/null +++ b/dotnet/AgentIdentity/build.ps1 @@ -0,0 +1,17 @@ +#!/bin/env pwsh + +$ErrorActionPreference = "Stop" + +Push-Location -Path $PSScriptRoot + +try { + $Project = "./Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj" + + dotnet build $Project --configuration Release + if ($LASTEXITCODE -ne 0) { throw "dotnet build failed" } + + dotnet pack $Project --configuration Release --no-build + if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed" } +} finally { + Pop-Location +} From 11119ff12b330ab794440ddc8617b54e8c7b6991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 17:16:02 +0900 Subject: [PATCH 03/93] feat(dotnet-agent-identity): add strict base64 codecs The contract gives every base64 and base64url value one canonical encoding, and an authority rejects any other form. The BCL decoders skip white space and ignore the pad bits of the final quantum, so they accept several encodings of one value. Add internal decoders that accept only the canonical RFC 4648 form, optionally of an exact decoded length, and a base64url encoder. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../StrictBase64Tests.cs | 223 ++++++++++++++++++ .../Devolutions.AgentIdentity/StrictBase64.cs | 135 +++++++++++ 2 files changed, 358 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/StrictBase64Tests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/StrictBase64.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/StrictBase64Tests.cs new file mode 100644 index 0000000000..e3b745962a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/StrictBase64Tests.cs @@ -0,0 +1,223 @@ +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +public class StrictBase64Tests +{ + // RFC 4648 §10 test vectors. + [Theory] + [InlineData("", "")] + [InlineData("Zg==", "f")] + [InlineData("Zm8=", "fo")] + [InlineData("Zm9v", "foo")] + [InlineData("Zm9vYg==", "foob")] + [InlineData("Zm9vYmE=", "fooba")] + [InlineData("Zm9vYmFy", "foobar")] + public void Base64DecodesRfc4648Vector(string encoded, string expected) + { + Assert.True(StrictBase64.TryDecodeBase64(encoded, out byte[]? bytes)); + Assert.Equal(Encoding.ASCII.GetBytes(expected), bytes); + } + + // Each row is a form that RFC 4648 §4 does not produce for any byte string, + // so accepting it would give one value two encodings. + [Theory] + [InlineData("missing padding", "Zg")] + [InlineData("missing one padding character", "Zm9vYg=")] + [InlineData("padding after a complete quantum", "Zm9v====")] + [InlineData("padding inside the text", "Zg==Zm9v")] + [InlineData("padding only", "====")] + [InlineData("line break between quanta", "Zm9v\r\nYmFy")] + [InlineData("trailing space", "Zm9vYmFy ")] + [InlineData("non-zero pad bits after one byte", "Zh==")] + [InlineData("non-zero pad bits after two bytes", "Zm9=")] + [InlineData("base64url alphabet", "-_8=")] + [InlineData("character outside ASCII whose low byte is 'v'", "Zm9\u0176")] + public void Base64RejectsNonCanonicalForm(string form, string encoded) + { + Assert.False(StrictBase64.TryDecodeBase64(encoded, out _), form); + } + + // RFC 4648 §10 test vectors without padding, which RFC 4648 §3.2 lets an encoding omit. + [Theory] + [InlineData("", "")] + [InlineData("Zg", "f")] + [InlineData("Zm8", "fo")] + [InlineData("Zm9v", "foo")] + [InlineData("Zm9vYg", "foob")] + [InlineData("Zm9vYmE", "fooba")] + [InlineData("Zm9vYmFy", "foobar")] + public void Base64UrlDecodesRfc4648Vector(string encoded, string expected) + { + Assert.True(StrictBase64.TryDecodeBase64Url(encoded, out byte[]? bytes)); + Assert.Equal(Encoding.ASCII.GetBytes(expected), bytes); + } + + // The sextets 62, 63 and 60 carry the bytes FB FF and two zero pad bits; + // RFC 4648 Table 1 writes them "+/8" and Table 2 writes them "-_8". + [Fact] + public void Base64DecodesTheLastTwoAlphabetCharacters() + { + Assert.True(StrictBase64.TryDecodeBase64("+/8=", out byte[]? bytes)); + Assert.Equal([0xFB, 0xFF], bytes); + } + + [Fact] + public void Base64UrlDecodesTheLastTwoAlphabetCharacters() + { + Assert.True(StrictBase64.TryDecodeBase64Url("-_8", out byte[]? bytes)); + Assert.Equal([0xFB, 0xFF], bytes); + } + + // Each row is a form that RFC 4648 §5 without padding does not produce for any byte string. + [Theory] + [InlineData("padding after one byte", "Zg==")] + [InlineData("padding after two bytes", "Zm8=")] + [InlineData("impossible length", "Zm9vY")] + [InlineData("line break between quanta", "Zm9v\r\nYmFy")] + [InlineData("trailing space", "Zm9vYmFy ")] + [InlineData("non-zero pad bits after one byte", "Zh")] + [InlineData("non-zero pad bits after two bytes", "Zm9")] + [InlineData("base64 alphabet", "+/8")] + [InlineData("character outside ASCII whose low byte is 'v'", "Zm9\u0176")] + public void Base64UrlRejectsNonCanonicalForm(string form, string encoded) + { + Assert.False(StrictBase64.TryDecodeBase64Url(encoded, out _), form); + } + + // RFC 4648 §10 vectors with the number of bytes they encode; padding characters encode none. + [Theory] + [InlineData("", 0)] + [InlineData("Zg==", 1)] + [InlineData("Zm8=", 2)] + [InlineData("Zm9v", 3)] + public void Base64AcceptsTheExactDecodedLength(string encoded, int length) + { + Assert.True(StrictBase64.TryDecodeBase64(encoded, out byte[]? bytes, exactLength: length)); + Assert.Equal(length, bytes.Length); + } + + [Theory] + [InlineData("", 1)] + [InlineData("Zg==", 0)] + [InlineData("Zg==", 3)] + [InlineData("Zm8=", 3)] + [InlineData("Zm9v", 2)] + [InlineData("Zm9v", 4)] + public void Base64RejectsAnotherDecodedLength(string encoded, int length) + { + Assert.False(StrictBase64.TryDecodeBase64(encoded, out _, exactLength: length)); + } + + [Theory] + [InlineData("", 0)] + [InlineData("Zg", 1)] + [InlineData("Zm8", 2)] + [InlineData("Zm9v", 3)] + public void Base64UrlAcceptsTheExactDecodedLength(string encoded, int length) + { + Assert.True(StrictBase64.TryDecodeBase64Url(encoded, out byte[]? bytes, exactLength: length)); + Assert.Equal(length, bytes.Length); + } + + [Theory] + [InlineData("", 1)] + [InlineData("Zg", 0)] + [InlineData("Zg", 2)] + [InlineData("Zm8", 3)] + [InlineData("Zm9v", 2)] + [InlineData("Zm9v", 4)] + public void Base64UrlRejectsAnotherDecodedLength(string encoded, int length) + { + Assert.False(StrictBase64.TryDecodeBase64Url(encoded, out _, exactLength: length)); + } + + [Theory] + [InlineData("", "")] + [InlineData("f", "Zg")] + [InlineData("fo", "Zm8")] + [InlineData("foo", "Zm9v")] + [InlineData("foob", "Zm9vYg")] + [InlineData("fooba", "Zm9vYmE")] + [InlineData("foobar", "Zm9vYmFy")] + public void Base64UrlEncodesRfc4648VectorWithoutPadding(string decoded, string expected) + { + Assert.Equal(expected, StrictBase64.EncodeBase64Url(Encoding.ASCII.GetBytes(decoded))); + } + + [Fact] + public void Base64UrlEncodesTheLastTwoAlphabetCharacters() + { + Assert.Equal("-_8", StrictBase64.EncodeBase64Url([0xFB, 0xFF])); + } + + // The BCL encoder writes canonical base64, + // and RFC 4648 §5 derives base64url from it by swapping the last two characters and dropping the padding. + // Random values of every length up to 96 bytes end in each final-quantum shape many times. + [Fact] + public void DecodesAndEncodesEveryLengthLikeTheBcl() + { + Random random = new(4648); + + for (int length = 0; length <= 96; length++) + { + byte[] value = new byte[length]; + random.NextBytes(value); + string base64 = Convert.ToBase64String(value); + string base64Url = ToBase64Url(base64); + + Assert.True(StrictBase64.TryDecodeBase64(base64, out byte[]? fromBase64), base64); + Assert.Equal(value, fromBase64); + Assert.True(StrictBase64.TryDecodeBase64Url(base64Url, out byte[]? fromBase64Url), base64Url); + Assert.Equal(value, fromBase64Url); + Assert.Equal(base64Url, StrictBase64.EncodeBase64Url(value)); + } + } + + // Only the final quantum has pad bits, + // so trying every final quantum of one or two bytes checks that each value has exactly one accepted encoding. + [Fact] + public void Base64AcceptsOneEncodingOfEachOneOrTwoByteValue() + { + IEnumerable finalQuanta = Strings(Base64Alphabet, 2).Select(s => s + "==") + .Concat(Strings(Base64Alphabet, 3).Select(s => s + "=")); + IEnumerable canonical = OneOrTwoByteValues().Select(Convert.ToBase64String); + + IEnumerable accepted = finalQuanta.Where(s => StrictBase64.TryDecodeBase64(s, out _)); + + Assert.Equal(canonical.Order(StringComparer.Ordinal), accepted.Order(StringComparer.Ordinal)); + } + + [Fact] + public void Base64UrlAcceptsOneEncodingOfEachOneOrTwoByteValue() + { + IEnumerable finalQuanta = Strings(Base64UrlAlphabet, 2).Concat(Strings(Base64UrlAlphabet, 3)); + IEnumerable canonical = OneOrTwoByteValues().Select(value => ToBase64Url(Convert.ToBase64String(value))); + + IEnumerable accepted = finalQuanta.Where(s => StrictBase64.TryDecodeBase64Url(s, out _)); + + Assert.Equal(canonical.Order(StringComparer.Ordinal), accepted.Order(StringComparer.Ordinal)); + } + + // RFC 4648 Table 1 and Table 2. + private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + private static string ToBase64Url(string base64) + { + return base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } + + private static IEnumerable Strings(string alphabet, int length) + { + return length == 0 + ? [string.Empty] + : Strings(alphabet, length - 1).SelectMany(prefix => alphabet.Select(c => prefix + c)); + } + + private static IEnumerable OneOrTwoByteValues() + { + return Enumerable.Range(0, 1 << 8).Select(v => new[] { (byte)v }) + .Concat(Enumerable.Range(0, 1 << 16).Select(v => new[] { (byte)(v >> 8), (byte)v })); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/StrictBase64.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/StrictBase64.cs new file mode 100644 index 0000000000..3094197828 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/StrictBase64.cs @@ -0,0 +1,135 @@ +using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// Decoders that accept only the canonical RFC 4648 form, so that each byte string has exactly one accepted encoding. +/// +/// +/// The BCL decoders skip white space and ignore the pad bits of the final quantum, +/// so they accept several encodings of one value. +/// +internal static class StrictBase64 +{ + /// + /// Decodes base64 (RFC 4648 §4) with padding. + /// + /// + /// Rejects white space, characters outside the alphabet, missing or extra padding, and non-zero pad bits. + /// + /// The encoded text. + /// The decoded bytes, when the text is canonical. + /// The number of bytes the text must decode to, if any. + public static bool TryDecodeBase64( + ReadOnlySpan text, + [NotNullWhen(true)] out byte[]? bytes, + int? exactLength = null) + { + bytes = null; + + if (text.Length % 4 != 0) + { + return false; + } + + int padding = text.EndsWith("==") ? 2 : text.EndsWith("=") ? 1 : 0; + + return TryDecodeUnpadded(text[..^padding], urlSafe: false, exactLength, out bytes); + } + + /// + /// Decodes base64url (RFC 4648 §5) without padding. + /// + /// + /// Rejects white space, characters outside the alphabet, padding, and non-zero pad bits. + /// + /// The encoded text. + /// The decoded bytes, when the text is canonical. + /// The number of bytes the text must decode to, if any. + public static bool TryDecodeBase64Url( + ReadOnlySpan text, + [NotNullWhen(true)] out byte[]? bytes, + int? exactLength = null) + { + return TryDecodeUnpadded(text, urlSafe: true, exactLength, out bytes); + } + + /// + /// Encodes bytes as base64url (RFC 4648 §5) without padding. + /// + public static string EncodeBase64Url(ReadOnlySpan bytes) + { + return Base64Url.EncodeToString(bytes); + } + + private static bool TryDecodeUnpadded( + ReadOnlySpan text, + bool urlSafe, + int? exactLength, + [NotNullWhen(true)] out byte[]? bytes) + { + bytes = null; + + // A final quantum of one character would hold fewer than 8 bits. + int partial = text.Length % 4; + if (partial == 1) + { + return false; + } + + int length = (text.Length / 4 * 3) + (partial == 0 ? 0 : partial - 1); + if (exactLength is { } expected && length != expected) + { + return false; + } + + byte[] decoded = new byte[length]; + int written = 0; + int accumulator = 0; + int bitCount = 0; + + foreach (char c in text) + { + int sextet = DecodeSextet(c, urlSafe); + if (sextet < 0) + { + return false; + } + + accumulator = (accumulator << 6) | sextet; + bitCount += 6; + + if (bitCount >= 8) + { + bitCount -= 8; + decoded[written++] = (byte)(accumulator >> bitCount); + accumulator &= (1 << bitCount) - 1; + } + } + + // The bits left over are the pad bits of the final quantum, which the canonical encoding sets to zero. + if (accumulator != 0) + { + return false; + } + + bytes = decoded; + return true; + } + + private static int DecodeSextet(char c, bool urlSafe) + { + return c switch + { + >= 'A' and <= 'Z' => c - 'A', + >= 'a' and <= 'z' => c - 'a' + 26, + >= '0' and <= '9' => c - '0' + 52, + '+' when !urlSafe => 62, + '/' when !urlSafe => 63, + '-' when urlSafe => 62, + '_' when urlSafe => 63, + _ => -1, + }; + } +} From fd67d759b4bf3ac20b93eb371f3d095a69b120c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 17:35:09 +0900 Subject: [PATCH 04/93] feat(dotnet-agent-identity): add the protocol error model Every authority must answer with the same error codes, statuses and bodies, because the agent decides from them whether to retry, discard a pending enrollment or stop using an identity. Add AgentIdentityErrorCode with its wire values and its HTTP and gRPC statuses, including unavailable for an overloaded or failing server, and AgentIdentityError, which writes the JSON error body and the RpcException with the error-code and server-time trailers. Each message is a fixed English sentence, so it never repeats request data. AgentIdentityProtocol holds the 64 KiB body limit, the timestamp format and the certificate thumbprint. The tests read the shared vector file into typed records and yield one theory row per vector name, so that each vector is its own test. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentIdentityErrorCodeTests.cs | 103 ++++++++++ .../AgentIdentityErrorTests.cs | 107 ++++++++++ .../AgentIdentityProtocolTests.cs | 77 +++++++ .../TestVectors.cs | 194 ++++++++++++++++++ .../VectorData.cs | 25 +++ .../AgentIdentityError.cs | 161 +++++++++++++++ .../AgentIdentityErrorCode.cs | 66 ++++++ .../AgentIdentityErrorCodeExtensions.cs | 89 ++++++++ .../AgentIdentityProtocol.cs | 43 ++++ 9 files changed, 865 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorCodeTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityProtocolTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorData.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCode.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCodeExtensions.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityProtocol.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorCodeTests.cs new file mode 100644 index 0000000000..4f93d65901 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorCodeTests.cs @@ -0,0 +1,103 @@ +using Grpc.Core; + +namespace Devolutions.AgentIdentity.Tests; + +public class AgentIdentityErrorCodeTests +{ + // CONTRACT.md §5.4, column "Code". + public static TheoryData WireValues => new() + { + { AgentIdentityErrorCode.TokenInvalid, "token_invalid" }, + { AgentIdentityErrorCode.TokenExhausted, "token_exhausted" }, + { AgentIdentityErrorCode.TokenExpired, "token_expired" }, + { AgentIdentityErrorCode.IdentityRevoked, "identity_revoked" }, + { AgentIdentityErrorCode.IdentityUnknown, "identity_unknown" }, + { AgentIdentityErrorCode.CertificateExpired, "certificate_expired" }, + { AgentIdentityErrorCode.SignatureInvalid, "signature_invalid" }, + { AgentIdentityErrorCode.ClockSkew, "clock_skew" }, + { AgentIdentityErrorCode.InvalidRequest, "invalid_request" }, + { AgentIdentityErrorCode.Unavailable, "unavailable" }, + }; + + [Theory] + [MemberData(nameof(WireValues))] + public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expected) + { + Assert.Equal(expected, code.ToWireValue()); + } + + // CONTRACT.md §5.4, column "HTTP". + // The table gives `unavailable` 5xx, and an unexpected failure gets 500. + public static TheoryData HttpStatusCodes => new() + { + { AgentIdentityErrorCode.TokenInvalid, 401 }, + { AgentIdentityErrorCode.TokenExhausted, 403 }, + { AgentIdentityErrorCode.TokenExpired, 401 }, + { AgentIdentityErrorCode.IdentityRevoked, 403 }, + { AgentIdentityErrorCode.IdentityUnknown, 401 }, + { AgentIdentityErrorCode.CertificateExpired, 401 }, + { AgentIdentityErrorCode.SignatureInvalid, 401 }, + { AgentIdentityErrorCode.ClockSkew, 401 }, + { AgentIdentityErrorCode.InvalidRequest, 400 }, + { AgentIdentityErrorCode.Unavailable, 500 }, + }; + + [Theory] + [MemberData(nameof(HttpStatusCodes))] + public void HttpStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, int expected) + { + Assert.Equal(expected, code.ToHttpStatusCode()); + } + + // CONTRACT.md §7.4: an authentication failure closes the stream with UNAUTHENTICATED, + // or PERMISSION_DENIED for `identity_revoked`. + // §7.3 step 4: invalid `Hello` metadata closes it with INVALID_ARGUMENT and `invalid_request`. + // `unavailable` gets UNAVAILABLE, which gRPC defines as a transient condition. + public static TheoryData GrpcStatusCodes => new() + { + { AgentIdentityErrorCode.TokenInvalid, StatusCode.Unauthenticated }, + { AgentIdentityErrorCode.TokenExhausted, StatusCode.Unauthenticated }, + { AgentIdentityErrorCode.TokenExpired, StatusCode.Unauthenticated }, + { AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied }, + { AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated }, + { AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated }, + { AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated }, + { AgentIdentityErrorCode.ClockSkew, StatusCode.Unauthenticated }, + { AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument }, + { AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable }, + }; + + [Theory] + [MemberData(nameof(GrpcStatusCodes))] + public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, StatusCode expected) + { + Assert.Equal(expected, code.ToGrpcStatusCode()); + } + + // A code added without a contract row would otherwise go untested in every table above. + [Fact] + public void EveryCodeHasAContractRow() + { + AgentIdentityErrorCode[] defined = Enum.GetValues(); + + Assert.Equal(defined, CodesOf(WireValues)); + Assert.Equal(defined, CodesOf(HttpStatusCodes)); + Assert.Equal(defined, CodesOf(GrpcStatusCodes)); + } + + // The default value of the enum is not a code, so a mapping must not turn it into one. + [Fact] + public void UndefinedCodeHasNoMapping() + { + AgentIdentityErrorCode undefined = default; + + Assert.Throws(() => undefined.ToWireValue()); + Assert.Throws(() => undefined.ToHttpStatusCode()); + Assert.Throws(() => undefined.ToGrpcStatusCode()); + } + + private static IEnumerable CodesOf(IEnumerable rows) + { + return rows.Select(row => (AgentIdentityErrorCode)row[0]).Order(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorTests.cs new file mode 100644 index 0000000000..a47495c45f --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using Grpc.Core; + +namespace Devolutions.AgentIdentity.Tests; + +public class AgentIdentityErrorTests +{ + // 12:00:00.750 UTC given in a +02:00 offset, which CONTRACT.md §1 writes as a UTC timestamp in whole seconds. + private static readonly DateTimeOffset ServerTime = + new DateTimeOffset(2026, 9, 24, 14, 0, 0, TimeSpan.FromHours(2)).AddMilliseconds(750); + + private const string ServerTimeText = "2026-09-24T12:00:00Z"; + + // CONTRACT.md §5.4, column "HTTP". + [Theory] + [MemberData(nameof(AgentIdentityErrorCodeTests.HttpStatusCodes), MemberType = typeof(AgentIdentityErrorCodeTests))] + public void FromCarriesTheCodeWithItsContractStatus(AgentIdentityErrorCode code, int expectedStatus) + { + AgentIdentityError error = AgentIdentityError.From(code); + + Assert.Equal(code, error.Code); + Assert.Equal(expectedStatus, error.HttpStatusCode); + } + + [Fact] + public void FromRejectsAnUndefinedCode() + { + Assert.Throws(() => AgentIdentityError.From(default)); + } + + // CONTRACT.md §5.4: malformed JSON or body → 400 invalid_request; unknown route → 404 invalid_request; + // wrong method → 405 invalid_request; body too large → 413 invalid_request; + // an unexpected server failure → 500 unavailable; overload or a missing dependency → 503 unavailable. + [Theory] + [InlineData(nameof(AgentIdentityError.MalformedBody), 400, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(nameof(AgentIdentityError.UnknownRoute), 404, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(nameof(AgentIdentityError.MethodNotAllowed), 405, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(nameof(AgentIdentityError.BodyTooLarge), 413, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(nameof(AgentIdentityError.UnexpectedFailure), 500, AgentIdentityErrorCode.Unavailable)] + [InlineData(nameof(AgentIdentityError.ServiceUnavailable), 503, AgentIdentityErrorCode.Unavailable)] + public void FrameworkLevelErrorHasItsContractStatusAndCode( + string name, + int expectedStatus, + AgentIdentityErrorCode expectedCode) + { + AgentIdentityError error = FrameworkLevelError(name); + + Assert.Equal(expectedStatus, error.HttpStatusCode); + Assert.Equal(expectedCode, error.Code); + } + + // CONTRACT.md §5.4 body, with the snake_case keys and the timestamp format of §1. + // Parsing the raw bytes also rejects a byte order mark, which RFC 8259 §8.1 forbids in a JSON text. + [Fact] + public void JsonBodyHasExactlyTheContractMembers() + { + byte[] body = AgentIdentityError.From(AgentIdentityErrorCode.ClockSkew).ToJsonBody(ServerTime); + + using JsonDocument document = JsonDocument.Parse(body); + Dictionary members = document.RootElement + .EnumerateObject() + .ToDictionary(member => member.Name, member => member.Value); + Assert.Equal(["error", "message", "server_time"], members.Keys.Order(StringComparer.Ordinal)); + Assert.Equal("clock_skew", members["error"].GetString()); + Assert.NotEmpty(members["message"].GetString()!); + Assert.Equal(ServerTimeText, members["server_time"].GetString()); + } + + // CONTRACT.md §7.4: a refused channel opening carries the code's status. + // §7.3 step 4: invalid `Hello` metadata gets INVALID_ARGUMENT. + [Theory] + [InlineData(AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated)] + [InlineData(AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied)] + [InlineData(AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument)] + [InlineData(AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable)] + public void RpcExceptionHasTheChannelStatus(AgentIdentityErrorCode code, StatusCode expected) + { + RpcException exception = AgentIdentityError.From(code).ToRpcException(ServerTime); + + Assert.Equal(expected, exception.StatusCode); + } + + // CONTRACT.md §7.4: trailers `error-code: <§5.4 code>` and `server-time: `. + [Fact] + public void RpcExceptionHasExactlyTheContractTrailers() + { + RpcException exception = AgentIdentityError.From(AgentIdentityErrorCode.ClockSkew).ToRpcException(ServerTime); + + Assert.Equal( + [("error-code", "clock_skew"), ("server-time", ServerTimeText)], + exception.Trailers.Select(entry => (entry.Key, entry.Value))); + } + + private static AgentIdentityError FrameworkLevelError(string name) + { + return name switch + { + nameof(AgentIdentityError.MalformedBody) => AgentIdentityError.MalformedBody, + nameof(AgentIdentityError.UnknownRoute) => AgentIdentityError.UnknownRoute, + nameof(AgentIdentityError.MethodNotAllowed) => AgentIdentityError.MethodNotAllowed, + nameof(AgentIdentityError.BodyTooLarge) => AgentIdentityError.BodyTooLarge, + nameof(AgentIdentityError.UnexpectedFailure) => AgentIdentityError.UnexpectedFailure, + nameof(AgentIdentityError.ServiceUnavailable) => AgentIdentityError.ServiceUnavailable, + _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Not a framework-level error."), + }; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityProtocolTests.cs new file mode 100644 index 0000000000..0965e2a1fc --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityProtocolTests.cs @@ -0,0 +1,77 @@ +using System.Globalization; + +namespace Devolutions.AgentIdentity.Tests; + +public class AgentIdentityProtocolTests +{ + // CONTRACT.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. + // A timestamp names the whole second that contains the instant. + [Theory] + [InlineData("2026-09-24T12:00:00+00:00", "2026-09-24T12:00:00Z")] + [InlineData("2026-09-24T14:00:00+02:00", "2026-09-24T12:00:00Z")] + [InlineData("2026-09-24T23:30:00-01:00", "2026-09-25T00:30:00Z")] + [InlineData("2026-09-24T12:00:00.9999999+00:00", "2026-09-24T12:00:00Z")] + public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected) + { + DateTimeOffset value = DateTimeOffset.Parse(instant, CultureInfo.InvariantCulture); + + Assert.Equal(expected, AgentIdentityProtocol.FormatTimestamp(value)); + } + + // A culture-sensitive format would write the Thai Buddhist year 2569 and the time separator of this culture. + [Fact] + public void FormatTimestampIgnoresTheCurrentCulture() + { + CultureInfo culture = (CultureInfo)CultureInfo.GetCultureInfo("th-TH").Clone(); + culture.DateTimeFormat.TimeSeparator = "."; + CultureInfo previous = CultureInfo.CurrentCulture; + DateTimeOffset value = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + + try + { + CultureInfo.CurrentCulture = culture; + + Assert.Equal("2026-09-24T12:00:00Z", AgentIdentityProtocol.FormatTimestamp(value)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + // CONTRACT.md §1: a thumbprint is base64url(SHA-256(certificate DER)). + // The vector file gives the thumbprint of each certificate. + [Theory] + [MemberData(nameof(VectorData.KeyNames), MemberType = typeof(VectorData))] + public void KeyCertificateHasTheVectorThumbprint(string name) + { + KeyVector key = TestVectors.Instance.Key(name); + + Assert.Equal(key.Thumbprint, AgentIdentityProtocol.ComputeThumbprint(key.Certificate)); + } + + [Fact] + public void RootCertificateHasTheVectorThumbprint() + { + RootVector root = TestVectors.Instance.Root; + + Assert.Equal(root.Thumbprint, AgentIdentityProtocol.ComputeThumbprint(root.Certificate)); + } + + // CONTRACT.md §6: a request's `keyid` is the thumbprint of the certificate that signs it. + [Theory] + [MemberData(nameof(VectorData.RegisteredCertificateNames), MemberType = typeof(VectorData))] + public void RegisteredCertificateHasTheVectorKeyId(string name) + { + RegisteredCertificateVector registered = TestVectors.Instance.HttpSignature.RegisteredCertificate(name); + + Assert.Equal(registered.KeyId, AgentIdentityProtocol.ComputeThumbprint(registered.Certificate)); + } + + // CONTRACT.md §5.4: servers accept request bodies up to 64 KiB. + [Fact] + public void MaxRequestBodyBytesIs64KiB() + { + Assert.Equal(65536, AgentIdentityProtocol.MaxRequestBodyBytes); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs new file mode 100644 index 0000000000..88c4847929 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs @@ -0,0 +1,194 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The shared vectors of docs/agent-identity/test-vectors.json, which the test project copies into its output. +/// +/// +/// The BCL decodes the byte strings, never the library under test, +/// so that a decoding bug in the library cannot make a vector agree with it. +/// Loading fails on a missing or unknown member, +/// so that a change to the file that the loader does not model is noticed rather than ignored. +/// Unix-second timestamps are read as . +/// +public sealed record TestVectors( + int Version, + string Description, + string Evaluation, + IReadOnlyList Keys, + RootVector Root, + IReadOnlyDictionary Csr, + HttpSignatureVectors HttpSignature, + ChannelProofVectors ChannelProof, + [property: JsonPropertyName("rfc9421_b_2_4")] Rfc9421Vector Rfc9421B24) +{ + private const int SupportedVersion = 1; + + private static readonly Lazy s_instance = new(Load); + + public static TestVectors Instance => s_instance.Value; + + public KeyVector Key(string name) + { + return Keys.Single(key => key.Name == name); + } + + private static TestVectors Load() + { + JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + RespectNullableAnnotations = true, + RespectRequiredConstructorParameters = true, + Converters = { new UnixSecondsConverter() }, + }; + + using FileStream file = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "test-vectors.json")); + TestVectors vectors = JsonSerializer.Deserialize(file, options) + ?? throw new InvalidDataException("The vector file holds null."); + + if (vectors.Version != SupportedVersion) + { + throw new InvalidDataException( + $"The loader reads version {SupportedVersion} of the vector file, not version {vectors.Version}."); + } + + return vectors; + } +} + +public sealed record KeyVector( + string Name, + Guid IdentityId, + byte[] PrivateKeyPkcs8, + byte[] PublicKeySec1, + byte[] Certificate, + string Thumbprint); + +public sealed record RootVector(byte[] Certificate, string Thumbprint, byte[] PublicKeySec1); + +public sealed record CsrVector(byte[] Csr, string Expected, string? Description = null, byte[]? PublicKeySec1 = null); + +public sealed record HttpSignatureVectors( + IReadOnlyList Registered, + IReadOnlyList Cases, + IReadOnlyList Sequences) +{ + public RegisteredCertificateVector RegisteredCertificate(string name) + { + return Registered.Single(registered => registered.Name == name); + } + + public HttpSignatureCaseVector Case(string name) + { + return Cases.Single(c => c.Name == name); + } + + public HttpSignatureSequenceVector Sequence(string name) + { + return Sequences.Single(sequence => sequence.Name == name); + } +} + +public sealed record RegisteredCertificateVector( + string Name, + Guid IdentityId, + [property: JsonPropertyName("keyid")] string KeyId, + byte[] PublicKeySec1, + byte[] Certificate, + string CertificateStatus, + bool IdentityRevoked, + DateTimeOffset NotBefore, + DateTimeOffset NotAfter); + +/// ok or the wire value of an error code. +/// The request body as JSON text, absent for operations without one. +public sealed record HttpSignatureCaseVector( + string Name, + string Description, + string Endpoint, + string Method, + DateTimeOffset Now, + HttpSignatureHeaders Headers, + string SignatureBase, + string Expected, + string? Body = null) +{ + // The vector's content digest covers the UTF-8 encoding of the body text. + public byte[] BodyBytes => Body is null ? [] : Encoding.UTF8.GetBytes(Body); +} + +/// +/// The signature fields of a request, each a single field line; an absent field is null. +/// +public sealed record HttpSignatureHeaders( + [property: JsonPropertyName("signature-input")] string? SignatureInput = null, + [property: JsonPropertyName("signature")] string? Signature = null, + [property: JsonPropertyName("content-digest")] string? ContentDigest = null); + +/// Names of cases to run in order against one nonce store. +/// The outcome of each step, as in . +public sealed record HttpSignatureSequenceVector( + string Name, + string Description, + IReadOnlyList Steps, + IReadOnlyList Expected); + +/// +/// The nonce parameter of the opening request, whose UTF-8 bytes end the proof input. +/// +/// The proof input that the valid proof signs. +public sealed record ChannelProofVectors( + string Description, + string Device, + byte[] PublicKeySec1, + byte[] Challenge, + string ConnectNonce, + string ConnectCase, + [property: JsonPropertyName("message_hex"), JsonConverter(typeof(HexConverter))] byte[] Message, + IReadOnlyList Cases) +{ + public ChannelProofCaseVector Case(string name) + { + return Cases.Single(c => c.Name == name); + } +} + +public sealed record ChannelProofCaseVector(string Name, string Description, byte[] Proof, bool ExpectedValid); + +public sealed record Rfc9421Vector( + string Description, + byte[] PublicKeySpki, + string SignatureBase, + byte[] Signature, + bool ExpectedValid); + +internal sealed class UnixSecondsConverter : JsonConverter +{ + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateTimeOffset.FromUnixTimeSeconds(reader.GetInt64()); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.ToUnixTimeSeconds()); + } +} + +internal sealed class HexConverter : JsonConverter +{ + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Convert.FromHexString(reader.GetString() ?? throw new JsonException("Expected a hexadecimal string.")); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + writer.WriteStringValue(Convert.ToHexStringLower(value)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorData.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorData.cs new file mode 100644 index 0000000000..d46979e69a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorData.cs @@ -0,0 +1,25 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Theory data that yields one row per vector name, so that each vector runs and reports as its own test. +/// +public static class VectorData +{ + public static TheoryData KeyNames => + new(TestVectors.Instance.Keys.Select(key => key.Name)); + + public static TheoryData CsrNames => + new(TestVectors.Instance.Csr.Keys); + + public static TheoryData RegisteredCertificateNames => + new(TestVectors.Instance.HttpSignature.Registered.Select(registered => registered.Name)); + + public static TheoryData HttpSignatureCaseNames => + new(TestVectors.Instance.HttpSignature.Cases.Select(c => c.Name)); + + public static TheoryData HttpSignatureSequenceNames => + new(TestVectors.Instance.HttpSignature.Sequences.Select(sequence => sequence.Name)); + + public static TheoryData ChannelProofCaseNames => + new(TestVectors.Instance.ChannelProof.Cases.Select(c => c.Name)); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs new file mode 100644 index 0000000000..5c08d2970d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs @@ -0,0 +1,161 @@ +using System.Buffers; +using System.Text.Json; +using Grpc.Core; + +namespace Devolutions.AgentIdentity; + +/// +/// An error response of the agent-facing protocol: a code, the HTTP status that carries it, +/// and a fixed English message. +/// +/// +/// Each message is fixed for its error, so it never repeats request data, a token or a secret. +/// +public sealed class AgentIdentityError +{ + // UNTESTED: every message is a constant sentence. + // Error bodies, trailers and agent logs carry the message, + // so one built from request data could leak a token or a secret. + // When adding an error, give it a constant message and keep this constructor private. + private AgentIdentityError(AgentIdentityErrorCode code, int httpStatusCode, string message) + { + Code = code; + HttpStatusCode = httpStatusCode; + Message = message; + } + + /// + /// The error code. + /// + public AgentIdentityErrorCode Code { get; } + + /// + /// The HTTP status of the response. + /// + public int HttpStatusCode { get; } + + /// + /// A fixed English sentence describing the error. + /// + public string Message { get; } + + /// + /// The error for a request body that is not valid JSON or does not have the expected shape: + /// 400 invalid_request. + /// + public static AgentIdentityError MalformedBody { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 400, "The request body is malformed."); + + /// + /// The error for a path under the agent identity base path that no endpoint serves: 404 invalid_request. + /// + public static AgentIdentityError UnknownRoute { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 404, "No endpoint serves this path."); + + /// + /// The error for an HTTP method that the endpoint does not accept: 405 invalid_request. + /// + public static AgentIdentityError MethodNotAllowed { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 405, "The endpoint does not accept this method."); + + /// + /// The error for a request body larger than : + /// 413 invalid_request. + /// + public static AgentIdentityError BodyTooLarge { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 413, "The request body is larger than 64 KiB."); + + /// + /// The error for an unexpected server failure: 500 unavailable. + /// + public static AgentIdentityError UnexpectedFailure { get; } = + new(AgentIdentityErrorCode.Unavailable, 500, "The server failed unexpectedly."); + + /// + /// The error for a server that is overloaded or missing a dependency: 503 unavailable. + /// + /// + /// When the host sheds load, it also sends a Retry-After header, which the agent honors; + /// the library writes no HTTP headers. + /// + public static AgentIdentityError ServiceUnavailable { get; } = + new( + AgentIdentityErrorCode.Unavailable, + 503, + "The server is overloaded or a service it depends on is unavailable."); + + /// + /// Returns the error for a code, + /// with the HTTP status of . + /// + /// The error code. + /// is not a defined code. + public static AgentIdentityError From(AgentIdentityErrorCode code) + { + return new AgentIdentityError(code, code.ToHttpStatusCode(), MessageFor(code)); + } + + /// + /// Writes the body of the error response: + /// a UTF-8 JSON object with the members error, message and server_time. + /// + /// + /// The server's current time, + /// which the agent uses to correct its clock after . + /// + public byte[] ToJsonBody(DateTimeOffset serverTime) + { + ArrayBufferWriter buffer = new(); + + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WriteString("error", Code.ToWireValue()); + writer.WriteString("message", Message); + writer.WriteString("server_time", AgentIdentityProtocol.FormatTimestamp(serverTime)); + writer.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + /// + /// Creates the exception that closes an agent channel stream with this error. + /// + /// + /// The exception carries the status of + /// and the trailers error-code and server-time. + /// + /// + /// The server's current time, + /// which the agent uses to correct its clock after . + /// + public RpcException ToRpcException(DateTimeOffset serverTime) + { + Metadata trailers = new() + { + { "error-code", Code.ToWireValue() }, + { "server-time", AgentIdentityProtocol.FormatTimestamp(serverTime) }, + }; + + return new RpcException(new Status(Code.ToGrpcStatusCode(), Message), trailers); + } + + private static string MessageFor(AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.TokenInvalid => "The enrollment token is not valid.", + AgentIdentityErrorCode.TokenExhausted => "The enrollment token has reached its use limit.", + AgentIdentityErrorCode.TokenExpired => "The enrollment token has expired.", + AgentIdentityErrorCode.IdentityRevoked => "The identity is revoked.", + AgentIdentityErrorCode.IdentityUnknown => "The certificate is not registered, or its identity was deleted.", + AgentIdentityErrorCode.CertificateExpired => "The certificate has expired.", + AgentIdentityErrorCode.SignatureInvalid => "The signature is not valid.", + AgentIdentityErrorCode.ClockSkew => "The signature time is outside the accepted window.", + AgentIdentityErrorCode.InvalidRequest => "The request body, CSR or metadata is not valid.", + AgentIdentityErrorCode.Unavailable => "The server cannot serve the request now.", + _ => throw AgentIdentityErrorCodeExtensions.UndefinedCode(code), + }; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCode.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCode.cs new file mode 100644 index 0000000000..17c4b423ea --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCode.cs @@ -0,0 +1,66 @@ +namespace Devolutions.AgentIdentity; + +/// +/// An error code of the agent-facing protocol. +/// +/// +/// The agent decides from the code whether to retry, to discard a pending enrollment, or to stop using an identity, +/// so each code keeps the meaning and the statuses that gives it. +/// +public enum AgentIdentityErrorCode +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a code would change what an existing host sends. + // When adding a code, give it the next unused value; + // values start at 1 so that an uninitialized code is not a valid one. + + /// + /// The enrollment token is unknown or deleted, or its secret does not match. + /// + TokenInvalid = 1, + + /// + /// The enrollment token has created its maximum number of identities. + /// + TokenExhausted = 2, + + /// + /// The enrollment token has expired. + /// + TokenExpired = 3, + + /// + /// The identity is revoked. + /// + IdentityRevoked = 4, + + /// + /// The certificate is not registered, or its identity was deleted. + /// + IdentityUnknown = 5, + + /// + /// The certificate has expired, or for a renewal, is beyond its grace. + /// + CertificateExpired = 6, + + /// + /// The signature, its parameters, the body digest or the channel proof is invalid, or the nonce was already used. + /// + SignatureInvalid = 7, + + /// + /// The signature's created or expires time is outside the accepted window. + /// + ClockSkew = 8, + + /// + /// The route, method, body, CSR or metadata is invalid. + /// + InvalidRequest = 9, + + /// + /// The server can't serve the request now, because it is overloaded or a part it needs fails. + /// + Unavailable = 10, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCodeExtensions.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCodeExtensions.cs new file mode 100644 index 0000000000..0cc7efd7e6 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCodeExtensions.cs @@ -0,0 +1,89 @@ +using Grpc.Core; + +namespace Devolutions.AgentIdentity; + +/// +/// Maps an to its wire value and to the statuses that carry it. +/// +public static class AgentIdentityErrorCodeExtensions +{ + /// + /// Returns the value sent in the error member of an error body and in the error-code trailer, + /// such as token_invalid. + /// + /// The error code. + /// is not a defined code. + public static string ToWireValue(this AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.TokenInvalid => "token_invalid", + AgentIdentityErrorCode.TokenExhausted => "token_exhausted", + AgentIdentityErrorCode.TokenExpired => "token_expired", + AgentIdentityErrorCode.IdentityRevoked => "identity_revoked", + AgentIdentityErrorCode.IdentityUnknown => "identity_unknown", + AgentIdentityErrorCode.CertificateExpired => "certificate_expired", + AgentIdentityErrorCode.SignatureInvalid => "signature_invalid", + AgentIdentityErrorCode.ClockSkew => "clock_skew", + AgentIdentityErrorCode.InvalidRequest => "invalid_request", + AgentIdentityErrorCode.Unavailable => "unavailable", + _ => throw UndefinedCode(code), + }; + } + + /// + /// Returns the HTTP status of a response carrying the code. + /// + /// + /// maps to 500, the status of an unexpected failure. + /// The framework-level errors of , + /// such as , carry other statuses with the same codes. + /// + /// The error code. + /// is not a defined code. + public static int ToHttpStatusCode(this AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.TokenInvalid => 401, + AgentIdentityErrorCode.TokenExhausted => 403, + AgentIdentityErrorCode.TokenExpired => 401, + AgentIdentityErrorCode.IdentityRevoked => 403, + AgentIdentityErrorCode.IdentityUnknown => 401, + AgentIdentityErrorCode.CertificateExpired => 401, + AgentIdentityErrorCode.SignatureInvalid => 401, + AgentIdentityErrorCode.ClockSkew => 401, + AgentIdentityErrorCode.InvalidRequest => 400, + AgentIdentityErrorCode.Unavailable => 500, + _ => throw UndefinedCode(code), + }; + } + + /// + /// Returns the gRPC status that closes an agent channel stream with the code. + /// + /// The error code. + /// is not a defined code. + public static StatusCode ToGrpcStatusCode(this AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.IdentityRevoked => StatusCode.PermissionDenied, + AgentIdentityErrorCode.InvalidRequest => StatusCode.InvalidArgument, + AgentIdentityErrorCode.Unavailable => StatusCode.Unavailable, + AgentIdentityErrorCode.TokenInvalid + or AgentIdentityErrorCode.TokenExhausted + or AgentIdentityErrorCode.TokenExpired + or AgentIdentityErrorCode.IdentityUnknown + or AgentIdentityErrorCode.CertificateExpired + or AgentIdentityErrorCode.SignatureInvalid + or AgentIdentityErrorCode.ClockSkew => StatusCode.Unauthenticated, + _ => throw UndefinedCode(code), + }; + } + + internal static ArgumentOutOfRangeException UndefinedCode(AgentIdentityErrorCode code) + { + return new ArgumentOutOfRangeException(nameof(code), code, "Not a defined agent identity error code."); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityProtocol.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityProtocol.cs new file mode 100644 index 0000000000..8302b40410 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityProtocol.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity; + +/// +/// Limits and encodings that every agent-facing endpoint shares. +/// +public static class AgentIdentityProtocol +{ + /// + /// The largest request body, in bytes, that an agent-facing endpoint accepts. + /// + /// + /// A larger body gets before any authentication. + /// + public const int MaxRequestBodyBytes = 64 * 1024; + + /// + /// Formats an instant as an RFC 3339 UTC timestamp in whole seconds, such as 2026-09-24T12:00:00Z. + /// + /// + /// The fraction of a second is dropped. + /// + /// The instant, in any offset. + public static string FormatTimestamp(DateTimeOffset value) + { + // The seconds specifier drops the fraction, so the timestamp names the second that contains the instant. + return value.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture); + } + + /// + /// Computes the thumbprint of a certificate: base64url(SHA-256(certificate DER)), without padding. + /// + /// + /// A signed request names its certificate by this thumbprint in its keyid parameter. + /// + /// The DER encoding of the certificate. + public static string ComputeThumbprint(ReadOnlySpan certificateDer) + { + return StrictBase64.EncodeBase64Url(SHA256.HashData(certificateDer)); + } +} From 82c7bff5677fb88035e2b7c1d4ec9978c708172c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 17:37:52 +0900 Subject: [PATCH 05/93] ci(dotnet-agent-identity): test and publish the authority library Run the Devolutions.AgentIdentity tests and build its package in the .NET utils job, and add the package to the NuGet publish matrix next to Devolutions.Agent.Channel, which it depends on. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++++++ .github/workflows/publish-libraries.yml | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49903a7ab9..032cf21261 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1353,6 +1353,14 @@ jobs: run: ./crates/agent-channel-proto/dotnet/build.ps1 shell: pwsh + - name: Agent identity tests + run: dotnet test dotnet/AgentIdentity/AgentIdentity.sln + shell: pwsh + + - name: Build agent identity package + run: ./dotnet/AgentIdentity/build.ps1 + shell: pwsh + agent-identity-vectors: name: Agent Identity test vectors runs-on: ubuntu-latest diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index 2faa724fdf..723c0b4fc6 100644 --- a/.github/workflows/publish-libraries.yml +++ b/.github/workflows/publish-libraries.yml @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - library: [ dotnet-client, dotnet-subscriber, utils, pedm-dotnet-client, agent-channel ] + library: [ dotnet-client, dotnet-subscriber, utils, pedm-dotnet-client, agent-channel, agent-identity ] include: - library: dotnet-client libpath: ./devolutions-gateway/openapi/dotnet-client @@ -52,6 +52,8 @@ jobs: libpath: ./crates/devolutions-pedm/openapi/dotnet-client - library: agent-channel libpath: ./crates/agent-channel-proto/dotnet + - library: agent-identity + libpath: ./dotnet/AgentIdentity steps: - name: Check out ${{ github.repository }} From f97f80c4b724626a05235cbff384f04ea6a30da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 17:58:57 +0900 Subject: [PATCH 06/93] build(dotnet-agent-identity): keep NuGet advisories as warnings The library and test projects treat warnings as errors, so an advisory published later against any direct or transitive package would fail every build of them, including unrelated pull requests and the scheduled publish run, while the rest of the repository only warns. Exclude the NuGet audit warnings NU1901 to NU1904 from warnings as errors so that advisories stay visible without breaking builds. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity.Tests.csproj | 1 + .../Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj index 78fa2d10b2..1e07dbe17f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj @@ -4,6 +4,7 @@ enable enable true + NU1901;NU1902;NU1903;NU1904 false diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj index bf3a2836f2..d1f6da7f52 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj @@ -5,6 +5,7 @@ enable true true + NU1901;NU1902;NU1903;NU1904 Devolutions.AgentIdentity Library Devolutions Inc. From 385b89e604b1d1392d593edb0af41ef7fde51f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:46:30 +0900 Subject: [PATCH 07/93] feat(dotnet-agent-identity): add the certificate registry and nonce store Request authentication resolves a keyid only through a certificate that the authority registered, and accepts each nonce once, so the host provides both through interfaces over its own storage. RegisteredCertificate holds the certificate DER as issued, from which the validity and the key are read, with the certificate status and the identity's revocation. It refuses an undefined status, which would otherwise draw identity_unknown and end the identity for the agent. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RegisteredCertificateTests.cs | 16 +++++ .../CertificateStatus.cs | 22 +++++++ .../ICertificateRegistry.cs | 25 ++++++++ .../Devolutions.AgentIdentity/INonceStore.cs | 29 ++++++++++ .../RegisteredCertificate.cs | 58 +++++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ICertificateRegistry.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/INonceStore.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RegisteredCertificate.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs new file mode 100644 index 0000000000..2f6b0f3943 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs @@ -0,0 +1,16 @@ +namespace Devolutions.AgentIdentity.Tests; + +public class RegisteredCertificateTests +{ + // An undefined status from the host's storage must fail as a host bug, + // rather than draw identity_unknown, which ends the identity for the agent. + [Fact] + public void RefusesAnUndefinedStatus() + { + KeyVector key = TestVectors.Instance.Key("device-a"); + CertificateStatus undefined = (CertificateStatus)3; + + Assert.Throws( + () => new RegisteredCertificate(key.IdentityId, key.Certificate, undefined, identityRevoked: false)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs new file mode 100644 index 0000000000..3a6f166abe --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs @@ -0,0 +1,22 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The status of a certificate the authority issued to an identity. +/// +public enum CertificateStatus +{ + /// + /// Issued by renew and not yet confirmed. + /// + Pending, + + /// + /// The certificate the identity authenticates with. + /// + Current, + + /// + /// Replaced by a confirmed certificate. + /// + Retired, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ICertificateRegistry.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ICertificateRegistry.cs new file mode 100644 index 0000000000..f39d280f42 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ICertificateRegistry.cs @@ -0,0 +1,25 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Finds the certificates that the authority issued, by thumbprint. +/// +/// +/// The host implements it over its own storage. +/// A signed request names its certificate only by thumbprint, so this lookup is the only way it is resolved. +/// +public interface ICertificateRegistry +{ + /// + /// Finds the certificate with a thumbprint. + /// + /// + /// The thumbprint, base64url(SHA-256(certificate DER)), + /// as computes it. + /// + /// A token that cancels the lookup. + /// + /// The certificate in any status, including , + /// or null when the authority issued no certificate with this thumbprint or deleted its identity. + /// + ValueTask FindByThumbprintAsync(string thumbprint, CancellationToken cancellationToken); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/INonceStore.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/INonceStore.cs new file mode 100644 index 0000000000..efcbb89ec9 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/INonceStore.cs @@ -0,0 +1,29 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Records the nonces of accepted signatures, so that each signature is accepted at most once. +/// +/// +/// The host implements it over its own storage. +/// Request authentication records a nonce only after the signature and the body digest verify, +/// so requests that fail verification cannot fill the store. +/// +public interface INonceStore +{ + /// + /// Records a nonce for a key unless the pair is already recorded, as one atomic operation. + /// + /// The keyid of the signature, which is the thumbprint of its certificate. + /// The nonce of the signature, as its canonical base64url text. + /// + /// The last instant at which the signature could be accepted again, + /// until which the store keeps the pair; it may discard the pair afterwards. + /// + /// A token that cancels the operation. + /// True when this call recorded the pair; false when the pair was already recorded. + ValueTask TryRecordAsync( + string keyId, + string nonce, + DateTimeOffset retainUntil, + CancellationToken cancellationToken); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RegisteredCertificate.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RegisteredCertificate.cs new file mode 100644 index 0000000000..d954c9ac41 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RegisteredCertificate.cs @@ -0,0 +1,58 @@ +namespace Devolutions.AgentIdentity; + +/// +/// A certificate that the authority issued to an identity, with the state the authority keeps beside it. +/// +/// +/// Request authentication reads the validity and the public key from , +/// so the host stores no copy of them. +/// +public sealed class RegisteredCertificate +{ + /// + /// Creates a registered certificate. + /// + /// The identity that the certificate was issued to. + /// The DER encoding of the certificate, exactly as the authority issued it. + /// The status of the certificate. + /// Whether the identity is revoked. + /// is not a defined status. + public RegisteredCertificate( + Guid identityId, + ReadOnlyMemory certificateDer, + CertificateStatus status, + bool identityRevoked) + { + // An undefined status is a host bug, + // which must fail as a server error rather than draw an outcome that is terminal for the agent. + if (!Enum.IsDefined(status)) + { + throw new ArgumentOutOfRangeException(nameof(status), status, "Not a defined certificate status."); + } + + IdentityId = identityId; + CertificateDer = certificateDer; + Status = status; + IdentityRevoked = identityRevoked; + } + + /// + /// The identity that the certificate was issued to. + /// + public Guid IdentityId { get; } + + /// + /// The DER encoding of the certificate, whose thumbprint the registry looked up. + /// + public ReadOnlyMemory CertificateDer { get; } + + /// + /// The status of the certificate. + /// + public CertificateStatus Status { get; } + + /// + /// Whether the identity is revoked. + /// + public bool IdentityRevoked { get; } +} From 82219c164c90528c924fe4a5aaba9269b8397211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:48:24 +0900 Subject: [PATCH 08/93] feat(dotnet-agent-identity): authenticate signed agent requests Every authority must accept and refuse the same signed requests with the same errors, so the library runs the RFC 9421 profile of the contract for renew, confirm, check-in and the agent channel opening. RequestAuthenticator refuses a body over 64 KiB first. It then parses Signature-Input, Signature and Content-Digest with an RFC 8941 parser that fails on a repeated key instead of keeping the last one, checks the profile, the tag and the time window against one reading of the clock, resolves the keyid through the registry, verifies the digest and the signature, and records the nonce only once all of these pass. @signature-params is the serialization of the parsed member, so one parse drives both the checks and the signature base. Only AuthenticateAsync creates an AuthenticatedIdentity, and it refuses Connect. AuthenticateChannelOpeningAsync returns a ChannelOpening with a fresh challenge and no identity, since a stream is authenticated only once the agent signs that challenge. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AuthenticationTypeTests.cs | 79 +++ .../ChannelOpeningTests.cs | 118 ++++ .../RequestAuthenticatorProfileTests.cs | 276 ++++++++ .../RequestAuthenticatorTests.cs | 371 +++++++++++ .../RequestAuthenticatorVectorTests.cs | 187 ++++++ .../TestAuthority.cs | 69 ++ .../TestDoubles.cs | 105 +++ .../TestRequest.cs | 247 +++++++ .../VectorAuthority.cs | 106 +++ .../AgentOperation.cs | 36 + .../AuthenticatedIdentity.cs | 58 ++ .../AuthenticationResult.cs | 52 ++ .../ChannelOpening.cs | 66 ++ .../ChannelOpeningResult.cs | 56 ++ .../EcdsaP256Signature.cs | 30 + .../Devolutions.AgentIdentity/FieldLines.cs | 36 + .../RequestAuthenticator.cs | 398 +++++++++++ .../SignatureFields.cs | 315 +++++++++ .../SignedHttpRequest.cs | 64 ++ .../SignedOperation.cs | 87 +++ .../StructuredField.cs | 621 ++++++++++++++++++ .../VerifiedCertificate.cs | 19 + 22 files changed, 3396 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorProfileTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorVectorTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestAuthority.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestRequest.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorAuthority.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentOperation.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticationResult.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpeningResult.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EcdsaP256Signature.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/FieldLines.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/SignatureFields.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedHttpRequest.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedOperation.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/StructuredField.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/VerifiedCertificate.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs new file mode 100644 index 0000000000..dcc2d25768 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs @@ -0,0 +1,79 @@ +using System.Reflection; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The types that only request authentication creates. +/// +/// +/// INTENT.md: code that acts for an identity takes it as a type only the signature check can create, +/// so that an identity is never taken from a value the agent or a caller sent. +/// +public class AuthenticationTypeTests +{ + private static readonly Type[] AuthenticationTypes = + [ + typeof(AuthenticatedIdentity), + typeof(ChannelOpening), + typeof(AuthenticationResult), + typeof(ChannelOpeningResult), + ]; + + public static TheoryData AuthenticationTypeData => new(AuthenticationTypes); + + // A public or protected constructor would let a caller build an identity from any values, + // or a subclass of one with its own constructor. + [Theory] + [MemberData(nameof(AuthenticationTypeData))] + public void TypeHasNoPublicOrProtectedConstructor(Type type) + { + IEnumerable reachable = type + .GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) + .Where(IsReachableFromAnotherAssembly); + + Assert.Empty(reachable); + } + + // Any other public member producing one of these types would be a factory that bypasses the signature check. + [Fact] + public void OnlyAuthenticationProducesTheseTypes() + { + const BindingFlags declared = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + IEnumerable producers = typeof(RequestAuthenticator).Assembly + .GetExportedTypes() + .SelectMany(type => type.GetMethods(declared) + .Where(IsReachableFromAnotherAssembly) + .Where(method => Produces(method.ReturnType)) + .Select(method => $"{type.Name}.{method.Name}") + .Concat(type.GetFields(declared) + .Where(field => field.IsPublic || field.IsFamily || field.IsFamilyOrAssembly) + .Where(field => Produces(field.FieldType)) + .Select(field => $"{type.Name}.{field.Name}"))); + + Assert.Equal( + [ + "AuthenticationResult.get_Identity", + "ChannelOpeningResult.get_Opening", + "RequestAuthenticator.AuthenticateAsync", + "RequestAuthenticator.AuthenticateChannelOpeningAsync", + ], + producers.Order(StringComparer.Ordinal)); + } + + private static bool IsReachableFromAnotherAssembly(MethodBase method) + { + return method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly; + } + + private static bool Produces(Type type) + { + Type? definition = type.IsGenericType ? type.GetGenericTypeDefinition() : null; + Type produced = definition == typeof(ValueTask<>) || definition == typeof(Task<>) + ? type.GetGenericArguments()[0] + : type; + + return AuthenticationTypes.Contains(produced); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs new file mode 100644 index 0000000000..dace2966ce --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography; + +using Devolutions.Agent.Channel.V1; +using Google.Protobuf; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The channel opening of CONTRACT.md §7.3 steps 1 and 2: +/// the verified opening request and the challenge that follows it. +/// +public class ChannelOpeningTests +{ + private const string UuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + + private static ChannelProofVectors ChannelProof => TestVectors.Instance.ChannelProof; + + // CONTRACT.md §7.3 step 2: the first message is Challenge, with 32 random bytes fresh for this stream. + // CONTRACT.md §1 and §7.3: ids are lowercase hyphenated UUIDs. + // The bytes come from the injected generator, the host's source of every secret byte. + [Fact] + public async Task ChallengeHolds32BytesFromTheInjectedGenerator() + { + TestAuthority authority = new(TestAuthority.DefaultNow); + + ChannelOpening opening = await OpenAsync(authority, TestRequest.DefaultNonce); + + ServerMessage challenge = opening.Challenge; + Assert.Equal(ServerMessage.PayloadOneofCase.Challenge, challenge.PayloadCase); + Assert.Equal(Enumerable.Range(0, 32).Select(i => (byte)i), challenge.Challenge.Challenge_.ToByteArray()); + Assert.Matches(UuidPattern, challenge.Id); + Assert.False(challenge.HasCorrelationId); + } + + // A challenge reused across streams would let a proof captured on one stream authenticate another. + [Fact] + public async Task EachOpeningHasItsOwnChallenge() + { + TestAuthority authority = new(TestAuthority.DefaultNow); + + ChannelOpening first = await OpenAsync(authority, TestRequest.DefaultNonce); + ChannelOpening second = await OpenAsync(authority, "EBESExQVFhcYGRobHB0eHw"); + + Assert.NotEqual(first.Challenge.Id, second.Challenge.Id); + Assert.Equal( + Enumerable.Range(32, 32).Select(i => (byte)i), + second.Challenge.Challenge.Challenge_.ToByteArray()); + } + + // CONTRACT.md §7.3 steps 3 and 4: the proof covers the challenge as sent and the connect nonce, + // and is verified with the key of the certificate that authenticated the opening, + // so the opening keeps each of them for the handshake. + [Fact] + public async Task OpeningKeepsWhatTheChannelProofCovers() + { + VectorAuthority authority = new(); + HttpSignatureCaseVector connect = TestVectors.Instance.HttpSignature.Case(ChannelProof.ConnectCase); + KeyVector device = TestVectors.Instance.Key(ChannelProof.Device); + RegisteredCertificateVector registered = + TestVectors.Instance.HttpSignature.RegisteredCertificate(ChannelProof.Device); + authority.Clock.Now = connect.Now; + + ChannelOpeningResult result = await authority.Authenticator.AuthenticateChannelOpeningAsync( + connect.Method, + VectorAuthority.Lines(connect.Headers.SignatureInput), + VectorAuthority.Lines(connect.Headers.Signature)); + + Assert.True(result.Succeeded); + ChannelOpening opening = result.Opening; + Assert.Equal(ChannelProof.ConnectNonce, opening.ConnectNonce); + Assert.Equal(ChannelProof.PublicKeySec1, Sec1PublicKey(opening.Certificate.SubjectPublicKeyInfo)); + Assert.Equal(device.Thumbprint, opening.CertificateThumbprint); + Assert.Equal(registered.IdentityId, opening.Certificate.IdentityId); + Assert.Equal(registered.CertificateStatus, opening.Certificate.Status.ToString().ToLowerInvariant()); + Assert.Equal(registered.NotBefore, opening.Certificate.NotBefore); + Assert.Equal(registered.NotAfter, opening.Certificate.NotAfter); + Assert.Equal(opening.Challenge.Id, opening.ChallengeId); + Assert.Equal(opening.Challenge.Challenge.Challenge_.ToByteArray(), opening.ChallengeBytes.ToArray()); + } + + // The handshake checks the proof against the challenge as sent, + // so a host that edits the message object after sending it must not change what the agent has to sign. + [Fact] + public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() + { + TestAuthority authority = new(TestAuthority.DefaultNow); + ChannelOpening opening = await OpenAsync(authority, TestRequest.DefaultNonce); + string sentId = opening.Challenge.Id; + byte[] sentBytes = opening.Challenge.Challenge.Challenge_.ToByteArray(); + + opening.Challenge.Id = Guid.NewGuid().ToString("D"); + opening.Challenge.Challenge.Challenge_ = ByteString.CopyFrom(new byte[32]); + + Assert.Equal(sentId, opening.ChallengeId); + Assert.Equal(sentBytes, opening.ChallengeBytes.ToArray()); + } + + private static async Task OpenAsync(TestAuthority authority, string nonce) + { + SignedHttpRequest request = authority.Valid(AgentOperation.Connect, nonce: nonce).ToHttpRequest(); + ChannelOpeningResult result = await authority.Authenticator.AuthenticateChannelOpeningAsync( + request.Method, + request.SignatureInput, + request.Signature); + + Assert.True(result.Succeeded, result.FailureDetail); + return result.Opening; + } + + // SEC 1 §2.3.3: an uncompressed point is 0x04, then X and Y. + private static byte[] Sec1PublicKey(byte[] subjectPublicKeyInfo) + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(subjectPublicKeyInfo, out _); + ECParameters parameters = key.ExportParameters(includePrivateParameters: false); + return [0x04, .. parameters.Q.X!, .. parameters.Q.Y!]; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorProfileTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorProfileTests.cs new file mode 100644 index 0000000000..c34cd91f2d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorProfileTests.cs @@ -0,0 +1,276 @@ +using System.Globalization; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The parsing rules of CONTRACT.md §6 step 1, beyond the vectors. +/// +/// +/// Each rejected request is signed over the base that a verifier without the rule under test would build, +/// so such a verifier would accept it, and only that rule can reject it. +/// +public class RequestAuthenticatorProfileTests +{ + private const string OtherNonce = "EBESExQVFhcYGRobHB0eHw"; + + // The 16 bytes "0123456789:;<=>?", whose base64url holds a '-'. + private const string NonceWithDash = "MDEyMzQ1Njc4OTo7PD0-Pw"; + + private static readonly DateTimeOffset Now = TestAuthority.DefaultNow; + + private static readonly Dictionary> StepOneViolations = new() + { + // RFC 8941 keeps the last of repeated keys; a verifier doing so reads the request that is signed here. + ["a parameter repeated with the same value"] = () => + RenewWithSignatureInput(signed => [$"sig={signed};tag=\"renew\""]), + ["a parameter repeated with another value"] = () => + Renew().WithParameter("nonce", Quoted(OtherNonce)) + .ToHttpRequest(signatureInput: [$"sig={Renew().SignatureParams};nonce={Quoted(OtherNonce)}"]), + ["Signature-Input repeating sig in one field line"] = () => + RenewWithSignatureInput(signed => [$"sig={signed}, sig={signed}"]), + ["Signature-Input repeating sig across two field lines"] = () => + RenewWithSignatureInput(signed => [$"sig={signed}", $"sig={signed}"]), + ["Signature-Input with a second label in a second field line"] = () => + RenewWithSignatureInput(signed => [$"sig={signed}", $"sig2={signed}"]), + ["Signature repeating sig in one field line"] = () => + RenewWithSignature(signature => [$"sig=:{signature}:, sig=:{signature}:"]), + ["Signature repeating sig across two field lines"] = () => + RenewWithSignature(signature => [$"sig=:{signature}:", $"sig=:{signature}:"]), + ["Signature with a second label in a second field line"] = () => + RenewWithSignature(signature => [$"sig=:{signature}:", $"sig2=:{signature}:"]), + + // RFC 8941 §4.2.7 lets a parser accept these forms, and they decode to the signed bytes. + ["Signature without padding"] = () => + RenewWithSignature(signature => [$"sig=:{signature.TrimEnd('=')}:"]), + ["Signature with non-zero pad bits"] = () => + RenewWithSignature(signature => [$"sig=:{Base64Forms.WithNonZeroPadBits(signature, urlSafe: false)}:"]), + ["Signature in the base64url alphabet"] = () => + { + TestRequest request = Renew(); + string signature = request.SignUntil(text => text.Contains('+') || text.Contains('/')); + return request.ToHttpRequest(signature: [$"sig=:{Base64Forms.ToBase64UrlAlphabet(signature)}:"]); + }, + ["a parameter on the Signature member"] = () => + RenewWithSignature(signature => [$"sig=:{signature}:;x=1"]), + + // Each of these keyid forms is no registered thumbprint, so without the rule the lookup draws identity_unknown. + ["keyid with padding"] = () => + RenewWith("keyid", Quoted($"{DeviceA.Thumbprint}=")), + ["keyid with non-zero pad bits"] = () => + RenewWith("keyid", Quoted(Base64Forms.WithNonZeroPadBits(DeviceA.Thumbprint, urlSafe: true))), + ["keyid in the base64 alphabet"] = () => + RenewWith("keyid", Quoted(Base64Forms.ToBase64Alphabet(DeviceA.Thumbprint))), + ["keyid of 31 bytes"] = () => + RenewWith("keyid", Quoted(Base64Forms.Base64Url(ThumbprintBytes()[..31]))), + ["keyid of 33 bytes"] = () => + RenewWith("keyid", Quoted(Base64Forms.Base64Url([.. ThumbprintBytes(), 0]))), + + ["nonce with padding"] = () => + RenewWith("nonce", Quoted($"{TestRequest.DefaultNonce}==")), + ["nonce with non-zero pad bits"] = () => + RenewWith("nonce", Quoted(Base64Forms.WithNonZeroPadBits(TestRequest.DefaultNonce, urlSafe: true))), + ["nonce in the base64 alphabet"] = () => + RenewWith("nonce", Quoted(Base64Forms.ToBase64Alphabet(NonceWithDash))), + ["nonce of 15 bytes"] = () => + RenewWith("nonce", Quoted(Base64Forms.Base64Url(new byte[15]))), + ["nonce of 17 bytes"] = () => + RenewWith("nonce", Quoted(Base64Forms.Base64Url(new byte[17]))), + + ["created as a string"] = () => RenewWith("created", Quoted(Created)), + ["created as a decimal"] = () => RenewWith("created", $"{Created}.0"), + ["expires as a string"] = () => RenewWith("expires", Quoted(Expires)), + ["expires as a decimal"] = () => RenewWith("expires", $"{Expires}.0"), + ["alg as a token"] = () => RenewWith("alg", "ecdsa-p256-sha256"), + + // A token starts with a letter, which the thumbprint of device-c does. + ["keyid as a token"] = () => + TestRequest.Valid(AgentOperation.Renew, Now, DeviceC) + .WithParameter("keyid", DeviceC.Thumbprint) + .ToHttpRequest(), + ["nonce as a token"] = () => RenewWith("nonce", TestRequest.DefaultNonce), + ["tag as a token"] = () => RenewWith("tag", "renew"), + + // No integer is a valid alg, keyid, nonce or tag, so these also break a rule on the value. + ["alg as an integer"] = () => RenewWith("alg", "256"), + ["keyid as an integer"] = () => RenewWith("keyid", "1"), + ["nonce as an integer"] = () => RenewWith("nonce", "1"), + ["tag as an integer"] = () => RenewWith("tag", "1"), + + // RFC 9421 §2.1.1 defines sf, which leaves this Content-Digest value unchanged in the base. + ["a parameter on a covered component"] = () => + (Renew() with { Components = ["\"@method\"", "\"content-digest\";sf"] }).ToHttpRequest(), + }; + + private static readonly Dictionary> ContentDigestViolations = new() + { + ["absent"] = request => request.ToHttpRequest(contentDigest: []), + ["with sha-256 and sha-512"] = request => + WithContentDigest(request, $"{request.ContentDigest}, {Sha512Digest(request.Body)}"), + ["repeating sha-256"] = request => + WithContentDigest(request, $"{request.ContentDigest}, {request.ContentDigest}"), + ["with a parameter"] = request => + WithContentDigest(request, $"{request.ContentDigest};x=1"), + ["with only sha-512"] = request => + WithContentDigest(request, Sha512Digest(request.Body)), + ["without padding"] = request => + WithContentDigest(request, $"sha-256=:{Sha256Base64(request.Body).TrimEnd('=')}:"), + ["with non-zero pad bits"] = request => + WithContentDigest( + request, + $"sha-256=:{Base64Forms.WithNonZeroPadBits(Sha256Base64(request.Body), urlSafe: false)}:"), + }; + + private static readonly Dictionary> AcceptedForms = new() + { + ["the form the profile shows"] = () => Renew().ToHttpRequest(), + ["parameters in reverse order"] = () => + (Renew() with { Parameters = [.. Renew().Parameters.Reverse()] }).ToHttpRequest(), + ["created and expires after the other parameters"] = () => + (Renew() with { Parameters = [.. Renew().Parameters.OrderBy(p => p.Key is "created" or "expires")] }) + .ToHttpRequest(), + + // The signed base serializes the parsed member, so the white space RFC 8941 allows never reaches it. + ["a space after each semicolon"] = () => + RenewWithSignatureInput(signed => [$"sig={signed.Replace(";", "; ")}"]), + ["spaces inside the inner list"] = () => + { + TestRequest request = Renew(); + return request.ToHttpRequest( + signatureInput: [$"sig=( {string.Join(" ", request.Components)} ){request.ParametersText}"]); + }, + + // A proxy that combines field lines can leave white space around each one; RFC 9421 §2.1 removes it. + ["white space around each field line"] = () => + { + TestRequest request = Renew(); + return request.ToHttpRequest( + signatureInput: [$" \tsig={request.SignatureParams}\t "], + signature: [$"\t sig=:{request.Sign()}: \t"], + contentDigest: [$" {request.ContentDigest}\t "]); + }, + }; + + public static TheoryData StepOneViolationNames => new(StepOneViolations.Keys); + + public static TheoryData ContentDigestViolationCases + { + get + { + TheoryData cases = new(); + + foreach (AgentOperation operation in new[] { AgentOperation.Renew, AgentOperation.CheckIn }) + { + foreach (string violation in ContentDigestViolations.Keys) + { + cases.Add(operation, violation); + } + } + + return cases; + } + } + + public static TheoryData AcceptedFormNames => new(AcceptedForms.Keys); + + private static KeyVector DeviceA => TestVectors.Instance.Key("device-a"); + + private static KeyVector DeviceC => TestVectors.Instance.Key("device-c"); + + private static string Created => Now.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + + private static string Expires => (Now.ToUnixTimeSeconds() + 60).ToString(CultureInfo.InvariantCulture); + + // A verifier that keeps the last of repeated keys, decodes leniently, or reads a parameter of another type + // accepts a request whose signature data a proxy, a log or another parser can read differently. + [Theory] + [MemberData(nameof(StepOneViolationNames))] + public async Task CorrectlySignedRequestBreakingAStepOneRuleIsSignatureInvalid(string violation) + { + TestAuthority authority = new(Now); + + string outcome = await authority.OutcomeAsync(AgentOperation.Renew, StepOneViolations[violation]()); + + Assert.Equal("signature_invalid", outcome); + } + + // CONTRACT.md §6 step 1: for renew and check-in, Content-Digest holds exactly one member, sha-256, + // without parameters, in canonical base64. + [Theory] + [MemberData(nameof(ContentDigestViolationCases))] + public async Task CorrectlySignedRequestBreakingAContentDigestRuleIsSignatureInvalid( + AgentOperation operation, + string violation) + { + TestAuthority authority = new(Now); + SignedHttpRequest request = ContentDigestViolations[violation](authority.Valid(operation)); + + string outcome = await authority.OutcomeAsync(operation, request); + + Assert.Equal("signature_invalid", outcome); + } + + // CONTRACT.md §6: parameters may appear in any order, and the verifier builds @signature-params + // from the received member, so a verifier that copied the received text would reject these. + [Theory] + [MemberData(nameof(AcceptedFormNames))] + public async Task RequestInAFormThatRfc8941AllowsIsAccepted(string form) + { + TestAuthority authority = new(Now); + + string outcome = await authority.OutcomeAsync(AgentOperation.Renew, AcceptedForms[form]()); + + Assert.Equal("ok", outcome); + } + + private static TestRequest Renew() + { + return TestRequest.Valid(AgentOperation.Renew, Now); + } + + private static SignedHttpRequest RenewWith(string parameter, string value) + { + return Renew().WithParameter(parameter, value).ToHttpRequest(); + } + + // The lines receive the signed value of @signature-params. + private static SignedHttpRequest RenewWithSignatureInput(Func> lines) + { + TestRequest request = Renew(); + return request.ToHttpRequest(signatureInput: lines(request.SignatureParams)); + } + + // The lines receive the signature in canonical base64. + private static SignedHttpRequest RenewWithSignature(Func> lines) + { + TestRequest request = Renew(); + return request.ToHttpRequest(signature: lines(request.Sign())); + } + + // The value is both the field and the covered component, so the signature covers it. + private static SignedHttpRequest WithContentDigest(TestRequest request, string value) + { + return (request with { ContentDigest = value }).ToHttpRequest(); + } + + private static string Quoted(string value) + { + return $"\"{value}\""; + } + + private static byte[] ThumbprintBytes() + { + return Convert.FromBase64String($"{Base64Forms.ToBase64Alphabet(DeviceA.Thumbprint)}="); + } + + private static string Sha256Base64(byte[] body) + { + return Convert.ToBase64String(SHA256.HashData(body)); + } + + // RFC 9530 §2. + private static string Sha512Digest(byte[] body) + { + return $"sha-512=:{Convert.ToBase64String(SHA512.HashData(body))}:"; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorTests.cs new file mode 100644 index 0000000000..9442e21efd --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorTests.cs @@ -0,0 +1,371 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The steps of CONTRACT.md §6 after parsing: their order, the certificate rules, the nonce, and the host's part. +/// +public class RequestAuthenticatorTests +{ + // CONTRACT.md §5.4: servers accept request bodies up to 64 KiB. + private const int OverTheLimit = (64 * 1024) + 1; + + private static readonly DateTimeOffset Now = TestAuthority.DefaultNow; + + private static readonly Dictionary> OversizedRequests = new() + { + ["with a malformed signature"] = _ => + new SignedHttpRequest("POST", ["not a signature"], [], [], new byte[OverTheLimit]), + ["correctly signed"] = authority => + { + byte[] body = new byte[OverTheLimit]; + TestRequest request = authority.Valid(AgentOperation.Renew) with + { + Body = body, + ContentDigest = TestRequest.Sha256Digest(body), + }; + return request.ToHttpRequest(); + }, + }; + + public static TheoryData Operations => new(Enum.GetValues()); + + public static TheoryData OperationsWithoutRenewalGrace => + new(AgentOperation.Confirm, AgentOperation.CheckIn, AgentOperation.Connect); + + public static TheoryData OperationsWithAcceptedStatuses + { + get + { + TheoryData cases = new(); + + foreach (AgentOperation operation in Enum.GetValues()) + { + cases.Add(operation, CertificateStatus.Pending); + cases.Add(operation, CertificateStatus.Current); + } + + return cases; + } + } + + public static TheoryData AcceptedStatuses => + new(CertificateStatus.Pending, CertificateStatus.Current); + + public static TheoryData OversizedRequestNames => new(OversizedRequests.Keys); + + private static KeyVector DeviceA => TestVectors.Instance.Key("device-a"); + + private static KeyVector DeviceB => TestVectors.Instance.Key("device-b"); + + // The validity of the certificate of device-a, as the vector file states it. + private static RegisteredCertificateVector DeviceACertificate => + TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a"); + + private static TimeSpan DeviceALifetime => DeviceACertificate.NotAfter - DeviceACertificate.NotBefore; + + // CONTRACT.md §5.4: a body larger than 64 KiB returns 413 invalid_request before any authentication outcome. + [Theory] + [MemberData(nameof(OversizedRequestNames))] + public async Task BodyOverTheLimitIsRejectedWith413First(string request) + { + TestAuthority authority = new(Now); + + AuthenticationResult result = await authority.Authenticator.AuthenticateAsync( + AgentOperation.Renew, + OversizedRequests[request](authority)); + + Assert.False(result.Succeeded); + Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); + Assert.Equal(413, result.Error.HttpStatusCode); + Assert.Empty(authority.Registry.Lookups); + Assert.Empty(authority.Nonces.Calls); + } + + // CONTRACT.md §5.4: servers accept request bodies up to 64 KiB. + [Fact] + public async Task BodyOfExactlyTheLimitIsAccepted() + { + TestAuthority authority = new(Now); + byte[] body = new byte[OverTheLimit - 1]; + TestRequest request = authority.Valid(AgentOperation.Renew) with + { + Body = body, + ContentDigest = TestRequest.Sha256Digest(body), + }; + + Assert.Equal("ok", await authority.OutcomeAsync(request)); + } + + // CONTRACT.md §6: the earliest failing step decides, + // so the identity's state is reported even when the signature would not verify. + // In these tests, device-b signs for the keyid of device-a. + [Fact] + public async Task RevokedIdentityWinsOverABadSignature() + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, identityRevoked: true); + + string outcome = await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew) with { Key = DeviceB }); + + Assert.Equal("identity_revoked", outcome); + } + + [Fact] + public async Task RetiredCertificateWinsOverABadSignature() + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, CertificateStatus.Retired); + + string outcome = await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew) with { Key = DeviceB }); + + Assert.Equal("identity_unknown", outcome); + } + + [Fact] + public async Task ExpiredCertificateWinsOverABadSignature() + { + TestAuthority authority = new(DeviceACertificate.NotAfter.AddSeconds(1)); + + string outcome = await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm) with { Key = DeviceB }); + + Assert.Equal("certificate_expired", outcome); + } + + // The body changes after signing, so only its digest fails. + [Fact] + public async Task CertificateBeyondRenewalGraceWinsOverABodyThatDoesNotMatchItsDigest() + { + TestAuthority authority = new(DeviceACertificate.NotAfter + DeviceALifetime); + TestRequest request = authority.Valid(AgentOperation.Renew) with { Body = [0x7B, 0x7D] }; + + Assert.Equal("certificate_expired", await authority.OutcomeAsync(request)); + } + + // CONTRACT.md §6 step 4 resolves the certificate before it reads the identity's revocation, + // and reads revocation before validity, so a revoked identity always draws the terminal identity_revoked. + [Fact] + public async Task RetiredCertificateWinsOverRevocation() + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, CertificateStatus.Retired, identityRevoked: true); + + Assert.Equal("identity_unknown", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); + } + + [Fact] + public async Task RevocationWinsOverExpiry() + { + TestAuthority authority = new(DeviceACertificate.NotAfter.AddSeconds(1)); + authority.Registry.Register(DeviceA, identityRevoked: true); + + Assert.Equal("identity_revoked", await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm))); + } + + // CONTRACT.md §6 table: every operation accepts a current or pending certificate. + [Theory] + [MemberData(nameof(OperationsWithAcceptedStatuses))] + public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation, CertificateStatus status) + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, status); + + Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); + } + + [Theory] + [MemberData(nameof(Operations))] + public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, CertificateStatus.Retired); + + Assert.Equal("identity_unknown", await authority.OutcomeAsync(authority.Valid(operation))); + } + + // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + [Theory] + [MemberData(nameof(Operations))] + public async Task CertificateIsAcceptedAtItsNotAfter(AgentOperation operation) + { + TestAuthority authority = new(DeviceACertificate.NotAfter); + + Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); + } + + // A clock truncated to the second would still accept the certificate here. + [Theory] + [MemberData(nameof(OperationsWithoutRenewalGrace))] + public async Task CertificateIsExpiredOneTickAfterItsNotAfter(AgentOperation operation) + { + TestAuthority authority = new(DeviceACertificate.NotAfter.AddTicks(1)); + + Assert.Equal("certificate_expired", await authority.OutcomeAsync(authority.Valid(operation))); + } + + // CONTRACT.md §6: notBefore is not checked, + // so a certificate dated by a fast authority clock does not lock devices out. + [Theory] + [MemberData(nameof(Operations))] + public async Task CertificateIsAcceptedBeforeItsNotBefore(AgentOperation operation) + { + TestAuthority authority = new(DeviceACertificate.NotBefore.AddTicks(-1)); + + Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); + } + + // CONTRACT.md §6 table: renew accepts a certificate while now < notAfter + (notAfter − notBefore). + [Theory] + [MemberData(nameof(AcceptedStatuses))] + public async Task RenewAcceptsACertificateOneTickAfterItsNotAfter(CertificateStatus status) + { + TestAuthority authority = new(DeviceACertificate.NotAfter.AddTicks(1)); + authority.Registry.Register(DeviceA, status); + + Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); + } + + [Theory] + [MemberData(nameof(AcceptedStatuses))] + public async Task RenewAcceptsACertificateOneTickBeforeItsGraceEnds(CertificateStatus status) + { + TestAuthority authority = new((DeviceACertificate.NotAfter + DeviceALifetime).AddTicks(-1)); + authority.Registry.Register(DeviceA, status); + + Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); + } + + [Theory] + [MemberData(nameof(AcceptedStatuses))] + public async Task RenewRejectsACertificateWhenItsGraceEnds(CertificateStatus status) + { + TestAuthority authority = new(DeviceACertificate.NotAfter + DeviceALifetime); + authority.Registry.Register(DeviceA, status); + + Assert.Equal("certificate_expired", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); + } + + // A second reading of the clock between the window and the certificate check + // would judge the certificate a day later, after it expired. + [Fact] + public async Task WindowAndCertificateAreJudgedAtOneReadingOfTheClock() + { + DateTimeOffset first = DeviceACertificate.NotAfter; + TestAuthority authority = new(first, clock: new SteppingTimeProvider(first, first.AddDays(1))); + + Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm))); + } + + // CONTRACT.md §6 step 6: a pair the store already holds fails, whichever request recorded it. + [Theory] + [MemberData(nameof(Operations))] + public async Task NonceAlreadyRecordedIsSignatureInvalid(AgentOperation operation) + { + TestAuthority authority = new(Now, new RecordingNonceStore { AnswerAlreadyRecorded = true }); + + Assert.Equal("signature_invalid", await authority.OutcomeAsync(authority.Valid(operation))); + } + + // CONTRACT.md §6: a header the operation does not cover, such as Content-Digest on confirm, is ignored. + [Fact] + public async Task ConfirmIgnoresContentDigest() + { + TestAuthority authority = new(Now); + TestRequest confirm = authority.Valid(AgentOperation.Confirm); + + string outcome = await authority.OutcomeAsync( + AgentOperation.Confirm, + confirm.ToHttpRequest(contentDigest: ["(not a dictionary"])); + + Assert.Equal("ok", outcome); + } + + // RFC 9110 §9.1: a method is a token, and anything else could put a line break of its own into the signature base. + [Theory] + [InlineData("")] + [InlineData("PO ST")] + [InlineData("POST\n")] + public async Task MethodThatIsNotATokenIsSignatureInvalid(string method) + { + TestAuthority authority = new(Now); + + string outcome = await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm) with { Method = method }); + + Assert.Equal("signature_invalid", outcome); + } + + // The shared intent, security invariant 7: signed opening headers never authenticate a stream on their own. + // An uninitialized operation names no endpoint, so it authenticates nothing either. + [Theory] + [InlineData(AgentOperation.Connect)] + [InlineData((AgentOperation)0)] + [InlineData((AgentOperation)5)] + public async Task AuthenticateAsyncRefusesAnOperationThatIsNotASignedHttpRequest(AgentOperation operation) + { + TestAuthority authority = new(Now); + SignedHttpRequest request = authority.Valid(AgentOperation.Connect).ToHttpRequest(); + + await Assert.ThrowsAsync( + () => authority.Authenticator.AuthenticateAsync(operation, request).AsTask()); + Assert.Empty(authority.Nonces.Calls); + } + + // The registry answers by thumbprint, so another certificate is a host bug, + // which must fail as a server error rather than draw an outcome the agent acts on. + [Fact] + public async Task RegistryReturningACertificateWithAnotherThumbprintThrows() + { + TestAuthority authority = new(Now); + authority.Registry.Register( + DeviceA.Thumbprint, + new RegisteredCertificate(DeviceB.IdentityId, DeviceB.Certificate, CertificateStatus.Current, false)); + SignedHttpRequest request = authority.Valid(AgentOperation.Renew).ToHttpRequest(); + + await Assert.ThrowsAsync( + () => authority.Authenticator.AuthenticateAsync(AgentOperation.Renew, request).AsTask()); + } + + // CONTRACT.md §4: the authority issues P-256 leaves only, so any other registered bytes are a host bug. + // The keyid is the thumbprint of those bytes, so the registry answers with what it was asked for. + [Theory] + [InlineData("a P-384 certificate")] + [InlineData("an RSA certificate")] + [InlineData("bytes that are not a certificate")] + public async Task RegistryReturningSomethingOtherThanAP256CertificateThrows(string registered) + { + byte[] der = registered switch + { + "a P-384 certificate" => SelfSignedP384Certificate(), + "an RSA certificate" => SelfSignedRsaCertificate(), + _ => [0x30, 0x00], + }; + string thumbprint = Base64Forms.Base64Url(SHA256.HashData(der)); + TestAuthority authority = new(Now); + authority.Registry.Register( + thumbprint, + new RegisteredCertificate(Guid.NewGuid(), der, CertificateStatus.Current, false)); + SignedHttpRequest request = authority.Valid(AgentOperation.Renew) + .WithParameter("keyid", $"\"{thumbprint}\"") + .ToHttpRequest(); + + await Assert.ThrowsAsync( + () => authority.Authenticator.AuthenticateAsync(AgentOperation.Renew, request).AsTask()); + } + + private static byte[] SelfSignedP384Certificate() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP384); + CertificateRequest request = new("CN=P-384", key, HashAlgorithmName.SHA384); + using X509Certificate2 certificate = request.CreateSelfSigned(Now.AddDays(-1), Now.AddDays(1)); + return certificate.RawData; + } + + private static byte[] SelfSignedRsaCertificate() + { + using RSA key = RSA.Create(2048); + CertificateRequest request = new("CN=RSA", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using X509Certificate2 certificate = request.CreateSelfSigned(Now.AddDays(-1), Now.AddDays(1)); + return certificate.RawData; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorVectorTests.cs new file mode 100644 index 0000000000..d07062a741 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorVectorTests.cs @@ -0,0 +1,187 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The shared request signature vectors, run as their evaluation field says. +/// +public class RequestAuthenticatorVectorTests +{ + private static HttpSignatureVectors Vectors => TestVectors.Instance.HttpSignature; + + public static TheoryData OkCaseNames => + new(Vectors.Cases.Where(c => c.Expected == "ok").Select(c => c.Name)); + + public static TheoryData FailingCaseNames => + new(Vectors.Cases.Where(c => c.Expected != "ok").Select(c => c.Name)); + + public static TheoryData OkSignedRequestCaseNames => + new(Vectors.Cases.Where(c => c.Expected == "ok" && c.Endpoint != "connect").Select(c => c.Name)); + + // Each case isolates one rule of CONTRACT.md §6 or its precedence, + // so any outcome but the vector's breaks that rule. + [Theory] + [MemberData(nameof(VectorData.HttpSignatureCaseNames), MemberType = typeof(VectorData))] + public async Task CaseReturnsItsExpectedOutcome(string name) + { + HttpSignatureCaseVector vector = Vectors.Case(name); + VectorAuthority authority = new(); + + VectorOutcome outcome = await authority.RunAsync(vector); + + Assert.Equal(vector.Expected, outcome.Outcome); + } + + // A replayed nonce must fail, a nonce must survive a request that failed verification, + // and a nonce accepted for one operation must not authenticate another. + [Theory] + [MemberData(nameof(VectorData.HttpSignatureSequenceNames), MemberType = typeof(VectorData))] + public async Task SequenceReturnsItsExpectedOutcomes(string name) + { + HttpSignatureSequenceVector sequence = Vectors.Sequence(name); + VectorAuthority authority = new(); + List outcomes = []; + + foreach (string step in sequence.Steps) + { + outcomes.Add((await authority.RunAsync(Vectors.Case(step))).Outcome); + } + + Assert.Equal(sequence.Expected, outcomes); + } + + // A signature base that differs from the signed one in any byte fails verification for every agent, + // and an accepted request does not show which byte the verifier would get wrong in another request. + [Theory] + [MemberData(nameof(OkCaseNames))] + public void OkCaseSignatureBaseIsTheVectorBase(string name) + { + HttpSignatureCaseVector vector = Vectors.Case(name); + SignedOperation operation = SignedOperation.For(VectorAuthority.Operation(vector.Endpoint)); + + Assert.True( + SignatureFields.TryParse( + operation, + VectorAuthority.Lines(vector.Headers.SignatureInput), + VectorAuthority.Lines(vector.Headers.Signature), + VectorAuthority.Lines(vector.Headers.ContentDigest), + out SignatureFields? fields, + out string? failureDetail), + failureDetail); + Assert.True(fields.TryBuildSignatureBase(vector.Method, out byte[]? signatureBase)); + Assert.Equal(Encoding.UTF8.GetBytes(vector.SignatureBase), signatureBase); + } + + // RFC 9421 Appendix B.2.4 is the RFC's own ecdsa-p256-sha256 example, + // so it fails a primitive that hashes with another algorithm or reads another signature format. + [Fact] + public void Rfc9421B24VerifiesWithTheLibraryPrimitive() + { + Rfc9421Vector vector = TestVectors.Instance.Rfc9421B24; + using ECDsa publicKey = ECDsa.Create(); + publicKey.ImportSubjectPublicKeyInfo(vector.PublicKeySpki, out _); + byte[] signatureBase = Encoding.UTF8.GetBytes(vector.SignatureBase); + + bool valid = EcdsaP256Signature.Verify(publicKey, signatureBase, vector.Signature); + + Assert.Equal(vector.ExpectedValid, valid); + } + + // CONTRACT.md §6: nonces are committed only after the signature verifies, so garbage requests cannot burn nonces. + // With an empty nonce store, every failing case fails before step 6. + [Theory] + [MemberData(nameof(FailingCaseNames))] + public async Task FailingCaseRecordsNoNonce(string name) + { + VectorAuthority authority = new(); + + await authority.RunAsync(Vectors.Case(name)); + + Assert.Empty(authority.Nonces.Calls); + } + + // The failure detail goes to host logs, so a detail that echoed the request would carry text the sender chose, + // and a captured signature, into those logs. + [Theory] + [MemberData(nameof(FailingCaseNames))] + public async Task FailingCaseDetailHoldsNoRequestData(string name) + { + HttpSignatureCaseVector vector = Vectors.Case(name); + VectorAuthority authority = new(); + + VectorOutcome outcome = await authority.RunAsync(vector); + + string[] headers = + [ + .. new[] { vector.Headers.SignatureInput, vector.Headers.Signature, vector.Headers.ContentDigest } + .OfType(), + ]; + IEnumerable values = headers + .SelectMany(header => Regex.Matches(header, "(?:keyid|nonce)=\"([^\"]+)\"|=:([^:]+):")) + .Select(match => match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value); + Assert.NotNull(outcome.FailureDetail); + Assert.All(headers.Concat(values), value => Assert.DoesNotContain(value, outcome.FailureDetail)); + } + + // CONTRACT.md §6 step 6 and the shared intent: the authority keeps each accepted nonce + // until 60 s after its signature expires, the last moment the window could accept the signature again. + [Theory] + [MemberData(nameof(OkCaseNames))] + public async Task OkCaseRecordsItsNonceOnceUntilExpiresPlus60Seconds(string name) + { + HttpSignatureCaseVector vector = Vectors.Case(name); + VectorAuthority authority = new(); + + await authority.RunAsync(vector); + + string signatureInput = vector.Headers.SignatureInput!; + long expires = long.Parse(Parameter(signatureInput, "expires"), CultureInfo.InvariantCulture); + (string, string, DateTimeOffset) recorded = ( + Parameter(signatureInput, "keyid"), + Parameter(signatureInput, "nonce"), + DateTimeOffset.FromUnixTimeSeconds(expires + 60)); + Assert.Equal([recorded], authority.Nonces.Calls); + } + + // Code that acts for an identity relies on these facts, + // such as the status that tells confirm whether to promote the certificate. + // The registered certificates of the vector file state them, in Unix seconds, independently of any time zone. + [Theory] + [MemberData(nameof(OkSignedRequestCaseNames))] + public async Task OkCaseIdentityIsThatOfTheSigningCertificate(string name) + { + HttpSignatureCaseVector vector = Vectors.Case(name); + string keyId = Parameter(vector.Headers.SignatureInput!, "keyid"); + RegisteredCertificateVector registered = Vectors.Registered.Single(r => r.KeyId == keyId); + VectorAuthority authority = new(); + authority.Clock.Now = vector.Now; + SignedHttpRequest request = new( + vector.Method, + VectorAuthority.Lines(vector.Headers.SignatureInput), + VectorAuthority.Lines(vector.Headers.Signature), + VectorAuthority.Lines(vector.Headers.ContentDigest), + vector.BodyBytes); + + AuthenticationResult result = await authority.Authenticator.AuthenticateAsync( + VectorAuthority.Operation(vector.Endpoint), + request); + + Assert.True(result.Succeeded); + AuthenticatedIdentity identity = result.Identity; + Assert.Equal(registered.IdentityId, identity.IdentityId); + Assert.Equal(registered.KeyId, identity.CertificateThumbprint); + Assert.Equal(registered.CertificateStatus, identity.CertificateStatus.ToString().ToLowerInvariant()); + Assert.Equal(registered.NotBefore, identity.CertificateNotBefore); + Assert.Equal(registered.NotAfter, identity.CertificateNotAfter); + Assert.Equal(VectorAuthority.Operation(vector.Endpoint), identity.Operation); + } + + // A parameter value, quoted or not, read from the header text by this test rather than by the library. + private static string Parameter(string signatureInput, string name) + { + return Regex.Match(signatureInput, $";{name}=\"?([^\";]+)").Groups[1].Value; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestAuthority.cs new file mode 100644 index 0000000000..8a115ad362 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestAuthority.cs @@ -0,0 +1,69 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// An authority for tests that sign their own requests: +/// every vector key is registered, current and not revoked, unless a test registers it otherwise. +/// +internal sealed class TestAuthority +{ + public TestAuthority(DateTimeOffset now, RecordingNonceStore? nonces = null, TimeProvider? clock = null) + { + Now = now; + Nonces = nonces ?? new RecordingNonceStore(); + + foreach (KeyVector key in TestVectors.Instance.Keys) + { + Registry.Register(key); + } + + Authenticator = new RequestAuthenticator(Registry, Nonces, clock ?? new FixedTimeProvider(now), Random); + } + + /// + /// An instant within the validity of the certificate of device-a, + /// as http_signature.registered gives it. + /// + public static DateTimeOffset DefaultNow { get; } = DateTimeOffset.FromUnixTimeSeconds(1_790_000_000); + + public DateTimeOffset Now { get; } + + public FakeCertificateRegistry Registry { get; } = new(); + + public RecordingNonceStore Nonces { get; } + + public CountingRandomNumberGenerator Random { get; } = new(); + + public RequestAuthenticator Authenticator { get; } + + public TestRequest Valid(AgentOperation operation, KeyVector? key = null, string nonce = TestRequest.DefaultNonce) + { + return TestRequest.Valid(operation, Now, key, nonce); + } + + public Task OutcomeAsync(TestRequest request) + { + return OutcomeAsync(request.Operation, request.ToHttpRequest()); + } + + /// + /// Authenticates a request through the public API: + /// connect as a channel opening, the others as signed requests. + /// + /// ok or the wire value of the error code, as the vector file writes outcomes. + public async Task OutcomeAsync(AgentOperation operation, SignedHttpRequest request) + { + if (operation == AgentOperation.Connect) + { + ChannelOpeningResult opening = await Authenticator.AuthenticateChannelOpeningAsync( + request.Method, + request.SignatureInput, + request.Signature); + + return opening.Succeeded ? "ok" : opening.Error.Code.ToWireValue(); + } + + AuthenticationResult result = await Authenticator.AuthenticateAsync(operation, request); + + return result.Succeeded ? "ok" : result.Error.Code.ToWireValue(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs new file mode 100644 index 0000000000..3144d404c9 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs @@ -0,0 +1,105 @@ +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// A clock that reads , which a test sets. +/// +internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider +{ + public DateTimeOffset Now { get; set; } = now; + + public override DateTimeOffset GetUtcNow() + { + return Now; + } +} + +/// +/// A clock that returns the given instants, one per reading, and then repeats the last one. +/// +internal sealed class SteppingTimeProvider(params DateTimeOffset[] readings) : TimeProvider +{ + private int _next; + + public override DateTimeOffset GetUtcNow() + { + DateTimeOffset reading = readings[Math.Min(_next, readings.Length - 1)]; + _next++; + return reading; + } +} + +/// +/// A registry over a dictionary, which records every lookup. +/// +internal sealed class FakeCertificateRegistry : ICertificateRegistry +{ + private readonly Dictionary _certificates = new(StringComparer.Ordinal); + + public List Lookups { get; } = []; + + public void Register(string thumbprint, RegisteredCertificate certificate) + { + _certificates[thumbprint] = certificate; + } + + public void Register( + KeyVector key, + CertificateStatus status = CertificateStatus.Current, + bool identityRevoked = false) + { + Register(key.Thumbprint, new RegisteredCertificate(key.IdentityId, key.Certificate, status, identityRevoked)); + } + + public ValueTask FindByThumbprintAsync( + string thumbprint, + CancellationToken cancellationToken) + { + Lookups.Add(thumbprint); + return ValueTask.FromResult(_certificates.GetValueOrDefault(thumbprint)); + } +} + +/// +/// A nonce store over a set, which records every call and can be told to answer that every pair is already recorded. +/// +internal sealed class RecordingNonceStore : INonceStore +{ + private readonly HashSet<(string KeyId, string Nonce)> _recorded = []; + + public bool AnswerAlreadyRecorded { get; init; } + + public List<(string KeyId, string Nonce, DateTimeOffset RetainUntil)> Calls { get; } = []; + + public ValueTask TryRecordAsync( + string keyId, + string nonce, + DateTimeOffset retainUntil, + CancellationToken cancellationToken) + { + Calls.Add((keyId, nonce, retainUntil)); + return ValueTask.FromResult(!AnswerAlreadyRecorded && _recorded.Add((keyId, nonce))); + } +} + +/// +/// A generator whose bytes count up from zero, so that a test knows which bytes each call returns. +/// +internal sealed class CountingRandomNumberGenerator : RandomNumberGenerator +{ + private byte _next; + + public override void GetBytes(byte[] data) + { + GetBytes(data.AsSpan()); + } + + public override void GetBytes(Span data) + { + for (int i = 0; i < data.Length; i++) + { + data[i] = _next++; + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestRequest.cs new file mode 100644 index 0000000000..0eb04a7690 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestRequest.cs @@ -0,0 +1,247 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// A request to sign, described by the parts of RFC 9421 that the profile constrains. +/// +/// +/// The signature base is built here by string formatting from RFC 9421 §2.5, never through the library, +/// so that a library bug cannot make a request agree with it. +/// Component identifiers and parameter values are written as Signature-Input and the base hold them. +/// +internal sealed record TestRequest +{ + public const string DefaultNonce = "AAECAwQFBgcICQoLDA0ODw"; + + public required AgentOperation Operation { get; init; } + + /// + /// The key that signs, whose thumbprint also writes as keyid. + /// + public required KeyVector Key { get; init; } + + public string Method { get; init; } = "POST"; + + /// + /// The serialized component identifiers, such as "@method". + /// + public required IReadOnlyList Components { get; init; } + + /// + /// The signature parameters in order, each with its serialized value, such as "renew" with its quotes. + /// + public required IReadOnlyList<(string Key, string Value)> Parameters { get; init; } + + public byte[] Body { get; init; } = []; + + /// + /// The Content-Digest field value, which is also the value of a covered content-digest. + /// + public string? ContentDigest { get; init; } + + /// + /// The value of @signature-params that the signature covers, as RFC 9421 §2.3 serializes it. + /// + public string SignatureParams => $"({string.Join(' ', Components)}){ParametersText}"; + + /// + /// The parameters as they follow the inner list, such as ;created=1;expires=61. + /// + public string ParametersText => + string.Concat(Parameters.Select(parameter => $";{parameter.Key}={parameter.Value}")); + + // RFC 9421 §2.5. + public string SignatureBase + { + get + { + StringBuilder builder = new(); + + foreach (string component in Components) + { + builder.Append(component).Append(": ").Append(ComponentValue(component)).Append('\n'); + } + + return builder.Append("\"@signature-params\": ").Append(SignatureParams).ToString(); + } + } + + /// + /// A request that every rule of CONTRACT.md §6 accepts at , when its key is registered. + /// + public static TestRequest Valid( + AgentOperation operation, + DateTimeOffset now, + KeyVector? key = null, + string nonce = DefaultNonce) + { + key ??= TestVectors.Instance.Key("device-a"); + long created = now.ToUnixTimeSeconds(); + bool coversBody = operation is AgentOperation.Renew or AgentOperation.CheckIn; + byte[] body = coversBody ? Encoding.UTF8.GetBytes("{\"metadata\":{\"hostname\":\"test-host\"}}") : []; + + return new TestRequest + { + Operation = operation, + Key = key, + Components = coversBody ? ["\"@method\"", "\"content-digest\""] : ["\"@method\""], + Parameters = + [ + ("created", created.ToString(CultureInfo.InvariantCulture)), + ("expires", (created + 60).ToString(CultureInfo.InvariantCulture)), + ("nonce", $"\"{nonce}\""), + ("keyid", $"\"{key.Thumbprint}\""), + ("alg", "\"ecdsa-p256-sha256\""), + ("tag", $"\"{Tag(operation)}\""), + ], + Body = body, + ContentDigest = coversBody ? Sha256Digest(body) : null, + }; + } + + // CONTRACT.md §6, column "tag". + public static string Tag(AgentOperation operation) + { + return operation switch + { + AgentOperation.Renew => "renew", + AgentOperation.Confirm => "confirm", + AgentOperation.CheckIn => "check-in", + AgentOperation.Connect => "connect", + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "Not an operation."), + }; + } + + // RFC 9530 §2. + public static string Sha256Digest(byte[] body) + { + return $"sha-256=:{Convert.ToBase64String(SHA256.HashData(body))}:"; + } + + /// + /// Replaces the serialized value of a parameter, keeping its position. + /// + public TestRequest WithParameter(string key, string value) + { + return this with + { + Parameters = [.. Parameters.Select(parameter => parameter.Key == key ? (key, value) : parameter)], + }; + } + + /// + /// Signs the base with and returns the signature in base64. + /// + /// + /// ECDSA signing is randomized, so each call returns another valid signature. + /// + public string Sign() + { + using ECDsa privateKey = ECDsa.Create(); + privateKey.ImportPkcs8PrivateKey(Key.PrivateKeyPkcs8, out _); + + byte[] signature = privateKey.SignData( + Encoding.UTF8.GetBytes(SignatureBase), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + return Convert.ToBase64String(signature); + } + + /// + /// Signs until the base64 signature satisfies . + /// + public string SignUntil(Func condition) + { + for (int attempt = 0; attempt < 1000; attempt++) + { + string signature = Sign(); + if (condition(signature)) + { + return signature; + } + } + + throw new InvalidOperationException("No signature satisfied the condition."); + } + + /// + /// Builds the HTTP request, with each field written as the profile expects unless a test passes its own lines. + /// + public SignedHttpRequest ToHttpRequest( + IReadOnlyList? signatureInput = null, + IReadOnlyList? signature = null, + IReadOnlyList? contentDigest = null) + { + return new SignedHttpRequest( + Method, + signatureInput ?? [$"sig={SignatureParams}"], + signature ?? [$"sig=:{Sign()}:"], + contentDigest ?? (ContentDigest is null ? [] : [ContentDigest]), + Body); + } + + private string ComponentValue(string identifier) + { + if (identifier.StartsWith("\"@method\"", StringComparison.Ordinal)) + { + return Method; + } + + if (identifier.StartsWith("\"content-digest\"", StringComparison.Ordinal)) + { + return ContentDigest + ?? throw new InvalidOperationException("The request covers content-digest without one."); + } + + throw new InvalidOperationException($"The request covers {identifier}, which it has no value for."); + } +} + +/// +/// Alterations of canonical base64 and base64url text that keep the value a lenient decoder reads. +/// +internal static class Base64Forms +{ + private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + /// + /// Sets a pad bit of the final quantum, which RFC 4648 §3.5 leaves to the decoder. + /// + public static string WithNonZeroPadBits(string canonical, bool urlSafe) + { + string alphabet = urlSafe ? Base64UrlAlphabet : Base64Alphabet; + string data = canonical.TrimEnd('='); + + // Only a final quantum of one or two bytes has pad bits. + if (data.Length % 4 == 0) + { + throw new ArgumentException("The value has no pad bits.", nameof(canonical)); + } + + // The lowest bit of the last character is a pad bit. + char last = data[^1]; + char altered = alphabet[alphabet.IndexOf(last, StringComparison.Ordinal) ^ 1]; + + return string.Concat(data.AsSpan(0, data.Length - 1), [altered], canonical.AsSpan(data.Length)); + } + + public static string ToBase64UrlAlphabet(string base64) + { + return base64.Replace('+', '-').Replace('/', '_'); + } + + public static string ToBase64Alphabet(string base64Url) + { + return base64Url.Replace('-', '+').Replace('_', '/'); + } + + public static string Base64Url(byte[] bytes) + { + return ToBase64UrlAlphabet(Convert.ToBase64String(bytes)).TrimEnd('='); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorAuthority.cs new file mode 100644 index 0000000000..998024a5f4 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorAuthority.cs @@ -0,0 +1,106 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The authority that http_signature describes: its registered certificates, an empty nonce store, +/// and a clock that each case sets to its now. +/// +internal sealed class VectorAuthority +{ + public VectorAuthority() + { + foreach (RegisteredCertificateVector registered in TestVectors.Instance.HttpSignature.Registered) + { + Registry.Register( + registered.KeyId, + new RegisteredCertificate( + registered.IdentityId, + registered.Certificate, + Status(registered.CertificateStatus), + registered.IdentityRevoked)); + } + + Authenticator = new RequestAuthenticator(Registry, Nonces, Clock, Random); + } + + public FakeCertificateRegistry Registry { get; } = new(); + + public RecordingNonceStore Nonces { get; } = new(); + + public FixedTimeProvider Clock { get; } = new(DateTimeOffset.UnixEpoch); + + public CountingRandomNumberGenerator Random { get; } = new(); + + public RequestAuthenticator Authenticator { get; } + + /// + /// Runs a case through the public API: connect as a channel opening, the others as signed requests. + /// + public async Task RunAsync(HttpSignatureCaseVector vector) + { + Clock.Now = vector.Now; + string[] signatureInput = Lines(vector.Headers.SignatureInput); + string[] signature = Lines(vector.Headers.Signature); + + if (vector.Endpoint == "connect") + { + ChannelOpeningResult opening = + await Authenticator.AuthenticateChannelOpeningAsync(vector.Method, signatureInput, signature); + + return opening.Succeeded + ? VectorOutcome.Ok + : new VectorOutcome(opening.Error.Code.ToWireValue(), opening.FailureDetail); + } + + SignedHttpRequest request = new( + vector.Method, + signatureInput, + signature, + Lines(vector.Headers.ContentDigest), + vector.BodyBytes); + AuthenticationResult result = await Authenticator.AuthenticateAsync(Operation(vector.Endpoint), request); + + return result.Succeeded + ? VectorOutcome.Ok + : new VectorOutcome(result.Error.Code.ToWireValue(), result.FailureDetail); + } + + /// + /// The field lines of a vector header, which holds at most one. + /// + public static string[] Lines(string? header) + { + return header is null ? [] : [header]; + } + + // CONTRACT.md §5: each endpoint is one operation. + public static AgentOperation Operation(string endpoint) + { + return endpoint switch + { + "renew" => AgentOperation.Renew, + "confirm" => AgentOperation.Confirm, + "check-in" => AgentOperation.CheckIn, + "connect" => AgentOperation.Connect, + _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, "Not an endpoint of the vectors."), + }; + } + + // CONTRACT.md "Definitions": the certificate statuses. + private static CertificateStatus Status(string status) + { + return status switch + { + "pending" => CertificateStatus.Pending, + "current" => CertificateStatus.Current, + "retired" => CertificateStatus.Retired, + _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Not a certificate status."), + }; + } +} + +/// ok or the wire value of the error code, as the vector file writes outcomes. +/// The failure detail, when the request failed. +internal sealed record VectorOutcome(string Outcome, string? FailureDetail) +{ + public static VectorOutcome Ok { get; } = new("ok", null); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentOperation.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentOperation.cs new file mode 100644 index 0000000000..c47e17d1f7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentOperation.cs @@ -0,0 +1,36 @@ +namespace Devolutions.AgentIdentity; + +/// +/// An operation that an agent authenticates with a signed request. +/// +/// +/// Each operation has its own tag, covered components and rule for the certificate it accepts, +/// so a signature made for one operation never authenticates another. +/// +public enum AgentOperation +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering an operation would change what an existing host asks for. + // When adding an operation, give it the next unused value; + // values start at 1 so that an uninitialized operation is not a valid one. + + /// + /// POST renew, signed with tag renew. + /// + Renew = 1, + + /// + /// POST confirm, signed with tag confirm. + /// + Confirm = 2, + + /// + /// POST check-in, signed with tag check-in. + /// + CheckIn = 3, + + /// + /// The opening request of an agent channel stream, signed with tag connect. + /// + Connect = 4, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs new file mode 100644 index 0000000000..87b6fbb9d5 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs @@ -0,0 +1,58 @@ +namespace Devolutions.AgentIdentity; + +/// +/// An identity that a signed request proved. +/// +/// +/// Only request authentication creates one, so code that takes this type acts for an identity that a signature check +/// established, never for one taken from a value that the agent or a caller sent. +/// +public sealed class AuthenticatedIdentity +{ + internal AuthenticatedIdentity( + Guid identityId, + string certificateThumbprint, + CertificateStatus certificateStatus, + DateTimeOffset certificateNotBefore, + DateTimeOffset certificateNotAfter, + AgentOperation operation) + { + IdentityId = identityId; + CertificateThumbprint = certificateThumbprint; + CertificateStatus = certificateStatus; + CertificateNotBefore = certificateNotBefore; + CertificateNotAfter = certificateNotAfter; + Operation = operation; + } + + /// + /// The identity that the request proved. + /// + public Guid IdentityId { get; } + + /// + /// The thumbprint of the certificate whose key signed the request. + /// + public string CertificateThumbprint { get; } + + /// + /// The status of the certificate whose key signed the request: + /// or . + /// + public CertificateStatus CertificateStatus { get; } + + /// + /// The notBefore of the certificate whose key signed the request. + /// + public DateTimeOffset CertificateNotBefore { get; } + + /// + /// The notAfter of the certificate whose key signed the request. + /// + public DateTimeOffset CertificateNotAfter { get; } + + /// + /// The operation that the request was signed for. + /// + public AgentOperation Operation { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticationResult.cs new file mode 100644 index 0000000000..ff94917f53 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticationResult.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of authenticating a signed request. +/// +public sealed class AuthenticationResult +{ + private AuthenticationResult(AuthenticatedIdentity? identity, AgentIdentityError? error, string? failureDetail) + { + Succeeded = identity is not null; + Identity = identity; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the request authenticated. + /// + [MemberNotNullWhen(true, nameof(Identity))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Succeeded { get; } + + /// + /// The identity that the request proved, when it authenticated. + /// + public AuthenticatedIdentity? Identity { get; } + + /// + /// The error to respond with, when the request did not authenticate. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that the request broke and the step of CONTRACT.md §6 that checks it, for host logs. + /// + /// + /// It is a fixed sentence, so it never holds a header value, a token or a secret. + /// + public string? FailureDetail { get; } + + internal static AuthenticationResult Success(AuthenticatedIdentity identity) + { + return new AuthenticationResult(identity, null, null); + } + + internal static AuthenticationResult Failure(AgentIdentityError error, string failureDetail) + { + return new AuthenticationResult(null, error, failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs new file mode 100644 index 0000000000..cc317eaa9a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs @@ -0,0 +1,66 @@ +using Devolutions.Agent.Channel.V1; +using Google.Protobuf; + +using ChallengePayload = Devolutions.Agent.Channel.V1.Challenge; + +namespace Devolutions.AgentIdentity; + +/// +/// An agent channel stream whose signed opening request verified, before the agent proves it holds the key. +/// +/// +/// Signed opening headers are not tied to one stream and can be captured on the way, +/// so they never authenticate a stream on their own. +/// The host sends as the first message on the stream, +/// and the stream is authenticated only once the agent signs that challenge. +/// The identity stays hidden until then, so that nothing acts for it on the strength of the headers alone. +/// +public sealed class ChannelOpening +{ + internal ChannelOpening(VerifiedCertificate certificate, string connectNonce, Guid challengeId, byte[] challenge) + { + Certificate = certificate; + ConnectNonce = connectNonce; + ChallengeId = challengeId.ToString("D"); + ChallengeBytes = challenge; + Challenge = new ServerMessage + { + Id = ChallengeId, + Challenge = new ChallengePayload { Challenge_ = ByteString.CopyFrom(challenge) }, + }; + } + + /// + /// The first message to send on the stream: a Challenge with 32 random bytes fresh for this stream. + /// + public ServerMessage Challenge { get; } + + /// + /// The thumbprint of the certificate that signed the opening request, for logs. + /// + public string CertificateThumbprint => Certificate.Thumbprint; + + /// + /// The certificate that signed the opening request, whose key must also sign the challenge. + /// + internal VerifiedCertificate Certificate { get; } + + /// + /// The nonce parameter of the opening request, whose UTF-8 bytes end the signed challenge. + /// + internal string ConnectNonce { get; } + + /// + /// The id of , as sent. + /// + internal string ChallengeId { get; } + + /// + /// The random bytes of , as sent. + /// + /// + /// They are kept apart from the message, + /// so that a change to the message object cannot change what the agent must sign. + /// + internal ReadOnlyMemory ChallengeBytes { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpeningResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpeningResult.cs new file mode 100644 index 0000000000..d9ad542f18 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpeningResult.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of authenticating the signed opening request of an agent channel stream. +/// +/// +/// When the opening fails, the host closes the stream with of +/// . +/// +public sealed class ChannelOpeningResult +{ + private ChannelOpeningResult(ChannelOpening? opening, AgentIdentityError? error, string? failureDetail) + { + Succeeded = opening is not null; + Opening = opening; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the opening request authenticated. + /// + [MemberNotNullWhen(true, nameof(Opening))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Succeeded { get; } + + /// + /// The opened stream, waiting for the agent to sign its challenge, when the opening request authenticated. + /// + public ChannelOpening? Opening { get; } + + /// + /// The error to close the stream with, when the opening request did not authenticate. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that the opening request broke and the step of CONTRACT.md §6 that checks it, for host logs. + /// + /// + /// It is a fixed sentence, so it never holds a header value, a token or a secret. + /// + public string? FailureDetail { get; } + + internal static ChannelOpeningResult Success(ChannelOpening opening) + { + return new ChannelOpeningResult(opening, null, null); + } + + internal static ChannelOpeningResult Failure(AgentIdentityError error, string failureDetail) + { + return new ChannelOpeningResult(null, error, failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EcdsaP256Signature.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EcdsaP256Signature.cs new file mode 100644 index 0000000000..09bd78bc86 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EcdsaP256Signature.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity; + +/// +/// The signature algorithm of request signatures and channel proofs: ECDSA P-256 over SHA-256, as raw r‖s. +/// +internal static class EcdsaP256Signature +{ + /// + /// The length of a signature: r and s, each a 32-byte big-endian unsigned integer. + /// + public const int Length = 64; + + /// + /// Verifies a signature over data, as RFC 9421 §3.3.4 defines for ecdsa-p256-sha256. + /// + /// A P-256 public key. + /// The signed bytes, which are hashed with SHA-256. + /// The signature, which fails unless it is exactly 64 bytes. + public static bool Verify(ECDsa publicKey, ReadOnlySpan data, ReadOnlySpan signature) + { + return signature.Length == Length + && publicKey.VerifyData( + data, + signature, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FieldLines.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FieldLines.cs new file mode 100644 index 0000000000..3200c33ff5 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FieldLines.cs @@ -0,0 +1,36 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Helpers for the lines of an HTTP field, which a request can split a field value across. +/// +internal static class FieldLines +{ + /// + /// Copies the field lines that a caller passed, rejecting a null line. + /// + public static string[] Copy(IReadOnlyList fieldLines, string parameterName) + { + ArgumentNullException.ThrowIfNull(fieldLines, parameterName); + + string[] copy = [.. fieldLines]; + if (Array.IndexOf(copy, null) >= 0) + { + throw new ArgumentException("A field line is null.", parameterName); + } + + return copy; + } + + /// + /// Combines field lines into one field value, as RFC 9421 §2.1 does for a covered field. + /// + /// + /// Each line loses its leading and trailing white space, + /// and the lines are joined in order with a comma and a space. + /// RFC 9110 §5.3 combines lines the same way, so one combined value serves both parsing and the signature base. + /// + public static string Combine(IReadOnlyList fieldLines) + { + return string.Join(", ", fieldLines.Select(line => line.Trim(' ', '\t'))); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs new file mode 100644 index 0000000000..2ee8fe582f --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs @@ -0,0 +1,398 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity; + +/// +/// Authenticates the signed requests of agents, as CONTRACT.md §6 specifies. +/// +/// +/// Each request goes through the six steps of CONTRACT.md §6 in order, and the first step that fails decides the error. +/// Exceptions from the certificate registry and the nonce store propagate to the caller. +/// +public sealed class RequestAuthenticator +{ + // CONTRACT.md §6 step 3. + private const long MaxSignatureLifetimeSeconds = 300; + private const long ClockToleranceSeconds = 60; + + // CONTRACT.md §7.3 step 2. + private const int ChallengeLength = 32; + + // RFC 5480 §2.1.1: id-ecPublicKey. + private const string EcPublicKeyOid = "1.2.840.10045.2.1"; + + // CONTRACT.md §6 step 6, and the shared intent: a nonce is kept until 60 s after its signature expires. + private static readonly TimeSpan NonceRetentionAfterExpiry = TimeSpan.FromSeconds(60); + + // RFC 5480 §2.1.1.1: the DER encoding of the namedCurve secp256r1, which is P-256. + private static readonly byte[] P256CurveParameters = [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]; + + private readonly ICertificateRegistry _certificateRegistry; + private readonly INonceStore _nonceStore; + private readonly TimeProvider _timeProvider; + private readonly RandomNumberGenerator _randomNumberGenerator; + + /// + /// Creates an authenticator over the host's certificate registry and nonce store. + /// + /// The certificates that the authority issued. + /// The nonces of accepted signatures. + /// The authority's clock. + /// The source of the random bytes of each channel challenge. + /// An argument is null. + public RequestAuthenticator( + ICertificateRegistry certificateRegistry, + INonceStore nonceStore, + TimeProvider timeProvider, + RandomNumberGenerator randomNumberGenerator) + { + ArgumentNullException.ThrowIfNull(certificateRegistry); + ArgumentNullException.ThrowIfNull(nonceStore); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(randomNumberGenerator); + + _certificateRegistry = certificateRegistry; + _nonceStore = nonceStore; + _timeProvider = timeProvider; + _randomNumberGenerator = randomNumberGenerator; + } + + /// + /// Authenticates a signed renew, confirm or check-in request. + /// + /// + /// A body larger than fails first, + /// with . + /// The request then goes through the steps of CONTRACT.md §6, + /// and its nonce is recorded only once every other step has passed. + /// + /// The operation of the endpoint that received the request. + /// The request, as received. + /// A token that cancels the registry lookup and the nonce recording. + /// + /// is , + /// which authenticates, or is not a defined operation. + /// + /// is null. + /// + /// The registry returned a certificate with another thumbprint, or one whose key is not a P-256 key. + /// + public async ValueTask AuthenticateAsync( + AgentOperation operation, + SignedHttpRequest request, + CancellationToken cancellationToken = default) + { + // A signed opening request never authenticates a stream on its own, + // so only the channel handshake turns a verified opening into an identity. + if (operation == AgentOperation.Connect) + { + throw new ArgumentOutOfRangeException( + nameof(operation), + operation, + "A channel opening is authenticated by AuthenticateChannelOpeningAsync."); + } + + SignedOperation signedOperation = SignedOperation.For(operation); + ArgumentNullException.ThrowIfNull(request); + + if (request.Body.Length > AgentIdentityProtocol.MaxRequestBodyBytes) + { + return AuthenticationResult.Failure( + AgentIdentityError.BodyTooLarge, + "The request body is larger than 64 KiB."); + } + + Verification verification = await VerifyAsync( + signedOperation, + request.Method, + request.SignatureInput, + request.Signature, + request.ContentDigest, + request.Body, + cancellationToken) + .ConfigureAwait(false); + + return verification.Accepted + ? AuthenticationResult.Success(verification.Certificate.ToIdentity(operation)) + : AuthenticationResult.Failure(verification.Error, verification.FailureDetail); + } + + /// + /// Authenticates the signed opening request of an agent channel stream, and prepares the stream's challenge. + /// + /// + /// The opening request goes through the steps of CONTRACT.md §6 with tag connect, + /// and its nonce is recorded only once every other step has passed. + /// A verified opening does not authenticate the stream: + /// the agent must still sign . + /// + /// The request method, as received. + /// The signature-input metadata entries in received order. + /// The signature metadata entries in received order. + /// A token that cancels the registry lookup and the nonce recording. + /// An argument is null. + /// A metadata entry is null. + /// + /// The registry returned a certificate with another thumbprint, or one whose key is not a P-256 key. + /// + public async ValueTask AuthenticateChannelOpeningAsync( + string method, + IReadOnlyList signatureInput, + IReadOnlyList signature, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(method); + string[] signatureInputLines = FieldLines.Copy(signatureInput, nameof(signatureInput)); + string[] signatureLines = FieldLines.Copy(signature, nameof(signature)); + + Verification verification = await VerifyAsync( + SignedOperation.Connect, + method, + signatureInputLines, + signatureLines, + [], + ReadOnlyMemory.Empty, + cancellationToken) + .ConfigureAwait(false); + + if (!verification.Accepted) + { + return ChannelOpeningResult.Failure(verification.Error, verification.FailureDetail); + } + + byte[] challenge = new byte[ChallengeLength]; + _randomNumberGenerator.GetBytes(challenge); + + return ChannelOpeningResult.Success( + new ChannelOpening(verification.Certificate, verification.Nonce, Guid.NewGuid(), challenge)); + } + + private async ValueTask VerifyAsync( + SignedOperation operation, + string method, + IReadOnlyList signatureInput, + IReadOnlyList signature, + IReadOnlyList contentDigest, + ReadOnlyMemory body, + CancellationToken cancellationToken) + { + // Step 1. + if (!SignatureFields.TryParse( + operation, + signatureInput, + signature, + contentDigest, + out SignatureFields? fields, + out string? failureDetail)) + { + return Verification.Reject(AgentIdentityErrorCode.SignatureInvalid, failureDetail); + } + + // Step 2. + if (fields.Tag != operation.Tag) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 2: the tag is not the operation's tag."); + } + + // Step 3. + // Steps 3 and 4 compare against one reading of the clock, exactly, so that they agree on what now is. + DateTimeOffset now = _timeProvider.GetUtcNow(); + if (!IsWithinWindow(fields.Created, fields.Expires, now)) + { + return Verification.Reject( + AgentIdentityErrorCode.ClockSkew, + "Step 3: created and expires are outside the accepted window."); + } + + // Step 4. + RegisteredCertificate? registered = await _certificateRegistry + .FindByThumbprintAsync(fields.KeyId, cancellationToken) + .ConfigureAwait(false); + + if (registered is null) + { + return Verification.Reject( + AgentIdentityErrorCode.IdentityUnknown, + "Step 4: no certificate is registered with the keyid, or its identity was deleted."); + } + + using X509Certificate2 certificate = LoadRegisteredCertificate(fields.KeyId, registered); + + if (registered.Status is not (CertificateStatus.Pending or CertificateStatus.Current)) + { + return Verification.Reject(AgentIdentityErrorCode.IdentityUnknown, "Step 4: the certificate is retired."); + } + + if (registered.IdentityRevoked) + { + return Verification.Reject(AgentIdentityErrorCode.IdentityRevoked, "Step 4: the identity is revoked."); + } + + DateTimeOffset notBefore = new(certificate.NotBefore.ToUniversalTime()); + DateTimeOffset notAfter = new(certificate.NotAfter.ToUniversalTime()); + + if (!operation.AcceptsValidity(notBefore, notAfter, now)) + { + return Verification.Reject( + AgentIdentityErrorCode.CertificateExpired, + operation.HasRenewalGrace + ? "Step 4: the certificate is expired beyond its renewal grace." + : "Step 4: the certificate is expired."); + } + + // Step 5. + // UNTESTED: the digest comparison takes the same time whichever bytes differ. + // The digest is not secret, but a constant-time comparison needs no argument that its timing reveals nothing. + // When changing this check, keep CryptographicOperations.FixedTimeEquals. + if (fields.ContentDigest is { } digest + && !CryptographicOperations.FixedTimeEquals(SHA256.HashData(body.Span), digest)) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 5: Content-Digest does not match the body."); + } + + if (!fields.TryBuildSignatureBase(method, out byte[]? signatureBase)) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 5: the method is not an HTTP token, so the signature base has no @method."); + } + + using ECDsa publicKey = LoadPublicKey(certificate); + + if (!EcdsaP256Signature.Verify(publicKey, signatureBase, fields.Signature)) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 5: the signature does not verify."); + } + + // Step 6. + DateTimeOffset retainUntil = DateTimeOffset.FromUnixTimeSeconds(fields.Expires) + NonceRetentionAfterExpiry; + bool recorded = await _nonceStore + .TryRecordAsync(fields.KeyId, fields.Nonce, retainUntil, cancellationToken) + .ConfigureAwait(false); + + if (!recorded) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 6: the nonce was already accepted with the keyid."); + } + + VerifiedCertificate verified = new( + registered.IdentityId, + fields.KeyId, + registered.Status, + notBefore, + notAfter, + publicKey.ExportSubjectPublicKeyInfo()); + + return Verification.Accept(verified, fields.Nonce); + } + + // CONTRACT.md §6 step 3: 0 < expires − created ≤ 300, created ≤ now + 60 and expires ≥ now − 60. + private static bool IsWithinWindow(long created, long expires, DateTimeOffset now) + { + // Both are integers of at most 15 digits, so their difference fits in a long. + long lifetime = expires - created; + if (lifetime is <= 0 or > MaxSignatureLifetimeSeconds) + { + return false; + } + + // Ticks in 128 bits keep the comparison exact, for times outside the range of DateTimeOffset too. + Int128 nowTicks = (now - DateTimeOffset.UnixEpoch).Ticks; + Int128 toleranceTicks = (Int128)ClockToleranceSeconds * TimeSpan.TicksPerSecond; + + return (Int128)created * TimeSpan.TicksPerSecond <= nowTicks + toleranceTicks + && (Int128)expires * TimeSpan.TicksPerSecond >= nowTicks - toleranceTicks; + } + + // The registry answers a thumbprint with a certificate that the authority issued, so anything else is a host bug, + // which fails as a server error rather than as a verdict on the agent. + private static X509Certificate2 LoadRegisteredCertificate(string keyId, RegisteredCertificate registered) + { + ReadOnlySpan der = registered.CertificateDer.Span; + + if (AgentIdentityProtocol.ComputeThumbprint(der) != keyId) + { + throw new InvalidOperationException( + "The certificate registry returned a certificate whose thumbprint is not the keyid it was asked for."); + } + + X509Certificate2 certificate; + try + { + certificate = X509CertificateLoader.LoadCertificate(der); + } + catch (CryptographicException exception) + { + throw new InvalidOperationException( + "The certificate registry returned bytes that are not an X.509 certificate.", + exception); + } + + PublicKey publicKey = certificate.PublicKey; + byte[]? curve = publicKey.EncodedParameters?.RawData; + if (publicKey.Oid.Value != EcPublicKeyOid + || curve is null + || !curve.AsSpan().SequenceEqual(P256CurveParameters)) + { + certificate.Dispose(); + throw new InvalidOperationException( + "The certificate registry returned a certificate whose key is not a P-256 key."); + } + + return certificate; + } + + private static ECDsa LoadPublicKey(X509Certificate2 certificate) + { + try + { + return certificate.GetECDsaPublicKey() + ?? throw new InvalidOperationException( + "The certificate registry returned a certificate whose key is not an EC key."); + } + catch (CryptographicException exception) + { + throw new InvalidOperationException( + "The certificate registry returned a certificate whose P-256 key cannot be loaded.", + exception); + } + } + + /// + /// The outcome of the steps of CONTRACT.md §6: the verified certificate and nonce, or the error of the failed step. + /// + private sealed class Verification + { + [MemberNotNullWhen(true, nameof(Certificate), nameof(Nonce))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Accepted { get; private init; } + + public VerifiedCertificate? Certificate { get; private init; } + + public string? Nonce { get; private init; } + + public AgentIdentityError? Error { get; private init; } + + public string? FailureDetail { get; private init; } + + public static Verification Accept(VerifiedCertificate certificate, string nonce) + { + return new Verification { Accepted = true, Certificate = certificate, Nonce = nonce }; + } + + public static Verification Reject(AgentIdentityErrorCode code, string failureDetail) + { + return new Verification { Error = AgentIdentityError.From(code), FailureDetail = failureDetail }; + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignatureFields.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignatureFields.cs new file mode 100644 index 0000000000..53ced62a21 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignatureFields.cs @@ -0,0 +1,315 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using static Devolutions.AgentIdentity.StructuredField; + +namespace Devolutions.AgentIdentity; + +/// +/// The Signature-Input, Signature and Content-Digest fields of a signed request, +/// parsed and checked against the profile of CONTRACT.md §6 step 1. +/// +/// +/// One parse drives both the profile checks and the signature base, +/// so the parameters that the checks accept are exactly those the signature covers. +/// +internal sealed class SignatureFields +{ + private const string Label = "sig"; + private const string Algorithm = "ecdsa-p256-sha256"; + private const string DigestAlgorithm = "sha-256"; + private const int ThumbprintLength = 32; + private const int NonceLength = 16; + + private static readonly string[] ParameterNames = ["created", "expires", "nonce", "keyid", "alg", "tag"]; + + private readonly InnerList _signatureInput; + private readonly string? _contentDigestValue; + + private SignatureFields( + InnerList signatureInput, + long created, + long expires, + string nonce, + string keyId, + string tag, + byte[] signature, + byte[]? contentDigest, + string? contentDigestValue) + { + _signatureInput = signatureInput; + Created = created; + Expires = expires; + Nonce = nonce; + KeyId = keyId; + Tag = tag; + Signature = signature; + ContentDigest = contentDigest; + _contentDigestValue = contentDigestValue; + } + + public long Created { get; } + + public long Expires { get; } + + /// + /// The nonce, as its canonical base64url text. + /// + public string Nonce { get; } + + /// + /// The thumbprint of the signing certificate, as its canonical base64url text. + /// + public string KeyId { get; } + + public string Tag { get; } + + public byte[] Signature { get; } + + /// + /// The SHA-256 digest that Content-Digest declares, for an operation that covers it; otherwise null. + /// + public byte[]? ContentDigest { get; } + + /// + /// Parses and checks the signature fields of a request for an operation. + /// + /// + /// Content-Digest is read only when the operation covers it; otherwise it is ignored. + /// The signature and digest lengths are not checked here, since verification checks them. + /// On failure, names the rule that the fields break. + /// + public static bool TryParse( + SignedOperation operation, + IReadOnlyList signatureInput, + IReadOnlyList signature, + IReadOnlyList contentDigest, + [NotNullWhen(true)] out SignatureFields? fields, + [NotNullWhen(false)] out string? failureDetail) + { + fields = null; + + if (!TryParseSingleMember( + signatureInput, + "Signature-Input", + Label, + out MemberValue? inputMember, + out failureDetail)) + { + return false; + } + + if (inputMember is not InnerList innerList) + { + failureDetail = "Step 1: the sig member of Signature-Input is not an inner list."; + return false; + } + + if (!innerList.Items.All(item => item is { Value: StringItem, Parameters.Count: 0 })) + { + failureDetail = "Step 1: a covered component is not a string without parameters."; + return false; + } + + IEnumerable components = innerList.Items.Select(item => ((StringItem)item.Value).Value); + if (!components.SequenceEqual(operation.CoveredComponents)) + { + failureDetail = "Step 1: the covered components are not exactly those of the operation, in order."; + return false; + } + + // The parser rejects a repeated key, so six known keys are the six parameters, each once. + IReadOnlyList parameters = innerList.Parameters; + if (parameters.Count != ParameterNames.Length + || !parameters.All(parameter => ParameterNames.Contains(parameter.Key))) + { + failureDetail = "Step 1: the parameters are not exactly created, expires, nonce, keyid, alg and tag."; + return false; + } + + if (Find(parameters, "created") is not IntegerItem created + || Find(parameters, "expires") is not IntegerItem expires + || Find(parameters, "nonce") is not StringItem nonce + || Find(parameters, "keyid") is not StringItem keyId + || Find(parameters, "alg") is not StringItem alg + || Find(parameters, "tag") is not StringItem tag) + { + failureDetail = "Step 1: created or expires is not an integer, or another parameter is not a string."; + return false; + } + + // A malformed keyid fails here rather than at the certificate lookup, + // so that an agent bug never draws the terminal identity_unknown. + if (!StrictBase64.TryDecodeBase64Url(keyId.Value, out _, exactLength: ThumbprintLength)) + { + failureDetail = "Step 1: keyid is not a certificate thumbprint in canonical base64url."; + return false; + } + + if (!StrictBase64.TryDecodeBase64Url(nonce.Value, out _, exactLength: NonceLength)) + { + failureDetail = "Step 1: nonce is not 16 bytes in canonical base64url."; + return false; + } + + if (alg.Value != Algorithm) + { + failureDetail = "Step 1: alg is not ecdsa-p256-sha256."; + return false; + } + + if (!TryParseSingleMember(signature, "Signature", Label, out MemberValue? signatureMember, out failureDetail)) + { + return false; + } + + if (!TryDecodeByteSequence(signatureMember, out byte[]? signatureBytes)) + { + failureDetail = "Step 1: the signature is not canonical base64 without parameters."; + return false; + } + + byte[]? digest = null; + string? contentDigestValue = null; + + if (operation.CoversContentDigest) + { + if (!TryParseSingleMember( + contentDigest, + "Content-Digest", + DigestAlgorithm, + out MemberValue? digestMember, + out failureDetail)) + { + return false; + } + + if (!TryDecodeByteSequence(digestMember, out digest)) + { + failureDetail = "Step 1: the sha-256 digest is not canonical base64 without parameters."; + return false; + } + + // RFC 9421 §2.1: a covered field contributes its combined value, as received. + contentDigestValue = FieldLines.Combine(contentDigest); + } + + fields = new SignatureFields( + innerList, + created.Value, + expires.Value, + nonce.Value, + keyId.Value, + tag.Value, + signatureBytes, + digest, + contentDigestValue); + failureDetail = null; + return true; + } + + /// + /// Builds the signature base of RFC 9421 §2.5 for the request. + /// + /// The request method, as received, which is the value of @method. + /// The ASCII bytes of the signature base. + /// False when the method is not an RFC 9110 token, so @method has no value. + public bool TryBuildSignatureBase(string method, [NotNullWhen(true)] out byte[]? signatureBase) + { + signatureBase = null; + + // RFC 9110 §9.1 defines a method as a token, + // so the base stays ASCII and no component value can add a line of its own. + if (!IsToken(method)) + { + return false; + } + + StringBuilder builder = new(); + + foreach (Item component in _signatureInput.Items) + { + string name = ((StringItem)component.Value).Value; + string value = name switch + { + SignedOperation.MethodComponent => method, + SignedOperation.ContentDigestComponent => _contentDigestValue + ?? throw new UnreachableException("Content-Digest is covered but was not read."), + _ => throw new UnreachableException("A covered component is outside the operation's profile."), + }; + + builder.Append(SerializeString(name)).Append(": ").Append(value).Append('\n'); + } + + builder.Append("\"@signature-params\": "); + AppendSignatureParameters(builder); + + signatureBase = Encoding.ASCII.GetBytes(builder.ToString()); + return true; + } + + // RFC 9421 §2.3: the value of @signature-params is the serialization of the received Signature-Input member, + // with its parameters in received order, so the base never holds the received Signature-Input text. + private void AppendSignatureParameters(StringBuilder builder) + { + builder.Append('('); + builder.AppendJoin(' ', _signatureInput.Items.Select(item => SerializeString(((StringItem)item.Value).Value))); + builder.Append(')'); + + foreach (Parameter parameter in _signatureInput.Parameters) + { + builder.Append(';').Append(parameter.Key).Append('=').Append(parameter.Value switch + { + IntegerItem integer => SerializeInteger(integer.Value), + StringItem text => SerializeString(text.Value), + _ => throw new UnreachableException("A signature parameter is outside the profile."), + }); + } + } + + private static bool TryParseSingleMember( + IReadOnlyList fieldLines, + string fieldName, + string key, + [NotNullWhen(true)] out MemberValue? value, + [NotNullWhen(false)] out string? failureDetail) + { + value = null; + + if (fieldLines.Count == 0) + { + failureDetail = $"Step 1: {fieldName} is absent."; + return false; + } + + if (!TryParseDictionary(FieldLines.Combine(fieldLines), out IReadOnlyList? dictionary)) + { + failureDetail = $"Step 1: {fieldName} is not an RFC 8941 dictionary without repeated keys."; + return false; + } + + if (dictionary is not [{ Key: var memberKey, Value: var memberValue }] || memberKey != key) + { + failureDetail = $"Step 1: {fieldName} does not hold exactly one member, {key}."; + return false; + } + + value = memberValue; + failureDetail = null; + return true; + } + + private static bool TryDecodeByteSequence(MemberValue value, [NotNullWhen(true)] out byte[]? bytes) + { + bytes = null; + + return value is Item { Value: ByteSequenceItem sequence, Parameters.Count: 0 } + && StrictBase64.TryDecodeBase64(sequence.Base64, out bytes); + } + + private static BareItem? Find(IReadOnlyList parameters, string key) + { + return parameters.FirstOrDefault(parameter => parameter.Key == key)?.Value; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedHttpRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedHttpRequest.cs new file mode 100644 index 0000000000..8483134735 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedHttpRequest.cs @@ -0,0 +1,64 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The parts of an HTTP request that request authentication reads, as the host received them. +/// +public sealed class SignedHttpRequest +{ + /// + /// Creates the request from what the host received. + /// + /// The request method, as received. + /// + /// The Signature-Input field lines in received order; empty when the field is absent. + /// + /// + /// The Signature field lines in received order; empty when the field is absent. + /// + /// + /// The Content-Digest field lines in received order; empty when the field is absent. + /// + /// The request body, as received. + /// An argument is null. + /// A field line is null. + public SignedHttpRequest( + string method, + IReadOnlyList signatureInput, + IReadOnlyList signature, + IReadOnlyList contentDigest, + ReadOnlyMemory body) + { + ArgumentNullException.ThrowIfNull(method); + + Method = method; + SignatureInput = FieldLines.Copy(signatureInput, nameof(signatureInput)); + Signature = FieldLines.Copy(signature, nameof(signature)); + ContentDigest = FieldLines.Copy(contentDigest, nameof(contentDigest)); + Body = body; + } + + /// + /// The request method, as received. + /// + public string Method { get; } + + /// + /// The Signature-Input field lines in received order. + /// + public IReadOnlyList SignatureInput { get; } + + /// + /// The Signature field lines in received order. + /// + public IReadOnlyList Signature { get; } + + /// + /// The Content-Digest field lines in received order. + /// + public IReadOnlyList ContentDigest { get; } + + /// + /// The request body, as received. + /// + public ReadOnlyMemory Body { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedOperation.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedOperation.cs new file mode 100644 index 0000000000..973b7eb4ad --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedOperation.cs @@ -0,0 +1,87 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The signature profile of one operation: its tag, its covered components and the certificate validity it accepts. +/// +/// +/// These are the rows of the table in CONTRACT.md §6. +/// +internal sealed class SignedOperation +{ + public const string MethodComponent = "@method"; + public const string ContentDigestComponent = "content-digest"; + + private SignedOperation(string tag, string[] coveredComponents, bool hasRenewalGrace) + { + Tag = tag; + CoveredComponents = coveredComponents; + CoversContentDigest = coveredComponents.Contains(ContentDigestComponent); + HasRenewalGrace = hasRenewalGrace; + } + + public static SignedOperation Renew { get; } = + new("renew", [MethodComponent, ContentDigestComponent], hasRenewalGrace: true); + + public static SignedOperation Confirm { get; } = + new("confirm", [MethodComponent], hasRenewalGrace: false); + + public static SignedOperation CheckIn { get; } = + new("check-in", [MethodComponent, ContentDigestComponent], hasRenewalGrace: false); + + public static SignedOperation Connect { get; } = + new("connect", [MethodComponent], hasRenewalGrace: false); + + public string Tag { get; } + + /// + /// The components that a signature for this operation covers, in the order it must list them. + /// + public IReadOnlyList CoveredComponents { get; } + + public bool CoversContentDigest { get; } + + /// + /// Whether the operation also accepts a certificate expired by less than its own lifetime. + /// + public bool HasRenewalGrace { get; } + + /// + /// Returns the profile of an operation. + /// + /// + /// is not a defined operation. + /// + public static SignedOperation For(AgentOperation operation) + { + return operation switch + { + AgentOperation.Renew => Renew, + AgentOperation.Confirm => Confirm, + AgentOperation.CheckIn => CheckIn, + AgentOperation.Connect => Connect, + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "Not a defined agent operation."), + }; + } + + /// + /// Tells whether this operation accepts a current or pending certificate with this validity + /// at . + /// + /// + /// A certificate is unexpired while now ≤ notAfter, as in RFC 5280 §4.1.2.5. + /// notBefore is not checked: the authority issued the certificate itself, + /// so a future notBefore only reflects the authority's clock. + /// Renewal also accepts a certificate expired by less than its own lifetime. + /// + public bool AcceptsValidity(DateTimeOffset notBefore, DateTimeOffset notAfter, DateTimeOffset now) + { + if (now <= notAfter) + { + return true; + } + + // now < notAfter + (notAfter − notBefore), compared as differences, + // which cannot overflow for a notAfter close to the largest DateTimeOffset. + return HasRenewalGrace && now - notAfter < notAfter - notBefore; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/StructuredField.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/StructuredField.cs new file mode 100644 index 0000000000..16e5cd4b2d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/StructuredField.cs @@ -0,0 +1,621 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// Parses RFC 8941 structured field dictionaries, and serializes the values that a signature base holds. +/// +/// +/// Parsing follows RFC 8941 §4.2, except that a repeated dictionary key or parameter key fails it. +/// RFC 8941 keeps the last of repeated keys, +/// so a field that repeats one could be read differently by a proxy, a log or another parser. +/// A byte sequence keeps its base64 text, so that the caller decodes it as strictly as its protocol requires. +/// +internal static class StructuredField +{ + /// + /// Parses a field value as a dictionary. + /// + /// The field value, with all its field lines combined. + /// + /// The members in received order, when the value is a dictionary without repeated keys. + /// + public static bool TryParseDictionary( + string input, + [NotNullWhen(true)] out IReadOnlyList? dictionary) + { + dictionary = null; + + // RFC 8941 §4.2 step 1: a value that is not ASCII fails parsing. + if (!Ascii.IsValid(input)) + { + return false; + } + + Reader reader = new(input); + reader.SkipSpaces(); + + // The dictionary rule consumes trailing white space and fails on anything else, + // so the input is empty when it succeeds. + if (!reader.TryParseDictionary(out List? members)) + { + return false; + } + + dictionary = members; + return true; + } + + /// + /// Serializes a string, as RFC 8941 §4.1.6 does. + /// + /// + /// A string of printable ASCII characters, such as one that produced. + /// + public static string SerializeString(string value) + { + StringBuilder builder = new(value.Length + 2); + builder.Append('"'); + + foreach (char c in value) + { + if (c is '"' or '\\') + { + builder.Append('\\'); + } + + builder.Append(c); + } + + return builder.Append('"').ToString(); + } + + /// + /// Serializes an integer, as RFC 8941 §4.1.4 does. + /// + /// + /// An integer of at most 15 digits, such as one that produced. + /// + public static string SerializeInteger(long value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + /// + /// Tells whether a value is an RFC 9110 token, such as a request method. + /// + public static bool IsToken(string value) + { + return value.Length > 0 && value.All(IsTokenCharacter); + } + + // RFC 9110 §5.6.2: tchar. + private static bool IsTokenCharacter(char c) + { + return char.IsAsciiLetterOrDigit(c) + || c is '!' or '#' or '$' or '%' or '&' or '\'' or '*' or '+' or '-' or '.' or '^' or '_' or '`' or '|' + or '~'; + } + + /// + /// A value without parameters: an integer, a decimal, a string, a token, a byte sequence or a Boolean. + /// + public abstract record BareItem; + + public sealed record IntegerItem(long Value) : BareItem; + + public sealed record DecimalItem(decimal Value) : BareItem; + + public sealed record StringItem(string Value) : BareItem; + + public sealed record TokenItem(string Value) : BareItem; + + /// + /// The text between the colons, which holds only base64 characters but may not be canonical. + /// + public sealed record ByteSequenceItem(string Base64) : BareItem; + + public sealed record BooleanItem(bool Value) : BareItem; + + public sealed record Parameter(string Key, BareItem Value); + + /// + /// The value of a dictionary member: an item or an inner list. + /// + public abstract record MemberValue; + + public sealed record Item(BareItem Value, IReadOnlyList Parameters) : MemberValue; + + public sealed record InnerList(IReadOnlyList Items, IReadOnlyList Parameters) : MemberValue; + + public sealed record DictionaryMember(string Key, MemberValue Value); + + /// + /// The parsing algorithms of RFC 8941 §4.2, each consuming the input from the current position. + /// + /// + /// UNTESTED: parsing takes time linear in the length of the input. + /// The input is an unauthenticated header, + /// so a quadratic step would let one request cost a quadratic amount of work. + /// When changing a rule, keep each key lookup a set lookup and read each character a bounded number of times. + /// + private sealed class Reader(string input) + { + private int _position; + + private bool AtEnd => _position == input.Length; + + public void SkipSpaces() + { + while (Next(' ')) + { + _position++; + } + } + + // RFC 8941 §4.2.2. + public bool TryParseDictionary([NotNullWhen(true)] out List? dictionary) + { + dictionary = null; + List members = []; + HashSet keys = new(StringComparer.Ordinal); + + while (!AtEnd) + { + if (!TryParseKey(out string? key)) + { + return false; + } + + MemberValue? value; + if (TryConsume('=')) + { + if (!TryParseItemOrInnerList(out value)) + { + return false; + } + } + else + { + if (!TryParseParameters(out List? parameters)) + { + return false; + } + + value = new Item(new BooleanItem(true), parameters); + } + + if (!keys.Add(key)) + { + return false; + } + + members.Add(new DictionaryMember(key, value)); + + SkipOptionalWhitespace(); + if (AtEnd) + { + break; + } + + if (!TryConsume(',')) + { + return false; + } + + SkipOptionalWhitespace(); + + // A trailing comma. + if (AtEnd) + { + return false; + } + } + + dictionary = members; + return true; + } + + // RFC 8941 §4.2.1.1. + private bool TryParseItemOrInnerList([NotNullWhen(true)] out MemberValue? value) + { + value = null; + + if (Next('(')) + { + if (!TryParseInnerList(out InnerList? innerList)) + { + return false; + } + + value = innerList; + return true; + } + + if (!TryParseItem(out Item? item)) + { + return false; + } + + value = item; + return true; + } + + // RFC 8941 §4.2.1.2. + private bool TryParseInnerList([NotNullWhen(true)] out InnerList? innerList) + { + innerList = null; + + if (!TryConsume('(')) + { + return false; + } + + List items = []; + + while (!AtEnd) + { + SkipSpaces(); + + if (TryConsume(')')) + { + if (!TryParseParameters(out List? parameters)) + { + return false; + } + + innerList = new InnerList(items, parameters); + return true; + } + + if (!TryParseItem(out Item? item)) + { + return false; + } + + items.Add(item); + + if (!Next(' ') && !Next(')')) + { + return false; + } + } + + // The end of the inner list was not found. + return false; + } + + // RFC 8941 §4.2.3. + private bool TryParseItem([NotNullWhen(true)] out Item? item) + { + item = null; + + if (!TryParseBareItem(out BareItem? value) || !TryParseParameters(out List? parameters)) + { + return false; + } + + item = new Item(value, parameters); + return true; + } + + // RFC 8941 §4.2.3.1. + private bool TryParseBareItem([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (AtEnd) + { + return false; + } + + char first = input[_position]; + + if (first == '-' || char.IsAsciiDigit(first)) + { + return TryParseIntegerOrDecimal(out value); + } + + if (first == '"') + { + return TryParseString(out value); + } + + if (char.IsAsciiLetter(first) || first == '*') + { + return TryParseToken(out value); + } + + if (first == ':') + { + return TryParseByteSequence(out value); + } + + if (first == '?') + { + return TryParseBoolean(out value); + } + + return false; + } + + // RFC 8941 §4.2.3.2. + private bool TryParseParameters([NotNullWhen(true)] out List? parameters) + { + parameters = null; + List result = []; + HashSet keys = new(StringComparer.Ordinal); + + while (TryConsume(';')) + { + SkipSpaces(); + + if (!TryParseKey(out string? key)) + { + return false; + } + + BareItem value = new BooleanItem(true); + if (TryConsume('=')) + { + if (!TryParseBareItem(out BareItem? parsed)) + { + return false; + } + + value = parsed; + } + + if (!keys.Add(key)) + { + return false; + } + + result.Add(new Parameter(key, value)); + } + + parameters = result; + return true; + } + + // RFC 8941 §4.2.3.3. + private bool TryParseKey([NotNullWhen(true)] out string? key) + { + key = null; + + if (AtEnd || !(char.IsAsciiLetterLower(input[_position]) || input[_position] == '*')) + { + return false; + } + + int start = _position; + while (!AtEnd && IsKeyCharacter(input[_position])) + { + _position++; + } + + key = input[start.._position]; + return true; + } + + // RFC 8941 §4.2.4. + private bool TryParseIntegerOrDecimal([NotNullWhen(true)] out BareItem? value) + { + value = null; + + bool negative = TryConsume('-'); + if (AtEnd || !char.IsAsciiDigit(input[_position])) + { + return false; + } + + int start = _position; + bool isDecimal = false; + + while (!AtEnd) + { + char c = input[_position]; + + if (char.IsAsciiDigit(c)) + { + _position++; + } + else if (!isDecimal && c == '.') + { + // At most 12 digits before the decimal point. + if (_position - start > 12) + { + return false; + } + + isDecimal = true; + _position++; + } + else + { + break; + } + + // At most 15 digits in an integer, and 16 characters in a decimal including its point. + if (_position - start > (isDecimal ? 16 : 15)) + { + return false; + } + } + + ReadOnlySpan number = input.AsSpan(start, _position - start); + + if (!isDecimal) + { + long integer = long.Parse(number, NumberStyles.None, CultureInfo.InvariantCulture); + value = new IntegerItem(negative ? -integer : integer); + return true; + } + + // One to three digits after the decimal point. + int fractionLength = number.Length - number.IndexOf('.') - 1; + if (fractionLength is 0 or > 3) + { + return false; + } + + decimal parsed = decimal.Parse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + value = new DecimalItem(negative ? -parsed : parsed); + return true; + } + + // RFC 8941 §4.2.5. + private bool TryParseString([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (!TryConsume('"')) + { + return false; + } + + StringBuilder builder = new(); + + while (!AtEnd) + { + char c = input[_position++]; + + if (c == '\\') + { + if (AtEnd) + { + return false; + } + + char escaped = input[_position++]; + if (escaped is not ('"' or '\\')) + { + return false; + } + + builder.Append(escaped); + } + else if (c == '"') + { + value = new StringItem(builder.ToString()); + return true; + } + else if (c is < ' ' or > '~') + { + return false; + } + else + { + builder.Append(c); + } + } + + // The closing quote was not found. + return false; + } + + // RFC 8941 §4.2.6. + private bool TryParseToken([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (AtEnd || !(char.IsAsciiLetter(input[_position]) || input[_position] == '*')) + { + return false; + } + + int start = _position; + while (!AtEnd && (IsTokenCharacter(input[_position]) || input[_position] is ':' or '/')) + { + _position++; + } + + value = new TokenItem(input[start.._position]); + return true; + } + + // RFC 8941 §4.2.7. + private bool TryParseByteSequence([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (!TryConsume(':')) + { + return false; + } + + int end = input.IndexOf(':', _position); + if (end < 0) + { + return false; + } + + string content = input[_position..end]; + _position = end + 1; + + if (!content.All(c => char.IsAsciiLetterOrDigit(c) || c is '+' or '/' or '=')) + { + return false; + } + + value = new ByteSequenceItem(content); + return true; + } + + // RFC 8941 §4.2.8. + private bool TryParseBoolean([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (!TryConsume('?')) + { + return false; + } + + if (TryConsume('1')) + { + value = new BooleanItem(true); + return true; + } + + if (TryConsume('0')) + { + value = new BooleanItem(false); + return true; + } + + return false; + } + + private void SkipOptionalWhitespace() + { + while (Next(' ') || Next('\t')) + { + _position++; + } + } + + private bool Next(char expected) + { + return !AtEnd && input[_position] == expected; + } + + private bool TryConsume(char expected) + { + if (!Next(expected)) + { + return false; + } + + _position++; + return true; + } + + private static bool IsKeyCharacter(char c) + { + return char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c is '_' or '-' or '.' or '*'; + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/VerifiedCertificate.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/VerifiedCertificate.cs new file mode 100644 index 0000000000..fbc743b19f --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/VerifiedCertificate.cs @@ -0,0 +1,19 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The facts about the certificate that authenticated a request: +/// the registry's state for it, and the validity and public key read from the certificate itself. +/// +internal sealed record VerifiedCertificate( + Guid IdentityId, + string Thumbprint, + CertificateStatus Status, + DateTimeOffset NotBefore, + DateTimeOffset NotAfter, + byte[] SubjectPublicKeyInfo) +{ + public AuthenticatedIdentity ToIdentity(AgentOperation operation) + { + return new AuthenticatedIdentity(IdentityId, Thumbprint, Status, NotBefore, NotAfter, operation); + } +} From 26b545974928bddab56bb902c0392d9a0444227f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:15:30 +0900 Subject: [PATCH 09/93] feat(dotnet-agent-identity): validate agent metadata Every authority must accept and reject the same metadata, whether it arrives in a request body or in a channel Hello, so the limits of the contract live in one place. Add AgentMetadata, a read-only dictionary that only validation creates, and AgentMetadata.Validate, which enforces the key pattern, the key count, the value length in UTF-8 bytes, the control-character ban and the 16 KiB total, and rejects repeated keys and strings that are not valid Unicode. A failure carries invalid_request and a detail for host logs that never quotes an invalid key. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentMetadataTests.cs | 232 ++++++++++++++++++ .../ConstructionTests.cs | 42 ++++ .../AgentMetadata.cs | 175 +++++++++++++ .../MetadataValidationResult.cs | 55 +++++ 4 files changed, 504 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentMetadataTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentMetadata.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/MetadataValidationResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentMetadataTests.cs new file mode 100644 index 0000000000..e0180586f2 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentMetadataTests.cs @@ -0,0 +1,232 @@ +namespace Devolutions.AgentIdentity.Tests; + +public class AgentMetadataTests +{ + // CONTRACT.md §3: metadata may be an empty object. + [Fact] + public void AcceptsNoEntries() + { + AgentMetadata metadata = AssertValid(AgentMetadata.Validate([])); + + Assert.Empty(metadata); + } + + [Fact] + public void KeepsEveryEntryInTheOrderReceived() + { + KeyValuePair[] entries = + [ + new("os_name", "Windows"), + new("hostname", "host"), + new("agent_version", "2026.3.0"), + ]; + + AgentMetadata metadata = AssertValid(AgentMetadata.Validate(entries)); + + Assert.Equal(entries, metadata); + Assert.Equal("host", metadata["hostname"]); + } + + // CONTRACT.md §3: at most 32 keys. + [Fact] + public void Accepts32Keys() + { + AgentMetadata metadata = AssertValid(AgentMetadata.Validate(NumberedEntries(32))); + + Assert.Equal(32, metadata.Count); + } + + [Fact] + public void Rejects33Keys() + { + AssertInvalid(AgentMetadata.Validate(NumberedEntries(33))); + } + + // CONTRACT.md §3: a key matches ^[a-z][a-z0-9_]{0,63}$. + [Theory] + [InlineData("a")] + [InlineData("hostname")] + [InlineData("os_version")] + [InlineData("z9_")] + [InlineData("abcdefghijklmnopqrstuvwxyz0123456789_")] + public void AcceptsKeyMatchingThePattern(string key) + { + AssertValid(AgentMetadata.Validate([new(key, "value")])); + } + + [Fact] + public void AcceptsKeyOf64Characters() + { + AssertValid(AgentMetadata.Validate([new("k" + new string('0', 63), "value")])); + } + + [Fact] + public void RejectsKeyOf65Characters() + { + AssertInvalid(AgentMetadata.Validate([new("k" + new string('0', 64), "value")])); + } + + // A case-insensitive match would take the Kelvin sign for 'k', + // and a .NET regular expression ending in $ also matches before a final line feed. + [Theory] + [InlineData("empty", "")] + [InlineData("uppercase first letter", "Hostname")] + [InlineData("uppercase letter", "hostName")] + [InlineData("leading digit", "1hostname")] + [InlineData("leading underscore", "_hostname")] + [InlineData("hyphen", "os-name")] + [InlineData("space", "os name")] + [InlineData("trailing line feed", "hostname\n")] + [InlineData("non-ASCII letter", "h\u00F4stname")] + [InlineData("Kelvin sign", "\u212Aey")] + [InlineData("fullwidth letter", "\uFF48ostname")] + public void RejectsKeyNotMatchingThePattern(string form, string key) + { + AssertInvalid(AgentMetadata.Validate([new(key, "value")]), form); + } + + // CONTRACT.md §3: a value has at most 1024 UTF-8 bytes. + // Characters of two, three and four bytes count by their bytes, not as one character or UTF-16 code unit. + [Theory] + [InlineData("a", 1024, "")] + [InlineData("\u00E9", 512, "")] + [InlineData("\u20AC", 341, "a")] + [InlineData("\U0001F600", 256, "")] + public void AcceptsValueOf1024Utf8Bytes(string unit, int count, string suffix) + { + AssertValid(AgentMetadata.Validate([new("hostname", Repeat(unit, count) + suffix)])); + } + + [Theory] + [InlineData("a", 1025, "")] + [InlineData("\u00E9", 512, "a")] + [InlineData("\u20AC", 341, "aa")] + [InlineData("\U0001F600", 256, "a")] + public void RejectsValueOf1025Utf8Bytes(string unit, int count, string suffix) + { + AssertInvalid(AgentMetadata.Validate([new("hostname", Repeat(unit, count) + suffix)])); + } + + [Fact] + public void AcceptsEmptyValue() + { + AssertValid(AgentMetadata.Validate([new("domain", "")])); + } + + // CONTRACT.md §3: no C0 (U+0000 to U+001F), DEL (U+007F) or C1 (U+0080 to U+009F) control characters. + public static TheoryData ControlCharacters => + new(Enumerable.Range(0x00, 0x20).Append(0x7F).Concat(Enumerable.Range(0x80, 0x20))); + + [Theory] + [MemberData(nameof(ControlCharacters))] + public void RejectsValueWithControlCharacter(int codePoint) + { + AssertInvalid(AgentMetadata.Validate([new("hostname", $"host{(char)codePoint}name")])); + } + + // The neighbors of the control ranges, including U+00A0, the first character after C1. + [Theory] + [InlineData(0x20)] + [InlineData(0x7E)] + [InlineData(0xA0)] + public void AcceptsValueWithCharacterNextToAControlRange(int codePoint) + { + AssertValid(AgentMetadata.Validate([new("hostname", $"host{(char)codePoint}name")])); + } + + // A string holding an unpaired surrogate is not Unicode text, so it has no UTF-8 encoding to measure or store. + [Theory] + [InlineData("lone high surrogate", 0x61, 0xD800, 0x62)] + [InlineData("lone low surrogate", 0x61, 0xDC00, 0x62)] + [InlineData("high surrogate at the end", 0x61, 0xD800)] + [InlineData("pair in reverse order", 0xDC00, 0xD800)] + public void RejectsValueWithUnpairedSurrogate(string form, params int[] codeUnits) + { + string value = new(codeUnits.Select(unit => (char)unit).ToArray()); + + AssertInvalid(AgentMetadata.Validate([new("hostname", value)]), form); + } + + // CONTRACT.md §3: the whole object has at most 16 KiB, the sum of the UTF-8 byte lengths of all keys and values. + // Each value has 1021 UTF-8 bytes but 511 characters, so a count of characters would stay far below the limit. + [Fact] + public void AcceptsObjectOf16KiB() + { + // 16 keys of 3 bytes and 16 values of 1021 bytes: 16 × 1024 = 16384 bytes. + AssertValid(AgentMetadata.Validate(EntriesOf1024Bytes(lastKey: "k15"))); + } + + [Fact] + public void RejectsObjectOf16KiBAndOneByte() + { + // The same with a last key of 4 bytes: 16385 bytes. + AssertInvalid(AgentMetadata.Validate(EntriesOf1024Bytes(lastKey: "k150"))); + } + + // A later entry with the same key would silently replace the earlier one. + [Fact] + public void RejectsRepeatedKey() + { + AssertInvalid(AgentMetadata.Validate([new("hostname", "a"), new("hostname", "b")])); + } + + // The failure detail goes to host logs, so it must not copy an invalid key, which could hold a line break. + [Fact] + public void FailureDetailDoesNotQuoteAnInvalidKey() + { + MetadataValidationResult result = AgentMetadata.Validate([new("host\nforged log line", "value")]); + + AssertInvalid(result); + Assert.DoesNotContain("forged log line", result.FailureDetail); + } + + // A null key or value can only come from the host, since neither JSON nor protobuf metadata can hold one. + [Fact] + public void ThrowsForNullKey() + { + Assert.Throws(() => AgentMetadata.Validate([new(null!, "value")])); + } + + [Fact] + public void ThrowsForNullValue() + { + Assert.Throws(() => AgentMetadata.Validate([new("hostname", null!)])); + } + + private static AgentMetadata AssertValid(MetadataValidationResult result) + { + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Null(result.Error); + Assert.Null(result.FailureDetail); + + return result.Metadata; + } + + private static void AssertInvalid(MetadataValidationResult result, string? form = null) + { + Assert.False(result.Succeeded, form); + Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); + Assert.Equal(400, result.Error.HttpStatusCode); + Assert.False(string.IsNullOrEmpty(result.FailureDetail)); + Assert.Null(result.Metadata); + } + + private static IEnumerable> NumberedEntries(int count) + { + return Enumerable.Range(0, count).Select(i => new KeyValuePair($"key_{i}", "value")); + } + + private static IEnumerable> EntriesOf1024Bytes(string lastKey) + { + string value = Repeat("\u00E9", 510) + "a"; + + return Enumerable.Range(0, 15) + .Select(i => new KeyValuePair($"k{i:D2}", value)) + .Append(new KeyValuePair(lastKey, value)); + } + + private static string Repeat(string unit, int count) + { + return string.Concat(Enumerable.Repeat(unit, count)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs new file mode 100644 index 0000000000..0ad7354d25 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs @@ -0,0 +1,42 @@ +using System.Reflection; + +namespace Devolutions.AgentIdentity.Tests; + +// An instance of each type below vouches that its value passed validation, +// so nothing but validation may create one: otherwise a host could pass unvalidated agent input in its place. +public class ConstructionTests +{ + public static TheoryData ValidatedTypes => new(typeof(AgentMetadata)); + + [Theory] + [MemberData(nameof(ValidatedTypes))] + public void HasNoPublicOrProtectedConstructor(Type type) + { + IEnumerable reachable = type + .GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(constructor => constructor.IsPublic || constructor.IsFamily || constructor.IsFamilyOrAssembly); + + Assert.Empty(reachable); + Assert.True(type.IsSealed); + } + + // Validation returns a result that holds the instance, + // so a public static member returning the type itself would be another factory. + [Theory] + [MemberData(nameof(ValidatedTypes))] + public void NoPublicStaticMemberReturnsTheType(Type type) + { + IEnumerable factories = typeof(AgentMetadata).Assembly + .GetExportedTypes() + .SelectMany(exported => exported.GetMembers(BindingFlags.Public | BindingFlags.Static)) + .Where(member => member switch + { + MethodInfo method => method.ReturnType == type, + PropertyInfo property => property.PropertyType == type, + FieldInfo field => field.FieldType == type, + _ => false, + }); + + Assert.Empty(factories); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentMetadata.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentMetadata.cs new file mode 100644 index 0000000000..f1e7e25858 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentMetadata.cs @@ -0,0 +1,175 @@ +using System.Buffers; +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// Metadata that an agent reported, such as its host name and OS version, checked against the limits of the protocol. +/// +/// +/// Metadata is informational, for display and friendly-name evaluation, and never authorizes anything. +/// Only and the request body parsers create instances, +/// so an instance always holds valid metadata. +/// Keys compare ordinally, and enumeration follows the order in which the agent sent the entries. +/// +public sealed class AgentMetadata : IReadOnlyDictionary +{ + private const int MaxKeys = 32; + private const int MaxKeyLength = 64; + private const int MaxValueBytes = 1024; + private const int MaxTotalBytes = 16 * 1024; + + private readonly OrderedDictionary _entries; + + private AgentMetadata(OrderedDictionary entries) + { + _entries = entries; + } + + /// + public int Count => _entries.Count; + + /// + public IEnumerable Keys => _entries.Keys; + + /// + public IEnumerable Values => _entries.Values; + + /// + public string this[string key] => _entries[key]; + + /// + /// Validates metadata received from an agent, such as the metadata of a Hello message. + /// + /// + /// Valid metadata has at most 32 keys, each matching ^[a-z][a-z0-9_]{0,63}$ and appearing once. + /// Each value is Unicode text of at most 1024 UTF-8 bytes without C0, DEL or C1 control characters, + /// and the UTF-8 lengths of all keys and values add up to at most 16 KiB. + /// + /// The metadata entries, in the order received. + /// An entry has a null key or value. + public static MetadataValidationResult Validate(IEnumerable> entries) + { + ArgumentNullException.ThrowIfNull(entries); + + OrderedDictionary validated = new(StringComparer.Ordinal); + int totalBytes = 0; + + foreach ((string key, string value) in entries) + { + if (key is null || value is null) + { + throw new ArgumentException("A metadata entry has a null key or value.", nameof(entries)); + } + + if (validated.Count == MaxKeys) + { + return MetadataValidationResult.Failure($"The metadata has more than {MaxKeys} keys."); + } + + if (!IsValidKey(key)) + { + return MetadataValidationResult.Failure( + $"Metadata key {validated.Count + 1} does not match ^[a-z][a-z0-9_]{{0,63}}$."); + } + + if (validated.ContainsKey(key)) + { + return MetadataValidationResult.Failure($"The metadata repeats the key '{key}'."); + } + + if (CheckValue(value, out int valueBytes) is { } problem) + { + return MetadataValidationResult.Failure($"The value of metadata key '{key}' {problem}."); + } + + // Keys are ASCII, so each character is one UTF-8 byte. + totalBytes += key.Length + valueBytes; + if (totalBytes > MaxTotalBytes) + { + return MetadataValidationResult.Failure( + $"The metadata keys and values have more than {MaxTotalBytes} UTF-8 bytes."); + } + + validated.Add(key, value); + } + + return MetadataValidationResult.Success(new AgentMetadata(validated)); + } + + /// + public bool ContainsKey(string key) + { + return _entries.ContainsKey(key); + } + + /// + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) + { + return _entries.TryGetValue(key, out value); + } + + /// + public IEnumerator> GetEnumerator() + { + return _entries.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + // Checks the key against ^[a-z][a-z0-9_]{0,63}$ character by character: + // a .NET regular expression ending in $ would also accept a final line feed. + private static bool IsValidKey(string key) + { + if (key.Length is 0 or > MaxKeyLength || key[0] is not (>= 'a' and <= 'z')) + { + return false; + } + + foreach (char c in key) + { + if (c is not ((>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + return false; + } + } + + return true; + } + + // Returns why the value is invalid, or null and its UTF-8 length. + private static string? CheckValue(string value, out int utf8Length) + { + utf8Length = 0; + ReadOnlySpan remaining = value; + + while (!remaining.IsEmpty) + { + // An unpaired surrogate encodes no Unicode scalar value, so the string has no UTF-8 form. + if (Rune.DecodeFromUtf16(remaining, out Rune rune, out int consumed) != OperationStatus.Done) + { + return "is not valid Unicode"; + } + + if (rune.Value is <= 0x1F or (>= 0x7F and <= 0x9F)) + { + return $"holds the control character U+{rune.Value:X4}"; + } + + utf8Length += rune.Utf8SequenceLength; + if (utf8Length > MaxValueBytes) + { + return $"is longer than {MaxValueBytes} UTF-8 bytes"; + } + + remaining = remaining[consumed..]; + } + + return null; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/MetadataValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/MetadataValidationResult.cs new file mode 100644 index 0000000000..214a7225be --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/MetadataValidationResult.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of : valid metadata, or the error that rejects it. +/// +public sealed class MetadataValidationResult +{ + private static readonly AgentIdentityError InvalidRequest = + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); + + private MetadataValidationResult(AgentMetadata? metadata, AgentIdentityError? error, string? failureDetail) + { + Succeeded = metadata is not null; + Metadata = metadata; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the metadata is valid. + /// + [MemberNotNullWhen(true, nameof(Metadata))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Succeeded { get; } + + /// + /// The validated metadata, when is true. + /// + public AgentMetadata? Metadata { get; } + + /// + /// 400 invalid_request, when is false. + /// + public AgentIdentityError? Error { get; } + + /// + /// Why the metadata is invalid, for the host's logs, when is false. + /// + /// + /// The detail quotes a key only when the key itself is valid, so it never repeats arbitrary agent input. + /// + public string? FailureDetail { get; } + + internal static MetadataValidationResult Success(AgentMetadata metadata) + { + return new MetadataValidationResult(metadata, null, null); + } + + internal static MetadataValidationResult Failure(string detail) + { + return new MetadataValidationResult(null, InvalidRequest, detail); + } +} From b146c24b721730eb470bff9a5df6e8ce708ec510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:30:21 +0900 Subject: [PATCH 10/93] feat(dotnet-agent-identity): validate certificate signing requests An authority takes only the public key from an agent's CSR, after the CSR proves possession of it, and each key must have exactly one SubjectPublicKeyInfo so that its SHA-256 identifies it among every key the authority has issued. Add CertificateSigningRequest.Validate, which accepts a DER PKCS #10 request of version 0 without trailing bytes, whose key is an uncompressed point on the named curve P-256 and whose self-signature is ecdsa-with-SHA256 without parameters over the exact request info. The subject and attributes are read only as DER elements of their type. The validator checks the curve equation itself, since the platform throws a platform-specific exception for a point off the curve, and rejects an r or s that DER makes negative, which the platform verifier accepts as unsigned. The result keeps the exact SubjectPublicKeyInfo and its SHA-256. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CertificateSigningRequestTests.cs | 518 ++++++++++++++++++ .../ConstructionTests.cs | 2 +- .../TestCsr.cs | 139 +++++ .../CertificateSigningRequest.cs | 220 ++++++++ .../CsrValidationResult.cs | 52 ++ 5 files changed, 930 insertions(+), 1 deletion(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateSigningRequest.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/CsrValidationResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs new file mode 100644 index 0000000000..72b06708e3 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs @@ -0,0 +1,518 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity.Tests; + +public class CertificateSigningRequestTests +{ + // RFC 5480 §2: the DER SubjectPublicKeyInfo of a P-256 key up to its uncompressed point, + // SEQUENCE { SEQUENCE { id-ecPublicKey, secp256r1 }, BIT STRING of 66 octets with no unused bits }. + private static readonly byte[] P256SubjectPublicKeyInfoPrefix = + Convert.FromHexString("3059301306072A8648CE3D020106082A8648CE3D030107034200"); + + [Theory] + [MemberData(nameof(VectorData.CsrNames), MemberType = typeof(VectorData))] + public void CsrVectorHasItsExpectedOutcome(string name) + { + CsrVector vector = TestVectors.Instance.Csr[name]; + + CsrValidationResult result = CertificateSigningRequest.Validate(vector.Csr); + + Assert.Equal(vector.Expected, result.Succeeded ? "ok" : result.Error.Code.ToWireValue()); + } + + [Fact] + public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() + { + CsrVector vector = TestVectors.Instance.Csr["valid"]; + byte[] expected = [.. P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1!]; + + CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(vector.Csr)); + + Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray()); + Assert.Equal(SHA256.HashData(expected), csr.SubjectPublicKeyInfoSha256.ToArray()); + } + + // A request written by .NET rather than by the test builder. + [Fact] + public void AcceptsRequestWrittenByDotNet() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + byte[] der = new CertificateRequest("CN=ignored", key, HashAlgorithmName.SHA256).CreateSigningRequest(); + + CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(der)); + + Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); + } + + // CONTRACT.md §4: the server ignores the CSR subject and extensions, + // and security invariant 2 lets a CSR supply only a verified public key. + [Fact] + public void AcceptsAnySubjectAttributesAndExtensions() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest request = new("CN=Administrator, O=Another Identity", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + SubjectAlternativeNameBuilder alternativeName = new(); + alternativeName.AddUri(new Uri("urn:uuid:5d2e8f14-3c61-4b7a-8e29-6a0f4c1d7b02")); + request.CertificateExtensions.Add(alternativeName.Build()); + // RFC 2985 §5.4.1 challengePassword, a DirectoryString. + byte[] challengePassword = [0x0C, 0x02, 0x70, 0x77]; + request.OtherRequestAttributes.Add(new AsnEncodedData("1.2.840.113549.1.9.7", challengePassword)); + + CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(request.CreateSigningRequest())); + + Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); + } + + [Theory] + [InlineData("")] + [InlineData("00")] + [InlineData("3000")] + [InlineData("300330010A")] + public void RejectsBytesThatAreNotARequest(string hex) + { + AssertInvalid(CertificateSigningRequest.Validate(Convert.FromHexString(hex))); + } + + // Bytes after the request would give one request many encodings. + [Fact] + public void RejectsTrailingBytes() + { + byte[] valid = TestVectors.Instance.Csr["valid"].Csr; + + AssertInvalid(CertificateSigningRequest.Validate([.. valid, 0x00])); + } + + // X.690 §10.1: DER writes each length in the fewest octets. + // The vector request is 223 octets long, which DER writes 81 DF; + // its self-signature covers only the request info, so it still verifies. + [Fact] + public void RejectsLengthInMoreOctetsThanNeeded() + { + byte[] valid = TestVectors.Instance.Csr["valid"].Csr; + Assert.Equal([0x30, 0x81, 0xDF], valid[..3]); + + AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x82, 0x00, 0xDF, .. valid[3..]])); + } + + // X.690 §10.1: DER uses the definite form of length. + [Fact] + public void RejectsIndefiniteLength() + { + byte[] valid = TestVectors.Instance.Csr["valid"].Csr; + + AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x80, .. valid[3..], 0x00, 0x00])); + } + + // The same rule inside the request info, which a test can only change by signing the request again. + [Fact] + public void RejectsLengthInMoreOctetsThanNeededInsideTheRequestInfo() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject: [0x30, 0x81, 0x00]); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + } + + // RFC 2986 §4.1: version is v1, encoded 0. + [Fact] + public void RejectsVersion1() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.Sequence( + TestCsr.Integer(1), + TestCsr.EmptySubject, + key.ExportSubjectPublicKeyInfo(), + TestCsr.EmptyAttributes); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + } + + // RFC 2986 §4.1: the subject is a Name, which is a SEQUENCE. + [Fact] + public void RejectsSubjectThatIsNotASequence() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject: [0x31, 0x00]); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + } + + // RFC 2986 §4.1: attributes is a required field, a constructed [0] IMPLICIT SET OF Attribute. + [Fact] + public void RejectsRequestInfoWithoutAttributes() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.Sequence(TestCsr.Integer(0), TestCsr.EmptySubject, key.ExportSubjectPublicKeyInfo()); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + } + + [Fact] + public void RejectsPrimitiveAttributes() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.Sequence( + TestCsr.Integer(0), + TestCsr.EmptySubject, + key.ExportSubjectPublicKeyInfo(), + [0x80, 0x00]); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + } + + // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker. + [Fact] + public void RejectsExtraElementInRequestInfo() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.Sequence( + TestCsr.Integer(0), + TestCsr.EmptySubject, + key.ExportSubjectPublicKeyInfo(), + TestCsr.EmptyAttributes, + TestCsr.Null); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + } + + [Fact] + public void RejectsExtraElementInRequest() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + byte[] der = TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(TestCsr.Sign(key, info)), + TestCsr.Null); + + AssertInvalid(CertificateSigningRequest.Validate(der)); + } + + // CONTRACT.md §4: the key is a P-256 key. + [Fact] + public void RejectsKeyOnAnotherCurve() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP384); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.For(key))); + } + + // secp256k1 (SEC 2 §2.4.1) points also take 65 octets uncompressed, so only the curve name tells them apart. + [Fact] + public void RejectsP256PointNamedAsAnotherCurve() + { + using ECDsa key = NewKey(); + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Oid("1.3.132.0.10")); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + // Another element would give the key a second SubjectPublicKeyInfo, and so a second SHA-256. + [Fact] + public void RejectsKeyAlgorithmWithAnExtraElement() + { + using ECDsa key = NewKey(); + byte[] algorithm = TestCsr.Sequence( + TestCsr.Oid(TestCsr.IdEcPublicKey), + TestCsr.Oid(TestCsr.Secp256r1), + TestCsr.Null); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + [Fact] + public void RejectsSubjectPublicKeyInfoWithAnExtraElement() + { + using ECDsa key = NewKey(); + byte[] spki = TestCsr.Sequence( + TestCsr.P256Algorithm(), + TestCsr.BitString(TestCsr.UncompressedPoint(key)), + TestCsr.Null); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; CONTRACT.md §4 needs a signing key. + [Fact] + public void RejectsKeyRestrictedToKeyAgreement() + { + using ECDsa key = NewKey(); + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid("1.3.132.1.12"), TestCsr.Oid(TestCsr.Secp256r1)); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + // RFC 5480 §2.1.1: the parameters of id-ecPublicKey MUST be present, and only namedCurve is allowed. + [Fact] + public void RejectsKeyWithoutCurveParameters() + { + using ECDsa key = NewKey(); + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey)); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + [Fact] + public void RejectsImplicitCurve() + { + using ECDsa key = NewKey(); + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Null); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + // The specified form of the P-256 domain parameters, which .NET writes for a key imported with explicit parameters. + [Fact] + public void RejectsSpecifiedCurve() + { + using ECDsa key = NewKey(); + using ECDsa explicitKey = ECDsa.Create(key.ExportExplicitParameters(includePrivateParameters: false)); + byte[] spki = explicitKey.ExportSubjectPublicKeyInfo(); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + // CONTRACT.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. + // SEC 1 §2.3.3: the compressed form is 02 for an even y or 03 for an odd y, then x. + [Fact] + public void RejectsCompressedPoint() + { + using ECDsa key = NewKey(); + ECParameters parameters = key.ExportParameters(includePrivateParameters: false); + byte[] point = [(byte)(0x02 | (parameters.Q.Y![^1] & 1)), .. parameters.Q.X!]; + + AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); + } + + // ANSI X9.62 also defines a hybrid form, 06 or 07 then both coordinates; RFC 5480 §2.2 rejects it. + [Fact] + public void RejectsHybridPoint() + { + using ECDsa key = NewKey(); + byte[] point = TestCsr.UncompressedPoint(key); + point[0] = (byte)(0x06 | (point[^1] & 1)); + + AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); + } + + [Fact] + public void RejectsPointWithoutItsFormOctet() + { + using ECDsa key = NewKey(); + + AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, TestCsr.UncompressedPoint(key)[1..]))); + } + + [Fact] + public void RejectsPointWithAnExtraOctet() + { + using ECDsa key = NewKey(); + byte[] point = [.. TestCsr.UncompressedPoint(key), 0x00]; + + AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); + } + + // SEC 1 §2.3.3: the point at infinity is the single octet 00. + [Fact] + public void RejectsPointAtInfinity() + { + using ECDsa key = NewKey(); + + AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, [0x00]))); + } + + // Flipping the low bit of y leaves the point off the curve, + // since the only other point with the same x has y' = p - y, which is y + 1 or y - 1 only for y = (p ± 1) / 2. + // No private key exists for such a point, so the key whose point was altered signs the request. + [Fact] + public void RejectsPointOffTheCurve() + { + using ECDsa key = ECDsa.Create(); + key.ImportPkcs8PrivateKey(TestVectors.Instance.Key("device-a").PrivateKeyPkcs8, out _); + byte[] point = TestCsr.UncompressedPoint(key); + point[^1] ^= 0x01; + + AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); + } + + // RFC 5480 §2.2: the octets of the point become the bits of the BIT STRING, so none is unused. + // DER requires an unused bit to be zero, so the key is one whose last octet is even. + [Fact] + public void RejectsKeyBitStringWithUnusedBits() + { + using ECDsa key = NewKeyWhere(point => (point[^1] & 1) == 0); + byte[] spki = TestCsr.SubjectPublicKeyInfo( + TestCsr.P256Algorithm(), + TestCsr.UncompressedPoint(key), + unusedBits: 1); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + } + + // CONTRACT.md §4: the signature algorithm is ecdsa-with-SHA256, even when the signature verifies under it. + [Fact] + public void RejectsAnotherSignatureAlgorithmOverASha256Signature() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + byte[] algorithm = TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha384); + byte[] der = TestCsr.Request(info, key, algorithm, HashAlgorithmName.SHA256); + + AssertInvalid(CertificateSigningRequest.Validate(der)); + } + + // RFC 5758 §3.2: the AlgorithmIdentifier of ecdsa-with-SHA256 MUST omit the parameters. + [Fact] + public void RejectsSignatureAlgorithmWithNullParameters() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.EcdsaWithSha256), TestCsr.Null); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key, algorithm))); + } + + // Security invariant 1: the self-signature is the proof of possession of the request's key. + [Fact] + public void RejectsRequestSignedByAnotherKey() + { + using ECDsa key = NewKey(); + using ECDsa other = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, other))); + } + + // RFC 5758 §3.2: the signature is a DER ECDSA-Sig-Value, not the 64-octet r‖s form that request signatures use. + [Fact] + public void RejectsSignatureInFixedFieldForm() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + byte[] signature = key.SignData( + info, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + byte[] der = TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(signature)); + + AssertInvalid(CertificateSigningRequest.Validate(der)); + } + + // RFC 3279 §2.2.3: r and s are positive INTEGERs. + // An r whose top bit is set needs a leading zero octet; without it, DER reads a negative number, + // which the BCL verifier accepts as the unsigned value, so only the library's own check rejects it. + // An r that would start FF after the zero octet is skipped, since its negative form would not be minimal DER. + [Fact] + public void RejectsSignatureWithNegativeR() + { + using ECDsa key = NewKey(); + (byte[] info, byte[] signature) = SignedInfoWhere(key, (r, _) => r.Length == 33 && r[1] != 0xFF); + (byte[] r, byte[] s) = ReadSignature(signature); + byte[] negative = TestCsr.Sequence(TestCsr.Tlv(0x02, r[1..]), TestCsr.Tlv(0x02, s)); + Assert.True(key.VerifyData(info, negative, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + byte[] der = TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(negative)); + + AssertInvalid(CertificateSigningRequest.Validate(der)); + } + + // DER requires an unused bit to be zero, so the signature is one whose last octet is even. + [Fact] + public void RejectsSignatureBitStringWithUnusedBits() + { + using ECDsa key = NewKey(); + (byte[] info, byte[] signature) = SignedInfoWhere(key, (_, s) => (s[^1] & 1) == 0); + byte[] der = TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(signature, unusedBits: 1)); + + AssertInvalid(CertificateSigningRequest.Validate(der)); + } + + private static CertificateSigningRequest AssertValid(CsrValidationResult result) + { + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Null(result.Error); + Assert.Null(result.FailureDetail); + + return result.Csr; + } + + private static void AssertInvalid(CsrValidationResult result) + { + Assert.False(result.Succeeded); + Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); + Assert.Equal(400, result.Error.HttpStatusCode); + Assert.False(string.IsNullOrEmpty(result.FailureDetail)); + Assert.Null(result.Csr); + } + + private static ECDsa NewKey() + { + return ECDsa.Create(ECCurve.NamedCurves.nistP256); + } + + private static ECDsa NewKeyWhere(Func pointCondition) + { + for (int attempt = 0; attempt < 64; attempt++) + { + ECDsa key = NewKey(); + if (pointCondition(TestCsr.UncompressedPoint(key))) + { + return key; + } + + key.Dispose(); + } + + throw new InvalidOperationException("No key met the condition in 64 attempts."); + } + + private static byte[] RequestWithPoint(ECDsa signer, byte[] point) + { + byte[] spki = TestCsr.SubjectPublicKeyInfo(TestCsr.P256Algorithm(), point); + + return TestCsr.Request(TestCsr.RequestInfo(spki), signer); + } + + // Each attempt signs a request info with another subject, so that the search does not rely on random signatures. + private static (byte[] Info, byte[] Signature) SignedInfoWhere(ECDsa key, Func condition) + { + for (int attempt = 0; attempt < 64; attempt++) + { + byte[] subject = new X500DistinguishedName($"CN=attempt {attempt}").RawData; + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject); + byte[] signature = TestCsr.Sign(key, info); + (byte[] r, byte[] s) = ReadSignature(signature); + if (condition(r, s)) + { + return (info, signature); + } + } + + throw new InvalidOperationException("No signature met the condition in 64 attempts."); + } + + // The contents of the two INTEGERs of an ECDSA-Sig-Value. + private static (byte[] R, byte[] S) ReadSignature(byte[] signature) + { + AsnReader sequence = new AsnReader(signature, AsnEncodingRules.DER).ReadSequence(); + + return (sequence.ReadIntegerBytes().ToArray(), sequence.ReadIntegerBytes().ToArray()); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs index 0ad7354d25..2fa7cc9dea 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs @@ -6,7 +6,7 @@ namespace Devolutions.AgentIdentity.Tests; // so nothing but validation may create one: otherwise a host could pass unvalidated agent input in its place. public class ConstructionTests { - public static TheoryData ValidatedTypes => new(typeof(AgentMetadata)); + public static TheoryData ValidatedTypes => new(typeof(AgentMetadata), typeof(CertificateSigningRequest)); [Theory] [MemberData(nameof(ValidatedTypes))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs new file mode 100644 index 0000000000..86dac219cb --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs @@ -0,0 +1,139 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Builds PKCS #10 requests (RFC 2986 §4) element by element, +/// so that a test can break one rule of a request and still sign it correctly. +/// +/// +/// The builders join the elements they are given without checking them, so that a test can also break DER. +/// +internal static class TestCsr +{ + // RFC 5480 §2.1.1 and §2.1.1.1. + public const string IdEcPublicKey = "1.2.840.10045.2.1"; + public const string Secp256r1 = "1.2.840.10045.3.1.7"; + + // RFC 5758 §3.2. + public const string EcdsaWithSha256 = "1.2.840.10045.4.3.2"; + public const string EcdsaWithSha384 = "1.2.840.10045.4.3.3"; + + // An empty RDNSequence and an empty attribute set, the smallest values of two fields that RFC 2986 §4.1 requires. + public static byte[] EmptySubject => [0x30, 0x00]; + + public static byte[] EmptyAttributes => [0xA0, 0x00]; + + public static byte[] Null => [0x05, 0x00]; + + public static byte[] Tlv(byte tag, params byte[][] contents) + { + byte[] body = [.. contents.SelectMany(content => content)]; + + return [tag, .. Length(body.Length), .. body]; + } + + public static byte[] Sequence(params byte[][] elements) + { + return Tlv(0x30, elements); + } + + public static byte[] Integer(int value) + { + return Der(writer => writer.WriteInteger(value)); + } + + public static byte[] Oid(string oid) + { + return Der(writer => writer.WriteObjectIdentifier(oid)); + } + + public static byte[] BitString(byte[] value, byte unusedBits = 0) + { + return Tlv(0x03, [unusedBits], value); + } + + /// + /// The AlgorithmIdentifier of a P-256 key: id-ecPublicKey with the named curve secp256r1. + /// + public static byte[] P256Algorithm() + { + return Sequence(Oid(IdEcPublicKey), Oid(Secp256r1)); + } + + public static byte[] SubjectPublicKeyInfo(byte[] algorithm, byte[] publicKey, byte unusedBits = 0) + { + return Sequence(algorithm, BitString(publicKey, unusedBits)); + } + + /// + /// The uncompressed point of the key (SEC 1 §2.3.3): 04, then x and y. + /// + public static byte[] UncompressedPoint(ECDsa key) + { + ECParameters parameters = key.ExportParameters(includePrivateParameters: false); + + return [0x04, .. parameters.Q.X!, .. parameters.Q.Y!]; + } + + public static byte[] RequestInfo(byte[] subjectPublicKeyInfo, byte[]? subject = null) + { + return Sequence(Integer(0), subject ?? EmptySubject, subjectPublicKeyInfo, EmptyAttributes); + } + + public static byte[] SignatureAlgorithm(string oid) + { + return Sequence(Oid(oid)); + } + + /// + /// The DER ECDSA-Sig-Value (RFC 3279 §2.2.3) of the request info, with SHA-256 unless stated otherwise. + /// + public static byte[] Sign(ECDsa key, byte[] requestInfo, HashAlgorithmName? hash = null) + { + return key.SignData(requestInfo, hash ?? HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + } + + /// + /// A request for the request info, signed by the key with ecdsa-with-SHA256 unless stated otherwise. + /// + public static byte[] Request( + byte[] requestInfo, + ECDsa signer, + byte[]? signatureAlgorithm = null, + HashAlgorithmName? hash = null) + { + return Sequence( + requestInfo, + signatureAlgorithm ?? SignatureAlgorithm(EcdsaWithSha256), + BitString(Sign(signer, requestInfo, hash))); + } + + /// + /// A request for the key, signed by it, in the form that every rule accepts. + /// + public static byte[] For(ECDsa key) + { + return Request(RequestInfo(key.ExportSubjectPublicKeyInfo()), key); + } + + // X.690 §8.1.3: the short form below 128, then the long form in as few octets as the length needs. + private static byte[] Length(int length) + { + return length switch + { + < 0x80 => [(byte)length], + <= 0xFF => [0x81, (byte)length], + _ => [0x82, (byte)(length >> 8), (byte)length], + }; + } + + private static byte[] Der(Action write) + { + AsnWriter writer = new(AsnEncodingRules.DER); + write(writer); + + return writer.Encode(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateSigningRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateSigningRequest.cs new file mode 100644 index 0000000000..0d9b0f9d95 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateSigningRequest.cs @@ -0,0 +1,220 @@ +using System.Formats.Asn1; +using System.Numerics; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity; + +/// +/// A PKCS #10 certificate signing request from an agent, reduced to the public key whose possession it proves. +/// +/// +/// Only and the request body parsers create instances, +/// so an instance always holds an uncompressed P-256 key that signed its request. +/// The request's subject, attributes and extensions are ignored and not kept, +/// since an identity is never derived from claims in a CSR. +/// +public sealed class CertificateSigningRequest +{ + private const string IdEcPublicKey = "1.2.840.10045.2.1"; + private const string Secp256r1 = "1.2.840.10045.3.1.7"; + private const string EcdsaWithSha256 = "1.2.840.10045.4.3.2"; + + private static readonly Asn1Tag AttributesTag = new(TagClass.ContextSpecific, 0, isConstructed: true); + + // SEC 2 §2.4.2: secp256r1 is y² = x³ - 3x + b over the prime field of order p. + private static readonly BigInteger P256Prime = Unsigned( + "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF"); + + private static readonly BigInteger P256B = Unsigned( + "5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B"); + + private CertificateSigningRequest(byte[] subjectPublicKeyInfo) + { + SubjectPublicKeyInfo = subjectPublicKeyInfo; + SubjectPublicKeyInfoSha256 = SHA256.HashData(subjectPublicKeyInfo); + } + + /// + /// The DER encoding of the request's SubjectPublicKeyInfo, exactly as the request holds it. + /// + /// + /// Validation accepts only the canonical encoding of an uncompressed P-256 key, + /// so each key has exactly one SubjectPublicKeyInfo. + /// + public ReadOnlyMemory SubjectPublicKeyInfo { get; } + + /// + /// The SHA-256 digest of , which identifies the key among every issued key. + /// + public ReadOnlyMemory SubjectPublicKeyInfoSha256 { get; } + + /// + /// Validates a DER-encoded PKCS #10 certificate signing request (RFC 2986). + /// + /// + /// A valid request is DER without trailing bytes and has version 0. + /// Its key is id-ecPublicKey on the named curve P-256, as an uncompressed point on the curve (RFC 5480). + /// Its signature algorithm is ecdsa-with-SHA256 without parameters (RFC 5758), + /// and its signature verifies under its key over the exact bytes of its certificationRequestInfo. + /// + /// The DER encoding of the request. + public static CsrValidationResult Validate(ReadOnlySpan der) + { + try + { + return Parse(der.ToArray()); + } + catch (AsnContentException) + { + return CsrValidationResult.Failure("The CSR is not a DER-encoded PKCS #10 request."); + } + } + + // RFC 2986 §4: + // CertificationRequest ::= SEQUENCE { certificationRequestInfo, signatureAlgorithm, signature BIT STRING } + // CertificationRequestInfo ::= SEQUENCE { version INTEGER, subject Name, subjectPKInfo, attributes [0] } + private static CsrValidationResult Parse(byte[] der) + { + AsnReader outer = new(der, AsnEncodingRules.DER); + AsnReader request = outer.ReadSequence(); + if (outer.HasData) + { + return CsrValidationResult.Failure("The CSR is followed by other bytes."); + } + + ReadOnlyMemory requestInfo = request.PeekEncodedValue(); + AsnReader info = request.ReadSequence(); + AsnReader signatureAlgorithm = request.ReadSequence(); + byte[] signature = request.ReadBitString(out int signatureUnusedBits); + request.ThrowIfNotEmpty(); + + if (!info.TryReadInt32(out int version) || version != 0) + { + return CsrValidationResult.Failure("The CSR version is not 0."); + } + + // The subject and the attributes are read only as DER elements of their type, and never parsed, + // because the server ignores them; the self-signature still covers their bytes. + _ = info.ReadSequence(); + ReadOnlyMemory subjectPublicKeyInfo = info.PeekEncodedValue(); + AsnReader subjectPublicKeyInfoReader = info.ReadSequence(); + _ = info.ReadSetOf(skipSortOrderValidation: true, AttributesTag); + info.ThrowIfNotEmpty(); + + if (ReadP256Point(subjectPublicKeyInfoReader, out ECPoint point) is { } keyFailure) + { + return CsrValidationResult.Failure(keyFailure); + } + + if (signatureAlgorithm.ReadObjectIdentifier() != EcdsaWithSha256 || signatureAlgorithm.HasData) + { + return CsrValidationResult.Failure( + "The CSR signature algorithm is not ecdsa-with-SHA256 without parameters."); + } + + if (signatureUnusedBits != 0 || !IsPositiveIntegerPair(signature)) + { + return CsrValidationResult.Failure( + "The CSR signature is not a DER ECDSA-Sig-Value of positive integers."); + } + + using ECDsa key = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = point }); + + if (!key.VerifyData( + requestInfo.Span, + signature, + HashAlgorithmName.SHA256, + DSASignatureFormat.Rfc3279DerSequence)) + { + return CsrValidationResult.Failure("The CSR self-signature does not verify."); + } + + return CsrValidationResult.Success(new CertificateSigningRequest(subjectPublicKeyInfo.ToArray())); + } + + // RFC 5480 §2: SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }, + // with id-ecPublicKey, the namedCurve secp256r1 and an ECPoint whose octets are the bits of the BIT STRING. + // Returns why the key is not an uncompressed P-256 point, or null. + private static string? ReadP256Point(AsnReader subjectPublicKeyInfo, out ECPoint point) + { + point = default; + + AsnReader algorithm = subjectPublicKeyInfo.ReadSequence(); + byte[] encodedPoint = subjectPublicKeyInfo.ReadBitString(out int unusedBits); + subjectPublicKeyInfo.ThrowIfNotEmpty(); + + if (algorithm.ReadObjectIdentifier() != IdEcPublicKey) + { + return "The CSR key is not an id-ecPublicKey key."; + } + + // RFC 5480 §2.1.1: the parameters are present and use the namedCurve choice. + if (!algorithm.HasData + || !algorithm.PeekTag().HasSameClassAndValue(Asn1Tag.ObjectIdentifier) + || algorithm.ReadObjectIdentifier() != Secp256r1 + || algorithm.HasData) + { + return "The CSR key is not on the named curve P-256."; + } + + // SEC 1 §2.3.3: the uncompressed form is 04, then x and y in 32 octets each. + // Requiring it gives each key exactly one encoding, and so one SubjectPublicKeyInfo SHA-256. + if (unusedBits != 0 || encodedPoint.Length != 65 || encodedPoint[0] != 0x04) + { + return "The CSR key is not an uncompressed P-256 point."; + } + + byte[] x = encodedPoint[1..33]; + byte[] y = encodedPoint[33..]; + + if (!IsOnP256(x, y)) + { + return "The CSR key is not a point on P-256."; + } + + point = new ECPoint { X = x, Y = y }; + return null; + } + + // Checks the curve equation itself rather than relying on the platform's key import, + // which throws an exception of a platform-specific type for a point off the curve. + private static bool IsOnP256(byte[] x, byte[] y) + { + BigInteger bx = new(x, isUnsigned: true, isBigEndian: true); + BigInteger by = new(y, isUnsigned: true, isBigEndian: true); + + // UNTESTED: a coordinate of p or more is rejected. + // Such a coordinate encodes a point a second way, + // which would give one key a second SubjectPublicKeyInfo SHA-256. + // A test would need the private key of a point with a coordinate below 2^256 - p, about one key in 2^31. + // When changing this check, keep both comparisons with p before the curve equation. + if (bx >= P256Prime || by >= P256Prime) + { + return false; + } + + BigInteger left = by * by % P256Prime; + BigInteger right = BigInteger.Remainder((bx * bx * bx) - (3 * bx) + P256B, P256Prime); + + return left == right; + } + + // RFC 3279 §2.2.3: Ecdsa-Sig-Value ::= SEQUENCE { r INTEGER, s INTEGER }, where r and s are positive. + // The platform verifier reads each INTEGER as unsigned, so it would also accept an r or s that DER makes negative. + private static bool IsPositiveIntegerPair(byte[] signature) + { + AsnReader outer = new(signature, AsnEncodingRules.DER); + AsnReader sequence = outer.ReadSequence(); + BigInteger r = sequence.ReadInteger(); + BigInteger s = sequence.ReadInteger(); + sequence.ThrowIfNotEmpty(); + outer.ThrowIfNotEmpty(); + + return r.Sign > 0 && s.Sign > 0; + } + + private static BigInteger Unsigned(string hex) + { + return new BigInteger(Convert.FromHexString(hex), isUnsigned: true, isBigEndian: true); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CsrValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CsrValidationResult.cs new file mode 100644 index 0000000000..50c386abbb --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CsrValidationResult.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of : a validated request, or the error that rejects it. +/// +public sealed class CsrValidationResult +{ + private static readonly AgentIdentityError InvalidRequest = + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); + + private CsrValidationResult(CertificateSigningRequest? csr, AgentIdentityError? error, string? failureDetail) + { + Succeeded = csr is not null; + Csr = csr; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the request is valid. + /// + [MemberNotNullWhen(true, nameof(Csr))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Succeeded { get; } + + /// + /// The validated request, when is true. + /// + public CertificateSigningRequest? Csr { get; } + + /// + /// 400 invalid_request, when is false. + /// + public AgentIdentityError? Error { get; } + + /// + /// Why the request is invalid, for the host's logs, when is false. + /// + public string? FailureDetail { get; } + + internal static CsrValidationResult Success(CertificateSigningRequest csr) + { + return new CsrValidationResult(csr, null, null); + } + + internal static CsrValidationResult Failure(string detail) + { + return new CsrValidationResult(null, InvalidRequest, detail); + } +} From be73911b67057ea61a2667f59cb4c52e0be3edeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:39:44 +0900 Subject: [PATCH 11/93] feat(dotnet-agent-identity): parse enroll, renew and check-in bodies The authority must classify a request body, its CSR and its metadata the same way on every endpoint, and must read the same values as any proxy, log or second parser would. Add EnrollRequestBody, RenewRequestBody and CheckInRequestBody, whose Parse methods share one reader. It accepts one JSON object without comments or trailing commas, rejects a member name repeated at any depth, including inside unknown members, and a string that is not Unicode text, then reads csr as canonical base64 into a validated CSR and metadata through AgentMetadata.Validate. A null member counts as absent and unknown members are ignored. Every failure is invalid_request with a detail for host logs. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CertificateSigningRequestTests.cs | 2 +- .../ConstructionTests.cs | 7 +- .../RequestBodyTests.cs | 441 ++++++++++++++++++ .../CheckInRequestBody.cs | 33 ++ .../EnrollRequestBody.cs | 40 ++ .../RenewRequestBody.cs | 40 ++ .../RequestBodyParseResult.cs | 59 +++ .../RequestBodyReader.cs | 210 +++++++++ 8 files changed, 830 insertions(+), 2 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/CheckInRequestBody.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollRequestBody.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewRequestBody.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyParseResult.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyReader.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs index 72b06708e3..53c71603a3 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs @@ -8,7 +8,7 @@ public class CertificateSigningRequestTests { // RFC 5480 §2: the DER SubjectPublicKeyInfo of a P-256 key up to its uncompressed point, // SEQUENCE { SEQUENCE { id-ecPublicKey, secp256r1 }, BIT STRING of 66 octets with no unused bits }. - private static readonly byte[] P256SubjectPublicKeyInfoPrefix = + internal static readonly byte[] P256SubjectPublicKeyInfoPrefix = Convert.FromHexString("3059301306072A8648CE3D020106082A8648CE3D030107034200"); [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs index 2fa7cc9dea..d870cca35d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs @@ -6,7 +6,12 @@ namespace Devolutions.AgentIdentity.Tests; // so nothing but validation may create one: otherwise a host could pass unvalidated agent input in its place. public class ConstructionTests { - public static TheoryData ValidatedTypes => new(typeof(AgentMetadata), typeof(CertificateSigningRequest)); + public static TheoryData ValidatedTypes => new( + typeof(AgentMetadata), + typeof(CertificateSigningRequest), + typeof(EnrollRequestBody), + typeof(RenewRequestBody), + typeof(CheckInRequestBody)); [Theory] [MemberData(nameof(ValidatedTypes))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs new file mode 100644 index 0000000000..b370521516 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs @@ -0,0 +1,441 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// What a test reads from the result of parsing any of the request bodies. +/// +public sealed record RequestBodyOutcome( + bool Succeeded, + AgentIdentityError? Error, + string? FailureDetail, + AgentMetadata? Metadata, + CertificateSigningRequest? Csr); + +/// +/// The rules that every request body shares (CONTRACT.md §1 and §3); each derived class runs them on one body type. +/// +public abstract class RequestBodyTests +{ + protected const string ValidMetadata = """{"hostname": "host"}"""; + + /// + /// A valid body of the type under test, with the given metadata member, omitted when null, + /// and the given members after the others. + /// + protected abstract string Body(string? metadata, string extraMembers); + + protected abstract RequestBodyOutcome Parse(byte[] utf8Json); + + [Fact] + public void AcceptsValidBody() + { + AgentMetadata metadata = AssertValid(ParseText(Body(ValidMetadata, ""))).Metadata!; + + Assert.Equal([new KeyValuePair("hostname", "host")], metadata); + } + + // CONTRACT.md §1: receivers ignore unknown JSON fields. + // A name repeated in different objects is not a repeated member. + [Theory] + [InlineData(""" "extra": 1""")] + [InlineData(""" "extra": {"nested": [1, 2.5e3, true, false, null, {"a": "b"}]}""")] + [InlineData(""" "version": 2, "extra": "h\u00e9 \ud83d\ude00" """)] + [InlineData(""" "extra": null""")] + [InlineData(""" "a": {"b": 1}, "c": {"b": 1}""")] + [InlineData(""" "extra": [{"b": 1}, {"b": 1}]""")] + public void AcceptsUnknownMembers(string extraMembers) + { + AssertValid(ParseText(Body(ValidMetadata, extraMembers))); + } + + [Fact] + public void ReturnsTheMetadataUnescapedInTheOrderSent() + { + RequestBodyOutcome outcome = ParseText(Body("""{"os_name": "Windows", "host\u006eame": "h\u00e9"}""", "")); + KeyValuePair[] expected = [new("os_name", "Windows"), new("hostname", "hé")]; + + Assert.Equal(expected, AssertValid(outcome).Metadata!); + } + + // CONTRACT.md §3: metadata may be an empty object. + [Fact] + public void AcceptsEmptyMetadata() + { + Assert.Empty(AssertValid(ParseText(Body("{}", ""))).Metadata!); + } + + // CONTRACT.md §3: metadata is required, and §1 reads a null member as an absent one. + [Fact] + public void RejectsMissingMetadata() + { + AssertInvalid(ParseText(Body(null, ""))); + } + + [Fact] + public void RejectsNullMetadata() + { + AssertInvalid(ParseText(Body("null", ""))); + } + + // CONTRACT.md §3: metadata is a JSON object of string to string. + [Theory] + [InlineData(""" "host" """)] + [InlineData("[]")] + [InlineData("""[{"hostname": "host"}]""")] + [InlineData("1")] + [InlineData("true")] + public void RejectsMetadataThatIsNotAnObject(string metadata) + { + AssertInvalid(ParseText(Body(metadata, ""))); + } + + [Theory] + [InlineData("""{"hostname": 1}""")] + [InlineData("""{"hostname": true}""")] + [InlineData("""{"hostname": null}""")] + [InlineData("""{"hostname": {}}""")] + [InlineData("""{"hostname": ["host"]}""")] + [InlineData("""{"hostname": {"name": "host"}}""")] + public void RejectsMetadataValueThatIsNotAString(string metadata) + { + AssertInvalid(ParseText(Body(metadata, ""))); + } + + // The limits of CONTRACT.md §3 apply to metadata in a body as they do anywhere else. + public static TheoryData MetadataBeyondTheLimits => new() + { + { "33 keys", JsonObject([.. Enumerable.Range(0, 33).Select(i => $"\"key_{i}\": \"value\"")]) }, + { "uppercase letter in a key", """{"Hostname": "host"}""" }, + { "key ending in a line feed", """{"hostname\n": "host"}""" }, + { "C0 control character in a value", """{"hostname": "a\u0007b"}""" }, + { "C1 control character in a value", """{"hostname": "a\u0085b"}""" }, + { "value of 1025 UTF-8 bytes", $$"""{"hostname": "{{string.Concat(Enumerable.Repeat("\u00E9", 512))}}a"}""" }, + }; + + [Theory] + [MemberData(nameof(MetadataBeyondTheLimits))] + public void RejectsMetadataBeyondTheLimits(string form, string metadata) + { + AssertInvalid(ParseText(Body(metadata, "")), form); + } + + // CONTRACT.md §1: a body that repeats a member name at any depth is invalid, + // whether the names are written alike or only unescape alike. + [Theory] + [InlineData("metadata", """ "metadata": {}""")] + [InlineData("metadata with an escaped letter", """ "m\u0065tadata": {}""")] + [InlineData("unknown member", """ "extra": 1, "extra": 2""")] + [InlineData("member of an unknown member", """ "extra": {"a": 1, "a": 2}""")] + [InlineData("member of an object in nested arrays", """ "extra": [[{"x": {"a": null, "a": null}}]]""")] + public void RejectsRepeatedMemberName(string form, string extraMembers) + { + AssertInvalid(ParseText(Body(ValidMetadata, extraMembers)), form); + } + + [Fact] + public void RejectsRepeatedMetadataKey() + { + AssertInvalid(ParseText(Body("""{"hostname": "a", "hostname": "a"}""", ""))); + } + + // RFC 8259 §2: a JSON text is one value; RFC 8259 §8.1 lets a parser reject a byte order mark. + [Theory] + [InlineData("trailing content", "", "x")] + [InlineData("second value", "", " {}")] + [InlineData("line comment after the object", "", " // comment")] + [InlineData("block comment before the object", "/* comment */", "")] + [InlineData("byte order mark", "\uFEFF", "")] + [InlineData("object inside an array", "[", "]")] + public void RejectsTextAroundTheObject(string form, string prefix, string suffix) + { + AssertInvalid(ParseText(prefix + Body(ValidMetadata, "") + suffix), form); + } + + // RFC 8259 grammar, which has no comments, trailing commas, single quotes or NaN. + [Theory] + [InlineData("trailing comma in the object", """ "extra": 1,""")] + [InlineData("trailing comma in an array", """ "extra": [1,]""")] + [InlineData("comment", """ /* comment */ "extra": 1""")] + [InlineData("single-quoted name", """ 'extra': 1""")] + [InlineData("unquoted name", """ extra: 1""")] + [InlineData("NaN", """ "extra": NaN""")] + [InlineData("number with a leading zero", """ "extra": 01""")] + [InlineData("unescaped tab in a string", " \"extra\": \"a\tb\"")] + [InlineData("missing value", """ "extra":""")] + [InlineData("missing colon", """ "extra" 1""")] + public void RejectsMalformedMember(string form, string extraMembers) + { + AssertInvalid(ParseText(Body(ValidMetadata, extraMembers)), form); + } + + [Fact] + public void RejectsTruncatedBody() + { + AssertInvalid(ParseText(Body(ValidMetadata, "")[..^1])); + } + + [Theory] + [InlineData("[]")] + [InlineData(""" "body" """)] + [InlineData("42")] + [InlineData("null")] + [InlineData("true")] + [InlineData("")] + [InlineData(" ")] + public void RejectsBodyThatIsNotAnObject(string json) + { + AssertInvalid(ParseText(json)); + } + + // RFC 8259 §8.2: an escaped unpaired surrogate is valid JSON syntax but no Unicode text, + // so parsers disagree on what it reads as. + [Theory] + [InlineData("lone high surrogate", """ "extra": "\ud800" """)] + [InlineData("lone low surrogate", """ "extra": "\udc00" """)] + [InlineData("high surrogate before another character", """ "extra": "\ud800\u0041" """)] + [InlineData("lone surrogate in a name", """ "\ud800": 1""")] + public void RejectsEscapedStringThatIsNotUnicode(string form, string extraMembers) + { + AssertInvalid(ParseText(Body(ValidMetadata, extraMembers)), form); + } + + // CONTRACT.md §1 and RFC 8259 §8.1: JSON bodies are UTF-8. + // The bytes replace the character of a string in an unknown member. + [Theory] + [InlineData(new byte[] { 0xFF })] + [InlineData(new byte[] { 0xC0, 0xAF })] + [InlineData(new byte[] { 0xED, 0xA0, 0x80 })] + [InlineData(new byte[] { 0xF4, 0x90, 0x80, 0x80 })] + public void RejectsStringThatIsNotUtf8(byte[] bytes) + { + byte[] body = Encoding.UTF8.GetBytes(Body(ValidMetadata, """ "extra": "#" """)); + int index = Array.IndexOf(body, (byte)'#'); + + AssertInvalid(Parse([.. body[..index], .. bytes, .. body[(index + 1)..]])); + } + + // The body object is the first level, so 63 nested arrays reach 64 levels. + [Fact] + public void AcceptsNestingOf64Levels() + { + AssertValid(ParseText(Body(ValidMetadata, NestedArrays(63)))); + } + + [Fact] + public void RejectsNestingOf65Levels() + { + AssertInvalid(ParseText(Body(ValidMetadata, NestedArrays(64)))); + } + + protected static string JsonObject(params string?[] members) + { + return "{" + string.Join(", ", members.Where(member => !string.IsNullOrEmpty(member))) + "}"; + } + + protected static string? MetadataMember(string? metadata) + { + return metadata is null ? null : $"\"metadata\": {metadata}"; + } + + protected RequestBodyOutcome ParseText(string json) + { + return Parse(Encoding.UTF8.GetBytes(json)); + } + + protected static RequestBodyOutcome AssertValid(RequestBodyOutcome outcome) + { + Assert.True(outcome.Succeeded, outcome.FailureDetail); + Assert.Null(outcome.Error); + Assert.Null(outcome.FailureDetail); + Assert.NotNull(outcome.Metadata); + + return outcome; + } + + // The body parsers report every failure as invalid_request with HTTP status 400. + protected static void AssertInvalid(RequestBodyOutcome outcome, string? form = null) + { + Assert.False(outcome.Succeeded, form); + Assert.NotNull(outcome.Error); + Assert.Equal(AgentIdentityErrorCode.InvalidRequest, outcome.Error.Code); + Assert.Equal(400, outcome.Error.HttpStatusCode); + Assert.Equal(AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest).Message, outcome.Error.Message); + Assert.False(string.IsNullOrEmpty(outcome.FailureDetail)); + Assert.Null(outcome.Metadata); + Assert.Null(outcome.Csr); + } + + private static string NestedArrays(int depth) + { + return $"\"extra\": {new string('[', depth)}{new string(']', depth)}"; + } +} + +/// +/// The rules of a body that carries a CSR (CONTRACT.md §1, §4, §5.2 and §5.3). +/// +public abstract class CsrRequestBodyTests : RequestBodyTests +{ + protected static readonly string ValidCsr = Convert.ToBase64String(TestVectors.Instance.Csr["valid"].Csr); + + protected sealed override string Body(string? metadata, string extraMembers) + { + return BodyWithCsr(Quote(ValidCsr), metadata, extraMembers); + } + + [Fact] + public void ReturnsTheValidatedCsr() + { + CsrVector vector = TestVectors.Instance.Csr["valid"]; + byte[] expected = [.. CertificateSigningRequestTests.P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1!]; + + CertificateSigningRequest csr = AssertValid(ParseText(Body(ValidMetadata, ""))).Csr!; + + Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray()); + Assert.Equal(SHA256.HashData(expected), csr.SubjectPublicKeyInfoSha256.ToArray()); + } + + // CONTRACT.md §5.2 and §5.3: csr is required, and §1 reads a null member as an absent one. + [Fact] + public void RejectsMissingCsr() + { + AssertInvalid(ParseText(BodyWithCsr(null, ValidMetadata))); + } + + [Fact] + public void RejectsNullCsr() + { + AssertInvalid(ParseText(BodyWithCsr("null", ValidMetadata))); + } + + // CONTRACT.md §1: a CSR is DER encoded base64 in a JSON string. + [Theory] + [InlineData("1")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("true")] + public void RejectsCsrThatIsNotAString(string csr) + { + AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata))); + } + + // CONTRACT.md §1: base64 in any form but the canonical one is invalid. + // Each form decodes, under a lenient decoder, to the valid request. + public static TheoryData NonCanonicalCsrs() + { + // The request is 226 octets long, so its final quantum holds one octet, two padding characters, + // and four pad bits in the character before them, which the canonical form sets to zero. + const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + string valid = ValidCsr; + Assert.EndsWith("==", valid); + char withPadBit = Alphabet[Alphabet.IndexOf(valid[^3]) | 1]; + + return new TheoryData + { + { "missing padding", valid.TrimEnd('=') }, + { "non-zero pad bit", valid[..^3] + withPadBit + "==" }, + { "line break", valid[..64] + "\n" + valid[64..] }, + { "space", valid[..64] + " " + valid[64..] }, + { "base64url alphabet", valid.Replace('+', '-').Replace('/', '_') }, + }; + } + + [Theory] + [MemberData(nameof(NonCanonicalCsrs))] + public void RejectsCsrInNonCanonicalBase64(string form, string csr) + { + Assert.NotEqual(ValidCsr, csr); + + AssertInvalid(ParseText(BodyWithCsr(Quote(csr), ValidMetadata)), form); + } + + [Fact] + public void RejectsCsrThatFailsValidation() + { + string csr = Convert.ToBase64String(TestVectors.Instance.Csr["bad_self_signature"].Csr); + + AssertInvalid(ParseText(BodyWithCsr(Quote(csr), ValidMetadata))); + } + + [Fact] + public void RejectsEmptyCsr() + { + AssertInvalid(ParseText(BodyWithCsr(Quote(""), ValidMetadata))); + } + + [Fact] + public void RejectsRepeatedCsr() + { + AssertInvalid(ParseText(Body(ValidMetadata, $"\"csr\": {Quote(ValidCsr)}"))); + } + + private static string BodyWithCsr(string? csr, string? metadata, string extraMembers = "") + { + return JsonObject(csr is null ? null : $"\"csr\": {csr}", MetadataMember(metadata), extraMembers); + } + + // The base64 alphabet needs no escape in a JSON string; a line break does. + private static string Quote(string text) + { + return $"\"{text.Replace("\n", "\\n")}\""; + } +} + +public class EnrollRequestBodyTests : CsrRequestBodyTests +{ + protected override RequestBodyOutcome Parse(byte[] utf8Json) + { + RequestBodyParseResult result = EnrollRequestBody.Parse(utf8Json); + + return new RequestBodyOutcome( + result.Succeeded, + result.Error, + result.FailureDetail, + result.Body?.Metadata, + result.Body?.Csr); + } +} + +public class RenewRequestBodyTests : CsrRequestBodyTests +{ + protected override RequestBodyOutcome Parse(byte[] utf8Json) + { + RequestBodyParseResult result = RenewRequestBody.Parse(utf8Json); + + return new RequestBodyOutcome( + result.Succeeded, + result.Error, + result.FailureDetail, + result.Body?.Metadata, + result.Body?.Csr); + } +} + +public class CheckInRequestBodyTests : RequestBodyTests +{ + // CONTRACT.md §5.6: a check-in body has no csr, so a csr member is an unknown member. + [Fact] + public void IgnoresCsrMember() + { + AssertValid(ParseText(Body(ValidMetadata, """ "csr": 42"""))); + } + + protected override string Body(string? metadata, string extraMembers) + { + return JsonObject(MetadataMember(metadata), extraMembers); + } + + protected override RequestBodyOutcome Parse(byte[] utf8Json) + { + RequestBodyParseResult result = CheckInRequestBody.Parse(utf8Json); + + return new RequestBodyOutcome( + result.Succeeded, + result.Error, + result.FailureDetail, + result.Body?.Metadata, + Csr: null); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CheckInRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CheckInRequestBody.cs new file mode 100644 index 0000000000..d770b27093 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CheckInRequestBody.cs @@ -0,0 +1,33 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of a check-in request: the agent's metadata. +/// +public sealed class CheckInRequestBody +{ + private CheckInRequestBody(AgentMetadata metadata) + { + Metadata = metadata; + } + + /// + /// The validated metadata. + /// + public AgentMetadata Metadata { get; } + + /// + /// Parses and validates the body of a check-in request, { "metadata": { ... } }. + /// + /// + /// The body is one JSON object without comments or trailing commas, nested at most 64 levels deep, + /// that repeats no member name at any depth and whose strings are all Unicode text. + /// metadata is an object that accepts, + /// a null member counts as absent, and other members are ignored. + /// Authenticate the request first, since an authentication error wins over an invalid body. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan utf8Json) + { + return RequestBodyReader.ReadMetadata(utf8Json, metadata => new CheckInRequestBody(metadata)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollRequestBody.cs new file mode 100644 index 0000000000..f6bdbad22b --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollRequestBody.cs @@ -0,0 +1,40 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of an enroll request: the CSR of the key to enroll and the agent's metadata. +/// +public sealed class EnrollRequestBody +{ + private EnrollRequestBody(CertificateSigningRequest csr, AgentMetadata metadata) + { + Csr = csr; + Metadata = metadata; + } + + /// + /// The validated CSR of the key to enroll. + /// + public CertificateSigningRequest Csr { get; } + + /// + /// The validated metadata. + /// + public AgentMetadata Metadata { get; } + + /// + /// Parses and validates the body of an enroll request, { "csr": "...", "metadata": { ... } }. + /// + /// + /// The body is one JSON object without comments or trailing commas, nested at most 64 levels deep, + /// that repeats no member name at any depth and whose strings are all Unicode text. + /// csr is a canonical base64 request that accepts, + /// metadata is an object that accepts, + /// a null member counts as absent, and other members are ignored. + /// Check the enrollment token first, since an authentication error wins over an invalid body. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan utf8Json) + { + return RequestBodyReader.ReadCsrAndMetadata(utf8Json, (csr, metadata) => new EnrollRequestBody(csr, metadata)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewRequestBody.cs new file mode 100644 index 0000000000..77ff656f57 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewRequestBody.cs @@ -0,0 +1,40 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of a renew request: the CSR of the new key and the agent's metadata. +/// +public sealed class RenewRequestBody +{ + private RenewRequestBody(CertificateSigningRequest csr, AgentMetadata metadata) + { + Csr = csr; + Metadata = metadata; + } + + /// + /// The validated CSR of the new key. + /// + public CertificateSigningRequest Csr { get; } + + /// + /// The validated metadata. + /// + public AgentMetadata Metadata { get; } + + /// + /// Parses and validates the body of a renew request, { "csr": "...", "metadata": { ... } }. + /// + /// + /// The body is one JSON object without comments or trailing commas, nested at most 64 levels deep, + /// that repeats no member name at any depth and whose strings are all Unicode text. + /// csr is a canonical base64 request that accepts, + /// metadata is an object that accepts, + /// a null member counts as absent, and other members are ignored. + /// Authenticate the request first, since an authentication error wins over an invalid body. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan utf8Json) + { + return RequestBodyReader.ReadCsrAndMetadata(utf8Json, (csr, metadata) => new RenewRequestBody(csr, metadata)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyParseResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyParseResult.cs new file mode 100644 index 0000000000..756e1c939e --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyParseResult.cs @@ -0,0 +1,59 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of parsing a request body: the validated body, or the error that rejects it. +/// +/// +/// The body type: , or . +/// +public sealed class RequestBodyParseResult + where TBody : class +{ + private static readonly AgentIdentityError InvalidRequest = + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); + + private RequestBodyParseResult(TBody? body, AgentIdentityError? error, string? failureDetail) + { + Succeeded = body is not null; + Body = body; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the body is valid. + /// + [MemberNotNullWhen(true, nameof(Body))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Succeeded { get; } + + /// + /// The validated body, when is true. + /// + public TBody? Body { get; } + + /// + /// 400 invalid_request, when is false. + /// + public AgentIdentityError? Error { get; } + + /// + /// Why the body is invalid, for the host's logs, when is false. + /// + /// + /// The detail never repeats a value from the body, and quotes a metadata key only when the key itself is valid. + /// + public string? FailureDetail { get; } + + internal static RequestBodyParseResult Success(TBody body) + { + return new RequestBodyParseResult(body, null, null); + } + + internal static RequestBodyParseResult Failure(string detail) + { + return new RequestBodyParseResult(null, InvalidRequest, detail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyReader.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyReader.cs new file mode 100644 index 0000000000..76eac569a1 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyReader.cs @@ -0,0 +1,210 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Devolutions.AgentIdentity; + +/// +/// Reads the JSON request bodies with one set of rules, +/// so that every endpoint classifies a body, its CSR and its metadata the same way. +/// +internal static class RequestBodyReader +{ + public static RequestBodyParseResult ReadCsrAndMetadata( + ReadOnlySpan utf8Json, + Func create) + where TBody : class + { + if (!TryRead( + utf8Json, + readsCsr: true, + out CertificateSigningRequest? csr, + out AgentMetadata? metadata, + out string? failure)) + { + return RequestBodyParseResult.Failure(failure); + } + + // A successful read with readsCsr set always yields a CSR. + return RequestBodyParseResult.Success(create(csr!, metadata)); + } + + public static RequestBodyParseResult ReadMetadata( + ReadOnlySpan utf8Json, + Func create) + where TBody : class + { + if (!TryRead(utf8Json, readsCsr: false, out _, out AgentMetadata? metadata, out string? failure)) + { + return RequestBodyParseResult.Failure(failure); + } + + return RequestBodyParseResult.Success(create(metadata)); + } + + private static bool TryRead( + ReadOnlySpan utf8Json, + bool readsCsr, + out CertificateSigningRequest? csr, + [NotNullWhen(true)] out AgentMetadata? metadata, + [NotNullWhen(false)] out string? failure) + { + csr = null; + metadata = null; + + failure = CheckJson(utf8Json); + if (failure is not null) + { + return false; + } + + // The check read the same bytes under the same default options, so parsing them succeeds. + using JsonDocument document = JsonDocument.Parse(utf8Json.ToArray()); + JsonElement body = document.RootElement; + + if (body.ValueKind != JsonValueKind.Object) + { + failure = "The body is not a JSON object."; + return false; + } + + if (readsCsr && !TryReadCsr(body, out csr, out failure)) + { + return false; + } + + return TryReadMetadata(body, out metadata, out failure); + } + + // Returns why the bytes are not one JSON text that every parser reads the same way, or null. + // A repeated member name lets two parsers read two values (CONTRACT.md §1), + // and so does a string that is not Unicode text (RFC 8259 §8.2). + private static string? CheckJson(ReadOnlySpan utf8Json) + { + // The default options reject comments, trailing commas, content after the value and nesting beyond 64 levels. + Utf8JsonReader reader = new(utf8Json); + Stack> objects = new(); + + try + { + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + objects.Push(new HashSet(StringComparer.Ordinal)); + break; + + case JsonTokenType.EndObject: + objects.Pop(); + break; + + case JsonTokenType.PropertyName: + // GetString unescapes the name, so names that differ only in their escapes count as repeated. + if (!objects.Peek().Add(reader.GetString()!)) + { + return $"The body repeats a member name at byte {reader.TokenStartIndex}."; + } + + break; + + case JsonTokenType.String: + // The reader accepts an unpaired surrogate escape or invalid UTF-8 until a string is decoded. + _ = reader.GetString(); + break; + } + } + } + catch (JsonException exception) + { + return $"The body is not valid JSON, at line {exception.LineNumber + 1}, " + + $"byte {exception.BytePositionInLine + 1} of that line."; + } + catch (InvalidOperationException) + { + return $"The body holds a string that is not Unicode text at byte {reader.TokenStartIndex}."; + } + + return null; + } + + private static bool TryReadCsr( + JsonElement body, + [NotNullWhen(true)] out CertificateSigningRequest? csr, + [NotNullWhen(false)] out string? failure) + { + csr = null; + + if (!body.TryGetProperty("csr", out JsonElement value) || value.ValueKind == JsonValueKind.Null) + { + failure = "The body has no csr."; + return false; + } + + if (value.ValueKind != JsonValueKind.String) + { + failure = "The csr is not a JSON string."; + return false; + } + + // CONTRACT.md §1: a base64 value in any form but its canonical one is invalid. + if (!StrictBase64.TryDecodeBase64(value.GetString(), out byte[]? der)) + { + failure = "The csr is not canonical base64."; + return false; + } + + CsrValidationResult result = CertificateSigningRequest.Validate(der); + if (!result.Succeeded) + { + failure = result.FailureDetail; + return false; + } + + csr = result.Csr; + failure = null; + return true; + } + + private static bool TryReadMetadata( + JsonElement body, + [NotNullWhen(true)] out AgentMetadata? metadata, + [NotNullWhen(false)] out string? failure) + { + metadata = null; + + if (!body.TryGetProperty("metadata", out JsonElement value) || value.ValueKind == JsonValueKind.Null) + { + failure = "The body has no metadata."; + return false; + } + + if (value.ValueKind != JsonValueKind.Object) + { + failure = "The metadata is not a JSON object."; + return false; + } + + List> entries = []; + foreach (JsonProperty entry in value.EnumerateObject()) + { + if (entry.Value.ValueKind != JsonValueKind.String) + { + failure = $"The value of metadata member {entries.Count + 1} is not a JSON string."; + return false; + } + + entries.Add(new KeyValuePair(entry.Name, entry.Value.GetString()!)); + } + + MetadataValidationResult result = AgentMetadata.Validate(entries); + if (!result.Succeeded) + { + failure = result.FailureDetail; + return false; + } + + metadata = result.Metadata; + failure = null; + return true; + } +} From c46d1781282b08c12d956c24c3174d3650cff3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:48:27 +0900 Subject: [PATCH 12/93] feat(dotnet-agent-identity): issue identity certificates Every authority must issue the same leaf profile, so that an agent and any relying party read an identity the same way whichever authority issued it. Add IRootSigner, through which the host lends its root certificate and a signature generator without exposing the root key, and IdentityCertificateIssuer.Issue. The leaf holds only the CSR's key, with CN and SAN URI built from the identity ID, clientAuth, a critical digitalSignature key usage, a subject key identifier, an authority key identifier equal to the root's, and no basic constraints. It is valid from the clock truncated to the second for the lifetime, 90 days by default, capped at the root's notAfter, and its serial number is 16 random octets with the top bit cleared, drawn again if zero. A lifetime under one second, a root that expires by notBefore, or a root without a subject key identifier throws. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IdentityCertificateIssuerTests.cs | 410 ++++++++++++++++++ .../Devolutions.AgentIdentity/IRootSigner.cs | 23 + .../IdentityCertificateIssuer.cs | 141 ++++++ 3 files changed, 574 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/IRootSigner.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs new file mode 100644 index 0000000000..e252140259 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs @@ -0,0 +1,410 @@ +using System.Formats.Asn1; +using System.Numerics; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity.Tests; + +// Each test decodes the issued certificate with the BCL and System.Formats.Asn1, never with the library, +// and checks it against CONTRACT.md §4 and RFC 5280. +public sealed class IdentityCertificateIssuerTests : IDisposable +{ + // CONTRACT.md §1: IDs are lowercase hyphenated UUIDs; this one has hexadecimal letters, so its case shows. + private const string IdentityIdText = "0b3c6a52-7a38-4f55-9a1c-2f1a8d3c9e01"; + + // RFC 5280 §4.2.1.12: id-kp-clientAuth. + private const string ClientAuth = "1.3.6.1.5.5.7.3.2"; + + private const string SubjectKeyIdentifierOid = "2.5.29.14"; + private const string KeyUsageOid = "2.5.29.15"; + private const string SubjectAlternativeNameOid = "2.5.29.17"; + private const string BasicConstraintsOid = "2.5.29.19"; + private const string AuthorityKeyIdentifierOid = "2.5.29.35"; + private const string ExtendedKeyUsageOid = "2.5.29.37"; + + private static readonly Guid IdentityId = Guid.Parse(IdentityIdText.ToUpperInvariant()); + + // 12:00:00.750 UTC, whose fraction a certificate cannot hold. + private static readonly DateTimeOffset NowInSeconds = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset Now = NowInSeconds.AddMilliseconds(750); + + private static readonly TimeSpan Lifetime = TimeSpan.FromDays(30); + + private readonly ECDsa _rootKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private readonly ECDsa _agentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private readonly RandomNumberGenerator _random = RandomNumberGenerator.Create(); + private readonly X509Certificate2 _root; + private readonly CertificateSigningRequest _csr; + + public IdentityCertificateIssuerTests() + { + _root = CreateRoot(Now.AddDays(-1), Now.AddYears(10)); + + byte[] csr = new CertificateRequest("CN=agent", _agentKey, HashAlgorithmName.SHA256).CreateSigningRequest(); + _csr = CertificateSigningRequest.Validate(csr).Csr!; + } + + public void Dispose() + { + _root.Dispose(); + _rootKey.Dispose(); + _agentKey.Dispose(); + _random.Dispose(); + } + + // CONTRACT.md §4: the certificate is for the P-256 key of the CSR. + [Fact] + public void CertifiesTheKeyOfTheCsr() + { + using X509Certificate2 leaf = Issue(); + + Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo()); + } + + // CONTRACT.md §4: CN=, and the root's subject as the issuer. + [Fact] + public void SubjectIsTheCommonNameOfTheIdentity() + { + using X509Certificate2 leaf = Issue(); + + X500RelativeDistinguishedName name = Assert.Single(leaf.SubjectName.EnumerateRelativeDistinguishedNames()); + Assert.Equal("2.5.4.3", name.GetSingleElementType().Value); + Assert.Equal(IdentityIdText, name.GetSingleElementValue()); + Assert.Equal(_root.SubjectName.RawData, leaf.IssuerName.RawData); + } + + // CONTRACT.md §4: SAN URI urn:uuid:. + // RFC 5280 §4.2.1.6: GeneralNames is a SEQUENCE OF GeneralName, and a URI is [6] IA5String; + // with a subject present, the extension is not critical. + [Fact] + public void AlternativeNameIsTheUrnUuidOfTheIdentity() + { + using X509Certificate2 leaf = Issue(); + X509Extension extension = + Assert.IsType(leaf.Extensions[SubjectAlternativeNameOid], exactMatch: false); + + AsnReader names = new AsnReader(extension.RawData, AsnEncodingRules.DER).ReadSequence(); + string uri = names.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)); + + Assert.Equal($"urn:uuid:{IdentityIdText}", uri); + Assert.False(names.HasData); + Assert.False(extension.Critical); + } + + // CONTRACT.md §4: EKU clientAuth. + [Fact] + public void ExtendedKeyUsageIsClientAuthenticationOnly() + { + using X509Certificate2 leaf = Issue(); + X509EnhancedKeyUsageExtension extension = + Assert.IsType(leaf.Extensions[ExtendedKeyUsageOid]); + + Assert.Equal([ClientAuth], extension.EnhancedKeyUsages.Cast().Select(oid => oid.Value)); + Assert.False(extension.Critical); + } + + // CONTRACT.md §4: KeyUsage digitalSignature; RFC 5280 §4.2.1.3: marked critical. + [Fact] + public void KeyUsageIsCriticalDigitalSignatureOnly() + { + using X509Certificate2 leaf = Issue(); + X509KeyUsageExtension extension = Assert.IsType(leaf.Extensions[KeyUsageOid]); + + Assert.Equal(X509KeyUsageFlags.DigitalSignature, extension.KeyUsages); + Assert.True(extension.Critical); + } + + // RFC 5280 §4.2.1.1: the key identifier of the issuer's subject key identifier, in a non-critical extension. + [Fact] + public void AuthorityKeyIdentifierIsTheSubjectKeyIdentifierOfTheIssuer() + { + using X509Certificate2 leaf = Issue(); + X509AuthorityKeyIdentifierExtension authority = + Assert.IsType(leaf.Extensions[AuthorityKeyIdentifierOid]); + X509SubjectKeyIdentifierExtension issuer = + Assert.IsType(_root.Extensions[SubjectKeyIdentifierOid]); + + Assert.Equal(issuer.SubjectKeyIdentifierBytes.ToArray(), authority.KeyIdentifier?.ToArray()); + Assert.Null(authority.NamedIssuer); + Assert.Null(authority.SerialNumber); + Assert.False(authority.Critical); + } + + // RFC 5280 §4.2.1.2, method (1): the SHA-1 hash of the BIT STRING subjectPublicKey, in a non-critical extension. + [Fact] + public void SubjectKeyIdentifierIsTheSha1OfTheKey() + { + using X509Certificate2 leaf = Issue(); + X509SubjectKeyIdentifierExtension extension = + Assert.IsType(leaf.Extensions[SubjectKeyIdentifierOid]); + + byte[] subjectPublicKey = TestCsr.UncompressedPoint(_agentKey); + + Assert.Equal(SHA1.HashData(subjectPublicKey), extension.SubjectKeyIdentifierBytes.ToArray()); + Assert.False(extension.Critical); + } + + // RFC 5280 §4.2.1.9: an end-entity certificate may omit basic constraints, and this profile does. + [Fact] + public void HasExactlyTheProfileExtensions() + { + using X509Certificate2 leaf = Issue(); + + IEnumerable extensions = leaf.Extensions.Select(extension => extension.Oid?.Value); + string[] expected = + [ + SubjectKeyIdentifierOid, + KeyUsageOid, + SubjectAlternativeNameOid, + AuthorityKeyIdentifierOid, + ExtendedKeyUsageOid, + ]; + + Assert.Equal(expected, extensions.Order(StringComparer.Ordinal)); + Assert.Equal(3, leaf.Version); + } + + // RFC 5758 §3.2: the issuer's generator signs with SHA-256, so ecdsa-with-SHA256 for a P-256 root. + [Fact] + public void IsSignedWithSha256() + { + using X509Certificate2 leaf = Issue(); + + Assert.Equal("1.2.840.10045.4.3.2", leaf.SignatureAlgorithm.Value); + } + + // RFC 5280 §4.1.2.2: a positive INTEGER of at most 20 octets. + // The serial number is 16 octets from the injected generator with the top bit cleared. + [Fact] + public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() + { + byte[] draw = [.. Enumerable.Repeat((byte)0xFF, 16)]; + + using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(draw)); + + byte[] serialNumber = SerialNumber(leaf); + Assert.Equal([0x7F, .. draw[1..]], serialNumber); + Assert.True(new BigInteger(serialNumber, isUnsigned: false, isBigEndian: true) > 0); + Assert.InRange(serialNumber.Length, 1, 20); + } + + // Clearing the top bit of 80 00 … 00 leaves zero, which is not a positive serial number. + [Fact] + public void SerialNumberIsDrawnAgainWhenItWouldBeZero() + { + byte[] topBitOnly = new byte[16]; + topBitOnly[0] = 0x80; + byte[] second = [.. Enumerable.Repeat((byte)0x01, 16)]; + + using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(topBitOnly, second)); + + Assert.Equal(second, SerialNumber(leaf)); + } + + [Fact] + public void NotBeforeIsTheClockTruncatedToTheSecond() + { + using X509Certificate2 leaf = Issue(); + + Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); + } + + [Fact] + public void NotAfterIsNotBeforePlusTheLifetime() + { + using X509Certificate2 leaf = Issue(lifetime: TimeSpan.FromDays(30)); + + Assert.Equal(NowInSeconds.AddDays(30).UtcDateTime, leaf.NotAfter.ToUniversalTime()); + } + + // A certificate holds whole seconds. + [Fact] + public void NotAfterDropsTheFractionOfASecondOfTheLifetime() + { + using X509Certificate2 leaf = Issue(lifetime: TimeSpan.FromDays(30) + TimeSpan.FromMilliseconds(500)); + + Assert.Equal(NowInSeconds.AddDays(30).UtcDateTime, leaf.NotAfter.ToUniversalTime()); + } + + // CONTRACT.md §4: the lifetime is capped at the issuing root's notAfter. + [Fact] + public void NotAfterIsCappedAtTheNotAfterOfTheIssuer() + { + using X509Certificate2 root = CreateRoot(Now.AddDays(-1), NowInSeconds.AddDays(10)); + + using X509Certificate2 leaf = Issue(lifetime: TimeSpan.FromDays(90), root: root); + + Assert.Equal(NowInSeconds.AddDays(10).UtcDateTime, leaf.NotAfter.ToUniversalTime()); + } + + // CONTRACT.md §4: the lifetime is a server setting with a default of 90 days. + [Fact] + public void DefaultLifetimeIs90Days() + { + Assert.Equal(TimeSpan.FromDays(90), IdentityCertificateIssuer.DefaultLifetime); + } + + // Reading a moving clock twice would give the certificate a validity other than its lifetime. + [Fact] + public void ReadsTheClockOnce() + { + using X509Certificate2 leaf = Issue(clock: new AdvancingTimeProvider(Now, TimeSpan.FromHours(1))); + + Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); + Assert.Equal((NowInSeconds + Lifetime).UtcDateTime, leaf.NotAfter.ToUniversalTime()); + } + + // The certificate chains to the root that issued it, for client authentication. + [Fact] + public void ChainsToTheIssuer() + { + using X509Certificate2 leaf = Issue(); + using X509Chain chain = new(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(_root); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationTime = Now.AddHours(1).UtcDateTime; + chain.ChainPolicy.ApplicationPolicy.Add(new Oid(ClientAuth)); + + bool built = chain.Build(leaf); + + Assert.True(built, string.Join("; ", chain.ChainStatus.Select(status => status.StatusInformation))); + Assert.Equal(2, chain.ChainElements.Count); + } + + // Security invariant 2: the authority mints the subject, and a CSR supplies only a verified public key. + [Fact] + public void IgnoresTheSubjectAndExtensionsOfTheCsr() + { + CertificateRequest request = new("CN=Administrator, O=Another Identity", _agentKey, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + CertificateSigningRequest csr = CertificateSigningRequest.Validate(request.CreateSigningRequest()).Csr!; + + using X509Certificate2 leaf = Issue(csr: csr); + + Assert.Equal($"CN={IdentityIdText}", leaf.Subject); + Assert.Null(leaf.Extensions[BasicConstraintsOid]); + X509EnhancedKeyUsageExtension usage = + Assert.IsType(leaf.Extensions[ExtendedKeyUsageOid]); + Assert.Equal([ClientAuth], usage.EnhancedKeyUsages.Cast().Select(oid => oid.Value)); + } + + // A certificate holds whole seconds, so a lifetime under one second would end in the second it begins. + [Theory] + [InlineData(0)] + [InlineData(-1_000)] + [InlineData(999)] + public void ThrowsForLifetimeShorterThanOneSecond(int milliseconds) + { + Assert.Throws(() => Issue(lifetime: TimeSpan.FromMilliseconds(milliseconds))); + } + + // notAfter is at most the issuer's notAfter, so an issuer that expires by notBefore leaves no validity. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ThrowsWhenTheIssuerExpiresByNotBefore(int seconds) + { + using X509Certificate2 root = CreateRoot(Now.AddDays(-30), NowInSeconds.AddSeconds(seconds)); + + Assert.Throws(() => Issue(root: root)); + } + + // RFC 5280 §4.2.1.1: the authority key identifier repeats the issuer's subject key identifier. + [Fact] + public void ThrowsWhenTheIssuerHasNoSubjectKeyIdentifier() + { + using X509Certificate2 root = CreateRoot(Now.AddDays(-1), Now.AddYears(10), withSubjectKeyIdentifier: false); + + Assert.Throws(() => Issue(root: root)); + } + + private X509Certificate2 Issue( + TimeSpan? lifetime = null, + TimeProvider? clock = null, + RandomNumberGenerator? random = null, + X509Certificate2? root = null, + CertificateSigningRequest? csr = null) + { + return IdentityCertificateIssuer.Issue( + IdentityId, + csr ?? _csr, + new TestRootSigner(root ?? _root, _rootKey), + lifetime ?? Lifetime, + clock ?? new FixedTimeProvider(Now), + random ?? _random); + } + + private X509Certificate2 CreateRoot( + DateTimeOffset notBefore, + DateTimeOffset notAfter, + bool withSubjectKeyIdentifier = true) + { + CertificateRequest request = new("CN=Test Agent Identity Root", _rootKey, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + if (withSubjectKeyIdentifier) + { + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + } + + return request.CreateSelfSigned(notBefore, notAfter); + } + + // RFC 5280 §4.1: TBSCertificate ::= SEQUENCE { [0] EXPLICIT version, serialNumber INTEGER, ... }. + private static byte[] SerialNumber(X509Certificate2 certificate) + { + AsnReader certificateReader = new AsnReader(certificate.RawData, AsnEncodingRules.DER).ReadSequence(); + AsnReader tbsCertificate = certificateReader.ReadSequence(); + _ = tbsCertificate.ReadEncodedValue(); + + return tbsCertificate.ReadIntegerBytes().ToArray(); + } + + private sealed class TestRootSigner(X509Certificate2 certificate, ECDsa key) : IRootSigner + { + public X509Certificate2 Certificate => certificate; + + public X509SignatureGenerator SignatureGenerator => X509SignatureGenerator.CreateForECDsa(key); + } + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() + { + return now; + } + } + + // Moves forward by one step at each reading. + private sealed class AdvancingTimeProvider(DateTimeOffset start, TimeSpan step) : TimeProvider + { + private int _readings; + + public override DateTimeOffset GetUtcNow() + { + return start + (step * _readings++); + } + } + + // Returns the given draws in order and fails when asked for another. + private sealed class SequenceRandomNumberGenerator(params byte[][] draws) : RandomNumberGenerator + { + private int _next; + + public override void GetBytes(byte[] data) + { + GetBytes(data.AsSpan()); + } + + public override void GetBytes(Span data) + { + byte[] draw = draws[_next++]; + Assert.Equal(draw.Length, data.Length); + draw.CopyTo(data); + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IRootSigner.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IRootSigner.cs new file mode 100644 index 0000000000..a4966c9067 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IRootSigner.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity; + +/// +/// The root that issues identity certificates, implemented by the host, which keeps the root's private key. +/// +public interface IRootSigner +{ + /// + /// The root certificate, which must have a subject key identifier. + /// + /// + /// Its subject becomes the issuer of each identity certificate, + /// and its subject key identifier becomes their authority key identifier. + /// + X509Certificate2 Certificate { get; } + + /// + /// Signs identity certificates with the private key of . + /// + X509SignatureGenerator SignatureGenerator { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs new file mode 100644 index 0000000000..2c54cfe339 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs @@ -0,0 +1,141 @@ +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity; + +/// +/// Issues identity certificates, the leaf certificates through which agents authenticate. +/// +public static class IdentityCertificateIssuer +{ + // RFC 5280 §4.2.1.12: id-kp-clientAuth. + private const string ClientAuthentication = "1.3.6.1.5.5.7.3.2"; + + private const string SubjectKeyIdentifierOid = "2.5.29.14"; + + /// + /// The lifetime of an identity certificate when the host sets no other: 90 days. + /// + public static TimeSpan DefaultLifetime { get; } = TimeSpan.FromDays(90); + + /// + /// Issues the identity certificate for the key of a validated CSR. + /// + /// + /// + /// The certificate has the CSR's key, the subject CN=<identity ID>, + /// the subject alternative name URI urn:uuid:<identity ID>, the extended key usage clientAuth, + /// the critical key usage digitalSignature, a subject key identifier, + /// an authority key identifier equal to the issuer's subject key identifier, and no basic constraints. + /// The identity ID is written lowercase and hyphenated, and nothing else from the CSR reaches the certificate. + /// + /// + /// It is valid from the current time, truncated to the second, for , + /// or until the issuer's notAfter if that comes first; a fraction of a second in the lifetime is dropped. + /// Its serial number is 16 octets from , positive and never zero, + /// and signs it with SHA-256. + /// + /// + /// The identity that the certificate belongs to. + /// The validated CSR whose key the certificate is for. + /// The root that signs the certificate. + /// The certificate lifetime, usually . + /// The clock, read once. + /// The source of the serial number. + /// + /// is shorter than one second. + /// + /// + /// The issuer certificate has no subject key identifier, or its notAfter is not after the current second. + /// + public static X509Certificate2 Issue( + Guid identityId, + CertificateSigningRequest csr, + IRootSigner issuer, + TimeSpan lifetime, + TimeProvider timeProvider, + RandomNumberGenerator random) + { + ArgumentNullException.ThrowIfNull(csr); + ArgumentNullException.ThrowIfNull(issuer); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(random); + + // A certificate holds whole seconds, so a shorter lifetime would end in the second it begins. + ArgumentOutOfRangeException.ThrowIfLessThan(lifetime, TimeSpan.FromSeconds(1)); + + X509Certificate2 issuerCertificate = issuer.Certificate; + + // RFC 5280 §4.2.1.1: the authority key identifier repeats the issuer's subject key identifier. + if (issuerCertificate.Extensions[SubjectKeyIdentifierOid] is not X509SubjectKeyIdentifierExtension issuerKeyId) + { + throw new ArgumentException("The issuer certificate has no subject key identifier.", nameof(issuer)); + } + + DateTimeOffset notBefore = TruncateToSecond(timeProvider.GetUtcNow()); + DateTimeOffset issuerNotAfter = new(issuerCertificate.NotAfter.ToUniversalTime(), TimeSpan.Zero); + + if (issuerNotAfter <= notBefore) + { + throw new ArgumentException( + "The issuer certificate expires before the new certificate begins.", + nameof(issuer)); + } + + // Comparing the lifetime with the time left avoids adding a lifetime too long for DateTimeOffset. + DateTimeOffset notAfter = lifetime < issuerNotAfter - notBefore + ? TruncateToSecond(notBefore + lifetime) + : issuerNotAfter; + + string id = identityId.ToString("D"); + PublicKey publicKey = PublicKey.CreateFromSubjectPublicKeyInfo(csr.SubjectPublicKeyInfo.Span, out _); + + X500DistinguishedNameBuilder subject = new(); + subject.AddCommonName(id); + + SubjectAlternativeNameBuilder alternativeName = new(); + alternativeName.AddUri(new Uri($"urn:uuid:{id}")); + + CertificateRequest request = new(subject.Build(), publicKey, HashAlgorithmName.SHA256); + OidCollection usages = [new Oid(ClientAuthentication)]; + + // RFC 5280 §4.2.1.2, method (1): the SHA-1 hash of the subjectPublicKey bits. + X509SubjectKeyIdentifierHashAlgorithm keyIdMethod = X509SubjectKeyIdentifierHashAlgorithm.Sha1; + Collection extensions = request.CertificateExtensions; + extensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + extensions.Add(new X509EnhancedKeyUsageExtension(usages, critical: false)); + extensions.Add(alternativeName.Build(critical: false)); + extensions.Add(new X509SubjectKeyIdentifierExtension(publicKey, keyIdMethod, critical: false)); + extensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(issuerKeyId)); + + return request.Create( + issuerCertificate.SubjectName, + issuer.SignatureGenerator, + notBefore, + notAfter, + NewSerialNumber(random)); + } + + private static DateTimeOffset TruncateToSecond(DateTimeOffset value) + { + return new DateTimeOffset(value.UtcTicks - (value.UtcTicks % TimeSpan.TicksPerSecond), TimeSpan.Zero); + } + + // RFC 5280 §4.1.2.2: a positive INTEGER of at most 20 octets. + private static byte[] NewSerialNumber(RandomNumberGenerator random) + { + byte[] serialNumber = new byte[16]; + + do + { + random.GetBytes(serialNumber); + + // A clear top bit keeps the INTEGER positive without a leading zero octet. + serialNumber[0] &= 0x7F; + } + while (!serialNumber.AsSpan().ContainsAnyExcept((byte)0)); + + return serialNumber; + } +} From 6f4376efb1b587ad702ffc3da305a651748f1a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:17:45 +0900 Subject: [PATCH 13/93] feat(dotnet-agent-identity): create enrollment tokens Every authority must write the same dvaet1 token format, so that one agent enrolls with any of them. Add EnrollmentToken.Create, which writes dvaet1... The bag holds the product base URL in normalized form and without trailing slashes, since the agent appends the API path to it. The secret encodes 32 bytes from the injected generator, and SecretHash, which the authority stores instead of the token, is the SHA-256 of the secret text. Create rejects a URL that is not an absolute https URL, carries user information, a query or a fragment, or makes the token longer than 4096 bytes. ToString and exception messages never reveal the token. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EnrollmentTokenAssert.cs | 23 ++ .../EnrollmentTokenTests.cs | 222 ++++++++++++++++++ .../EnrollmentToken.cs | 157 +++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenAssert.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenAssert.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenAssert.cs new file mode 100644 index 0000000000..68a334c129 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenAssert.cs @@ -0,0 +1,23 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Checks that a text reveals no part of an enrollment token, +/// since tokens never appear in logs, not even partially (INTENT.agent-identity.md, security invariant 9). +/// +internal static class EnrollmentTokenAssert +{ + // Any six consecutive characters of a bag or a secret count as a part of the token. + // The prefix is public, so a fixed message may name it. + private const int PartLength = 6; + + public static void RevealsNoPart(string text, params ReadOnlySpan segments) + { + foreach (string segment in segments) + { + for (int start = 0; start + PartLength <= segment.Length; start++) + { + Assert.DoesNotContain(segment.Substring(start, PartLength), text, StringComparison.Ordinal); + } + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs new file mode 100644 index 0000000000..309f810e5f --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs @@ -0,0 +1,222 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Devolutions.AgentIdentity.Tests; + +public class EnrollmentTokenTests +{ + // The bytes 00 to 1F, and their base64url encoding without padding (RFC 4648 §5). + private static readonly byte[] SecretBytes = [.. Enumerable.Range(0, 32).Select(i => (byte)i)]; + private const string SecretText = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + + // CONTRACT.md §2: `..`, where `secret` is base64url of 32 random bytes. + [Fact] + public void CreateEncodesTheGeneratorBytesAsTheSecret() + { + EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); + + Assert.StartsWith("dvaet1.", token.Value, StringComparison.Ordinal); + Assert.EndsWith("." + SecretText, token.Value, StringComparison.Ordinal); + } + + // CONTRACT.md §2: the authority matches the `secret` text exactly, + // so it hashes the 43 characters rather than the bytes they encode. + [Fact] + public void SecretHashIsTheSha256OfTheSecretText() + { + EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); + + Assert.Equal(SHA256.HashData(Encoding.ASCII.GetBytes(SecretText)), token.SecretHash.ToArray()); + } + + // CONTRACT.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`, + // and its secret is the canonical base64url encoding of the 32 random bytes. + // The random values are seeded, so a failure reproduces. + [Fact] + public void CreateWritesEverySecretInTheContractPattern() + { + Random random = new(2); + + for (int i = 0; i < 256; i++) + { + byte[] bytes = new byte[32]; + random.NextBytes(bytes); + + EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(bytes)); + + Assert.Matches(@"\Advaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}\z", token.Value); + string secret = token.Value.Split('.')[2]; + Assert.Equal(bytes, Base64Url.DecodeFromChars(secret)); + Assert.Equal(secret, Base64Url.EncodeToString(bytes)); + } + } + + // CONTRACT.md §2: `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL. + [Fact] + public void CreatePutsTheProductBaseUrlInTheBag() + { + EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); + + Assert.Equal(("u", "https://host/dvls"), Assert.Single(BagMembers(token))); + } + + // The agent appends `/api/agent-identity/v1/...` to `u` (CONTRACT.md §5), so `u` ends without a slash. + [Theory] + [InlineData("https://host/dvls/", "https://host/dvls")] + [InlineData("https://host/dvls//", "https://host/dvls")] + [InlineData("https://host/", "https://host")] + [InlineData("https://host", "https://host")] + public void CreateRemovesTrailingSlashesFromTheUrl(string productBaseUrl, string expected) + { + EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + + Assert.Equal(("u", expected), Assert.Single(BagMembers(token))); + } + + // CONTRACT.md §2: `u` is an absolute `https` URL without a query or fragment. + // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. + private static readonly string[] UnacceptableUrls = + [ + "", + "/dvls", + "host/dvls", + "http://host/dvls", + "wss://host/dvls", + "https:///dvls", + "https://host/dvls?tenant=1", + "https://host/dvls?", + "https://host/dvls#top", + "https://host/dvls#", + "https://admin@host/dvls", + "https://@host/dvls", + ]; + + public static TheoryData UnacceptableUrlRows => new(UnacceptableUrls); + + [Theory] + [MemberData(nameof(UnacceptableUrlRows))] + public void CreateRejectsAnotherUrl(string productBaseUrl) + { + ArgumentException exception = Assert.Throws( + () => EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes))); + + Assert.Equal("productBaseUrl", exception.ParamName); + } + + // A URI holds no white space (RFC 3986 §2): + // white space around it is not part of it (Appendix C), + // and a space or a non-ASCII character inside it is percent-encoded (§2.1; RFC 3987 §3.1). + [Theory] + [InlineData(" https://host/dvls\t", "https://host/dvls")] + [InlineData("https://host/my dvls", "https://host/my%20dvls")] + [InlineData("https://host/dvls\u00E9", "https://host/dvls%C3%A9")] + public void CreateWritesTheUrlInUriSyntax(string productBaseUrl, string expected) + { + EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + + Assert.Equal(("u", expected), Assert.Single(BagMembers(token))); + } + + // CONTRACT.md §2: a token is at most 4096 bytes. + // A 3025-byte URL makes a 3033-byte bag object, a 4044-character bag and a 4095-byte token. + // One more byte makes a 4046-character bag and a 4097-byte token, since no base64url text has 4045 characters. + [Fact] + public void CreateAcceptsTheLongestUrlThatFits() + { + EnrollmentToken token = EnrollmentToken.Create(UrlOfLength(3025), new ReplayingGenerator(SecretBytes)); + + Assert.Equal(4095, token.Value.Length); + } + + [Fact] + public void CreateRejectsAUrlThatMakesTheTokenTooLong() + { + ArgumentException exception = Assert.Throws( + () => EnrollmentToken.Create(UrlOfLength(3026), new ReplayingGenerator(SecretBytes))); + + Assert.Equal("productBaseUrl", exception.ParamName); + } + + // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially, + // and a log statement may format the token object. + [Fact] + public void ToStringRevealsNoPartOfTheToken() + { + EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); + string[] segments = token.Value.Split('.'); + + EnrollmentTokenAssert.RevealsNoPart(token.ToString(), segments[1], segments[2]); + } + + // INTENT.agent-identity.md, security invariant 9: a host may log the exception of a failed creation. + public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls, UrlOfLength(3026)]); + + [Theory] + [MemberData(nameof(RejectedUrlRows))] + public void CreateFailureRevealsNoPartOfTheSecret(string productBaseUrl) + { + ArgumentException exception = Assert.Throws( + () => EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes))); + + EnrollmentTokenAssert.RevealsNoPart(exception.Message, SecretText); + } + + [Fact] + public void CreateRejectsANullUrl() + { + ArgumentNullException exception = Assert.Throws( + () => EnrollmentToken.Create(null!, new ReplayingGenerator(SecretBytes))); + + Assert.Equal("productBaseUrl", exception.ParamName); + } + + [Fact] + public void CreateRejectsANullGenerator() + { + ArgumentNullException exception = Assert.Throws( + () => EnrollmentToken.Create("https://host/dvls", null!)); + + Assert.Equal("randomNumberGenerator", exception.ParamName); + } + + private static string UrlOfLength(int length) + { + const string prefix = "https://host/"; + + return prefix + new string('a', length - prefix.Length); + } + + // Reads the members of the bag, after checking that the bag is the canonical base64url encoding of its bytes. + private static (string Name, string? Value)[] BagMembers(EnrollmentToken token) + { + string bag = token.Value.Split('.')[1]; + Assert.True(Base64Url.IsValid(bag)); + byte[] json = Base64Url.DecodeFromChars(bag); + Assert.Equal(bag, Base64Url.EncodeToString(json)); + + using JsonDocument document = JsonDocument.Parse(json); + + return [.. document.RootElement.EnumerateObject().Select(member => (member.Name, member.Value.GetString()))]; + } + + /// + /// A generator that returns the given bytes in order, so that a test knows the secret it produces. + /// + private sealed class ReplayingGenerator(byte[] bytes) : RandomNumberGenerator + { + private int _position; + + public override void GetBytes(byte[] data) + { + GetBytes(data.AsSpan()); + } + + public override void GetBytes(Span data) + { + bytes.AsSpan(_position, data.Length).CopyTo(data); + _position += data.Length; + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs new file mode 100644 index 0000000000..c967f44e7b --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs @@ -0,0 +1,157 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Devolutions.AgentIdentity; + +/// +/// A new enrollment token, dvaet1.<bag>.<secret>, +/// which authorizes enrolling new identities and nothing else. +/// +/// +/// The bag tells the agent where to enroll and is never trusted. +/// The secret authenticates the token, and the authority stores only its hash. +/// +public sealed class EnrollmentToken +{ + /// + /// The format version that starts every token this library creates or reads. + /// + internal const string Prefix = "dvaet1"; + + /// + /// The length of the secret segment: the base64url encoding of 32 bytes, without padding. + /// + internal const int SecretLength = 43; + + /// + /// The largest token, in bytes; a token is ASCII, so this is also its largest number of characters. + /// + internal const int MaxLength = 4096; + + private const int SecretByteCount = 32; + + private EnrollmentToken(string value, byte[] secretHash) + { + Value = value; + SecretHash = secretHash; + } + + /// + /// The token text, which the authority shows once, to the administrator who creates the token. + /// + /// + /// The authority never stores or logs the token text, not even partially; it stores . + /// + public string Value { get; } + + /// + /// The SHA-256 of the ASCII text of the secret segment, under which the authority stores the token. + /// + public ReadOnlyMemory SecretHash { get; } + + /// + /// Creates a token for the authority that serves the agent-facing API under a product base URL. + /// + /// + /// The bag holds the JSON object {"u": <productBaseUrl>}, + /// with the URL in normalized absolute form and without trailing slashes, + /// since the agent appends /api/agent-identity/v1/... to it. + /// The secret is 32 bytes from . + /// + /// + /// The absolute https URL of the product, such as https://host/dvls, + /// without user information, query or fragment. + /// + /// A cryptographically secure generator, which supplies the secret. + /// An argument is null. + /// + /// is not an absolute https URL, + /// has user information, a query or a fragment, or makes the token longer than 4096 bytes. + /// + public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerator randomNumberGenerator) + { + ArgumentNullException.ThrowIfNull(productBaseUrl); + ArgumentNullException.ThrowIfNull(randomNumberGenerator); + + ArrayBufferWriter json = new(); + + using (Utf8JsonWriter writer = new(json)) + { + writer.WriteStartObject(); + writer.WriteString("u", NormalizeProductBaseUrl(productBaseUrl)); + writer.WriteEndObject(); + } + + string bag = StrictBase64.EncodeBase64Url(json.WrittenSpan); + + if (!(Prefix.Length + 1 + bag.Length + 1 + SecretLength <= MaxLength)) + { + throw new ArgumentException( + "The product base URL makes the token longer than 4096 bytes.", + nameof(productBaseUrl)); + } + + Span secretBytes = stackalloc byte[SecretByteCount]; + randomNumberGenerator.GetBytes(secretBytes); + string secret = StrictBase64.EncodeBase64Url(secretBytes); + + return new EnrollmentToken(Prefix + "." + bag + "." + secret, HashSecret(secret)); + } + + /// + /// Returns the type name, never the token, so that logging the object reveals no part of the token. + /// + public override string ToString() + { + return nameof(EnrollmentToken); + } + + /// + /// Computes the hash under which the authority stores a token: the SHA-256 of the ASCII text of its secret. + /// + /// + /// The authority matches the secret text exactly, so the hash covers the text rather than the bytes it encodes. + /// + /// The secret segment of the token, in the base64url alphabet. + internal static byte[] HashSecret(ReadOnlySpan secret) + { + byte[] ascii = new byte[secret.Length]; + Encoding.ASCII.GetBytes(secret, ascii); + + return SHA256.HashData(ascii); + } + + private static string NormalizeProductBaseUrl(string productBaseUrl) + { + if (!Uri.TryCreate(productBaseUrl, UriKind.Absolute, out Uri? url)) + { + throw new ArgumentException("The product base URL is not an absolute URL.", nameof(productBaseUrl)); + } + + if (url.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("The product base URL does not use the https scheme.", nameof(productBaseUrl)); + } + + // UserInfo is empty for https://@host, so only the component with its delimiter shows the '@'. + if (url.GetComponents(UriComponents.UserInfo | UriComponents.KeepDelimiter, UriFormat.UriEscaped).Length != 0) + { + throw new ArgumentException("The product base URL has user information.", nameof(productBaseUrl)); + } + + // Query and Fragment include their delimiter, so an empty query or fragment still makes them non-empty. + if (url.Query.Length != 0) + { + throw new ArgumentException("The product base URL has a query.", nameof(productBaseUrl)); + } + + if (url.Fragment.Length != 0) + { + throw new ArgumentException("The product base URL has a fragment.", nameof(productBaseUrl)); + } + + return url.AbsoluteUri.TrimEnd('/'); + } +} From f244737f26f48fb61dba30aa99c923b47d3f8ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:28:04 +0900 Subject: [PATCH 14/93] feat(dotnet-agent-identity): read the enroll bearer token Every authority must refuse the same enroll credentials, because the agent treats token_invalid as permanent and never retries that token. Add EnrollmentAuthorization.ReadBearerToken, which returns the hash under which the authority finds the token, or token_invalid when the Authorization field is missing, repeated, not Bearer, or holds no dvaet1 token of at most 4096 bytes. As RFC 9110 defines credentials, the scheme is case-insensitive and one or more spaces precede the token. The bag is not decoded, since the authority finds a token by its secret alone, and the hash covers the secret text, so a non-canonical secret matches no stored token. The failure detail for logs is a fixed sentence that never holds any part of the token. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EnrollmentAuthorizationTests.cs | 257 ++++++++++++++++++ .../EnrollmentTokenTests.cs | 23 ++ .../EnrollmentAuthorization.cs | 108 ++++++++ .../EnrollmentAuthorizationResult.cs | 64 +++++ .../EnrollmentToken.cs | 4 + 5 files changed, 456 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentAuthorizationTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorizationResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentAuthorizationTests.cs new file mode 100644 index 0000000000..c051d2f935 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentAuthorizationTests.cs @@ -0,0 +1,257 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +public class EnrollmentAuthorizationTests +{ + // A token for https://host/dvls whose secret encodes the bytes 00 to 1F (CONTRACT.md §2). + private const string Bag = "eyJ1IjoiaHR0cHM6Ly9ob3N0L2R2bHMifQ"; + private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + private const string Token = "dvaet1." + Bag + "." + Secret; + + // CONTRACT.md §2.1: the authority finds the token whose SHA-256(secret) matches, + // and §2 makes it match the secret text exactly. + [Fact] + public void ReadsTheSecretHashOfABearerToken() + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + Token]); + + Assert.True(result.Succeeded); + Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + } + + // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is missing. + [Fact] + public void RejectsARequestWithoutAuthorization() + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([]); + + AssertTokenInvalid(result); + } + + // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is repeated, + // even when every field line holds the same valid token. + [Theory] + [InlineData("Bearer " + Token, "Bearer " + Token)] + [InlineData("Bearer " + Token, "")] + [InlineData("", "Bearer " + Token)] + public void RejectsARepeatedAuthorizationField(string first, string second) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([first, second]); + + AssertTokenInvalid(result); + } + + // RFC 9110 §11.1: the authentication scheme is case-insensitive. + [Theory] + [InlineData("bearer")] + [InlineData("BEARER")] + [InlineData("bEaReR")] + public void AcceptsTheBearerSchemeInAnyCase(string scheme) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([scheme + " " + Token]); + + Assert.True(result.Succeeded); + Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + } + + // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is not `Bearer`. + [Theory] + [InlineData("Basic " + Token)] + [InlineData("DPoP " + Token)] + [InlineData("Bearers " + Token)] + [InlineData("Bear " + Token)] + [InlineData(Token)] + public void RejectsAnotherScheme(string fieldValue) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); + + AssertTokenInvalid(result); + } + + // RFC 9110 §11.4: `credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]`. + [Theory] + [InlineData("Bearer " + Token)] + [InlineData("Bearer " + Token)] + public void AcceptsSeveralSpacesBeforeTheToken(string fieldValue) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); + + Assert.True(result.Succeeded); + Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + } + + // RFC 9110 §11.4: one or more spaces, and nothing else, separate the scheme from the token. + [Theory] + [InlineData("Bearer")] + [InlineData("Bearer ")] + [InlineData("Bearer" + Token)] + [InlineData("Bearer\t" + Token)] + [InlineData("Bearer \t" + Token)] + public void RejectsAFieldWithoutSpacesThenAToken(string fieldValue) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); + + AssertTokenInvalid(result); + } + + // CONTRACT.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`; + // §2.1 (1): a field that holds no token gets `token_invalid`. + // The pattern covers the whole value, so a line terminator after the token is not part of a match. + public static TheoryData MalformedTokens => new() + { + "dvaet2." + Bag + "." + Secret, + "DVAET1." + Bag + "." + Secret, + "dvaet1" + Bag + "." + Secret, + Bag + "." + Secret, + "dvaet1.." + Secret, + "dvaet1." + Bag + "=." + Secret, + "dvaet1." + Bag + "+/." + Secret, + "dvaet1." + Bag + " ." + Secret, + "dvaet1." + Bag + "\uFF41." + Secret, + "dvaet1." + Bag + "." + Bag + "." + Secret, + "dvaet1." + Bag, + "dvaet1." + Bag + ".", + "dvaet1." + Bag + "." + Secret[..42], + "dvaet1." + Bag + "." + Secret + "A", + "dvaet1." + Bag + "." + Secret[..42] + "=", + "dvaet1." + Bag + "." + Secret[..42] + "/", + Token + "\n", + Token + "\r\n", + Token + "\0", + Token + ",", + Token + ", Bearer " + Token, + }; + + [Theory] + [MemberData(nameof(MalformedTokens))] + public void RejectsAValueThatIsNotADvaet1Token(string token) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + token]); + + AssertTokenInvalid(result); + } + + // CONTRACT.md §2: a token is at most 4096 bytes. + // The bag is not decoded, so a bag of any length in the alphabet sets the token length. + [Fact] + public void AcceptsATokenOf4096Bytes() + { + string token = "dvaet1." + new string('A', 4045) + "." + Secret; + + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + token]); + + Assert.Equal(4096, token.Length); + Assert.True(result.Succeeded); + Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + } + + [Fact] + public void RejectsATokenLongerThan4096Bytes() + { + string token = "dvaet1." + new string('A', 4046) + "." + Secret; + + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + token]); + + Assert.Equal(4097, token.Length); + AssertTokenInvalid(result); + } + + // CONTRACT.md §2.1 (1): the authority finds a token by `SHA-256(secret)` alone, + // and §2 makes the bag routing data that the authority never trusts, so it is not decoded. + // The rows are a bag of an impossible base64url length, one that decodes to bytes that are not JSON, + // and the bag of {"u":"https://other/psu"}. + [Theory] + [InlineData("A")] + [InlineData("_-_-")] + [InlineData("eyJ1IjoiaHR0cHM6Ly9vdGhlci9wc3UifQ")] + public void ReadsTheSecretHashWhateverTheBag(string bag) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken( + ["Bearer dvaet1." + bag + "." + Secret]); + + Assert.True(result.Succeeded); + Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + } + + // CONTRACT.md §2: the authority matches the `secret` text exactly. + // Ending in '9' (sextet 61) instead of '8' (sextet 60) sets a pad bit, + // so a lenient decoder reads the same 32 bytes, but the text, and so its hash, differs. + [Fact] + public void HashesANonCanonicalSecretAsItsText() + { + string nonCanonical = Secret[..42] + "9"; + + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken( + ["Bearer dvaet1." + Bag + "." + nonCanonical]); + + Assert.True(result.Succeeded); + Assert.Equal(HashOf(nonCanonical), result.SecretHash.ToArray()); + Assert.NotEqual(HashOf(Secret), result.SecretHash.ToArray()); + } + + // RFC 9110 §5.5: a field value excludes the white space around it, + // which a parser removes before evaluating the value. + [Theory] + [InlineData(" Bearer " + Token)] + [InlineData("Bearer " + Token + " ")] + [InlineData("\tBearer " + Token + "\t")] + [InlineData(" \t Bearer " + Token + " \t ")] + public void IgnoresWhiteSpaceAroundTheFieldValue(string fieldValue) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); + + Assert.True(result.Succeeded); + Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + } + + // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially, + // and a host logs the failure detail, the error and the result of a refused enrollment. + // Each row takes another failure path with the token's bag and secret in the field. + public static TheoryData RefusedFieldLines => new() + { + Array.Empty(), + new[] { "Bearer " + Token, "Bearer " + Token }, + new[] { "Basic " + Token }, + new[] { Token }, + new[] { "Bearer" + Token }, + new[] { "Bearer" }, + new[] { "Bearer dvaet1." + Bag + new string('A', 4012) + "." + Secret }, + new[] { "Bearer " + Token + "\n" }, + }; + + [Theory] + [MemberData(nameof(RefusedFieldLines))] + public void FailureRevealsNoPartOfTheToken(string[] fieldLines) + { + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(fieldLines); + + Assert.False(result.Succeeded); + EnrollmentTokenAssert.RevealsNoPart(result.FailureDetail, Bag, Secret); + EnrollmentTokenAssert.RevealsNoPart(result.Error.Message, Bag, Secret); + EnrollmentTokenAssert.RevealsNoPart($"{result}", Bag, Secret); + } + + [Fact] + public void RejectsNullFieldLines() + { + ArgumentNullException exception = Assert.Throws( + () => EnrollmentAuthorization.ReadBearerToken(null!)); + + Assert.Equal("authorizationFieldLines", exception.ParamName); + } + + // CONTRACT.md §5.4: `token_invalid` is carried by HTTP 401. + private static void AssertTokenInvalid(EnrollmentAuthorizationResult result) + { + Assert.False(result.Succeeded); + Assert.Equal(AgentIdentityErrorCode.TokenInvalid, result.Error.Code); + Assert.Equal(401, result.Error.HttpStatusCode); + } + + private static byte[] HashOf(string secret) + { + return SHA256.HashData(Encoding.ASCII.GetBytes(secret)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs index 309f810e5f..0484ba2938 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs @@ -53,6 +53,29 @@ public void CreateWritesEverySecretInTheContractPattern() } } + // The authority stores SecretHash when it creates a token, + // and finds the token of an enroll request by the hash that ReadBearerToken returns. + [Fact] + public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() + { + Random random = new(3); + + foreach (string productBaseUrl in (string[])["https://host/dvls", UrlOfLength(3025)]) + { + for (int i = 0; i < 64; i++) + { + byte[] bytes = new byte[32]; + random.NextBytes(bytes); + EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(bytes)); + + EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + token.Value]); + + Assert.True(result.Succeeded); + Assert.Equal(token.SecretHash.ToArray(), result.SecretHash.ToArray()); + } + } + } + // CONTRACT.md §2: `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL. [Fact] public void CreatePutsTheProductBaseUrlInTheBag() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs new file mode 100644 index 0000000000..683956c314 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs @@ -0,0 +1,108 @@ +using System.Buffers; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// Reads the credential of an enroll request, Authorization: Bearer <token>. +/// +public static class EnrollmentAuthorization +{ + private const string TokenStart = EnrollmentToken.Prefix + "."; + + private static readonly SearchValues Base64UrlAlphabet = + SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"); + + /// + /// Reads the enrollment token of an enroll request and returns the hash under which the authority finds it. + /// + /// + /// + /// The request gets token_invalid when the Authorization field is missing or repeated, + /// does not use the Bearer scheme, or holds no dvaet1 token of at most 4096 bytes. + /// The scheme is case-insensitive, one or more spaces separate it from the token, + /// and white space around the field value is ignored. + /// + /// + /// The authority finds a token by its secret alone, so the bag is not decoded. + /// When no stored token has , + /// the request gets token_invalid too. + /// Both checks come before any check of the request body. + /// + /// + /// + /// The values of the request's Authorization field lines, one per line as received, without combining them. + /// + /// is null. + public static EnrollmentAuthorizationResult ReadBearerToken(IReadOnlyList authorizationFieldLines) + { + ArgumentNullException.ThrowIfNull(authorizationFieldLines); + + if (authorizationFieldLines.Count == 0) + { + return EnrollmentAuthorizationResult.Failure("The request has no Authorization field."); + } + + if (authorizationFieldLines.Count != 1) + { + return EnrollmentAuthorizationResult.Failure("The request has more than one Authorization field line."); + } + + // RFC 9110 §5.5: the optional white space around a field value is not part of it. + ReadOnlySpan value = authorizationFieldLines[0].AsSpan().Trim(" \t"); + int separator = value.IndexOf(' '); + ReadOnlySpan scheme = separator < 0 ? value : value[..separator]; + + if (!Ascii.EqualsIgnoreCase(scheme, "Bearer")) + { + return EnrollmentAuthorizationResult.Failure("The Authorization field does not use the Bearer scheme."); + } + + if (separator < 0) + { + return EnrollmentAuthorizationResult.Failure("The Authorization field has no token after the Bearer scheme."); + } + + ReadOnlySpan token = value[separator..].TrimStart(' '); + + if (!(token.Length <= EnrollmentToken.MaxLength)) + { + return EnrollmentAuthorizationResult.Failure("The bearer token is longer than 4096 bytes."); + } + + if (!TryReadSecret(token, out ReadOnlySpan secret)) + { + return EnrollmentAuthorizationResult.Failure("The bearer token is not a dvaet1 enrollment token."); + } + + return EnrollmentAuthorizationResult.Success(EnrollmentToken.HashSecret(secret)); + } + + // Matches the entire token, with nothing after the secret, + // against the pattern of CONTRACT.md §2: ^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$. + private static bool TryReadSecret(ReadOnlySpan token, out ReadOnlySpan secret) + { + secret = default; + + if (!token.StartsWith(TokenStart, StringComparison.Ordinal)) + { + return false; + } + + ReadOnlySpan segments = token[TokenStart.Length..]; + int separator = segments.LastIndexOf('.'); + + if (separator < 0) + { + return false; + } + + ReadOnlySpan bag = segments[..separator]; + secret = segments[(separator + 1)..]; + + return 0 < bag.Length + && secret.Length == EnrollmentToken.SecretLength + && !bag.ContainsAnyExcept(Base64UrlAlphabet) + && !secret.ContainsAnyExcept(Base64UrlAlphabet); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorizationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorizationResult.cs new file mode 100644 index 0000000000..c28ee52a2a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorizationResult.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The result of : +/// the hash under which the authority finds the enrollment token, or token_invalid. +/// +public sealed class EnrollmentAuthorizationResult +{ + private EnrollmentAuthorizationResult( + ReadOnlyMemory secretHash, + AgentIdentityError? error, + string? failureDetail) + { + Succeeded = error is null; + SecretHash = secretHash; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the request holds a well-formed token. + /// + /// + /// A well-formed token still authenticates nothing until the authority finds a stored token by its hash. + /// + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Succeeded { get; } + + /// + /// The SHA-256 of the ASCII text of the token's secret, + /// which is the of the stored token; + /// empty when is false. + /// + public ReadOnlyMemory SecretHash { get; } + + /// + /// token_invalid, with HTTP status 401, when is false; otherwise null. + /// + public AgentIdentityError? Error { get; } + + /// + /// Why the request holds no well-formed token, for the host's logs, when is false; + /// otherwise null. + /// + /// + /// The detail is a fixed sentence and never holds any part of the token. + /// + public string? FailureDetail { get; } + + internal static EnrollmentAuthorizationResult Success(byte[] secretHash) + { + return new EnrollmentAuthorizationResult(secretHash, null, null); + } + + internal static EnrollmentAuthorizationResult Failure(string failureDetail) + { + return new EnrollmentAuthorizationResult( + ReadOnlyMemory.Empty, + AgentIdentityError.From(AgentIdentityErrorCode.TokenInvalid), + failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs index c967f44e7b..5c652f1a17 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs @@ -49,6 +49,10 @@ private EnrollmentToken(string value, byte[] secretHash) /// /// The SHA-256 of the ASCII text of the secret segment, under which the authority stores the token. /// + /// + /// returns the same hash for the token text, + /// so that the authority finds the token by it. + /// public ReadOnlyMemory SecretHash { get; } /// From 3789a7afa4a8df7d4c919f4c46087c9b8fb15014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:36:40 +0900 Subject: [PATCH 15/93] feat(dotnet-agent-identity): decide enrollments by token and key Every authority must answer an enroll request the same way, because the agent retries a lost enrollment response with the same token and CSR, and treats every token_* code as permanent. Add EnrollmentRules.Decide, which applies the contract's precedence to the stored token state and to the issued certificate that holds the CSR key, if any. The current key of a non-revoked identity enrolled with the same token replays that identity without counting a use, even with a deleted, expired or exhausted token. Any other issued key gets the first matching key rule, or token_invalid with a deleted token; the key of a deleted identity gets identity_revoked, since the authority keeps only its SPKI hash and issuance time. A new key gets token_invalid with a deleted token, then token_expired, then token_exhausted, and otherwise creates an identity. The host supplies these facts through EnrollmentTokenState and IssuedKeyMatch, with the CertificateStatus of the matching certificate. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EnrollmentRulesTests.cs | 269 ++++++++++++++++++ .../EnrollmentAuthorization.cs | 5 +- .../EnrollmentDecision.cs | 55 ++++ .../EnrollmentOutcome.cs | 32 +++ .../EnrollmentRules.cs | 113 ++++++++ .../EnrollmentTokenState.cs | 58 ++++ .../IssuedKeyMatch.cs | 80 ++++++ 7 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentDecision.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentOutcome.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentRules.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentTokenState.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs new file mode 100644 index 0000000000..e213a372c7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs @@ -0,0 +1,269 @@ +namespace Devolutions.AgentIdentity.Tests; + +public class EnrollmentRulesTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + + private static readonly Guid IdentityId = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"); + + // CONTRACT.md §2.1: a token creates an identity when it exists, has not been deleted, has not expired, + // and fewer than `max_uses` identities have been created with it; (6) a new identity. + [Fact] + public void CreatesAnIdentityForANewKeyWithAUsableToken() + { + EnrollmentDecision decision = EnrollmentRules.Decide(Token(), null, Now); + + Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); + Assert.Null(decision.ExistingIdentityId); + Assert.Null(decision.Error); + } + + // CONTRACT.md §5.4: `token_expired` when `now ≥ expires_at`. + [Fact] + public void RejectsATokenAtItsExpiryWithTokenExpired() + { + EnrollmentTokenState token = new(false, expiresAt: Now, maxUses: 10, identitiesCreated: 3); + + EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); + + AssertRejected(AgentIdentityErrorCode.TokenExpired, decision); + } + + [Fact] + public void CreatesAnIdentityJustBeforeTheTokenExpiry() + { + EnrollmentTokenState token = new(false, expiresAt: Now.AddTicks(1), maxUses: 10, identitiesCreated: 3); + + EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); + + Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); + } + + // CONTRACT.md §2.1: a token creates an identity only while fewer than `max_uses` identities have been created + // with it; (5) `token_exhausted`. + [Theory] + [InlineData(10, 10)] + [InlineData(10, 11)] + [InlineData(0, 0)] + public void RejectsATokenThatCreatedMaxUsesIdentitiesWithTokenExhausted(int maxUses, int identitiesCreated) + { + EnrollmentTokenState token = new(false, Now.AddDays(1), maxUses, identitiesCreated); + + EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); + + AssertRejected(AgentIdentityErrorCode.TokenExhausted, decision); + } + + [Fact] + public void CreatesTheLastIdentityThatATokenAllows() + { + EnrollmentTokenState token = new(false, Now.AddDays(1), maxUses: 10, identitiesCreated: 9); + + EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); + + Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); + } + + // CONTRACT.md §2.1: a deleted token authenticates only for an idempotent replay; + // every other use returns `token_invalid`. + [Fact] + public void RejectsADeletedTokenWithTokenInvalid() + { + EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted: true), null, Now); + + AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); + } + + // CONTRACT.md §2.1: when several outcomes apply, the earliest wins: + // (4) `token_invalid` for a deleted token, then `token_expired` for an expired one; (5) `token_exhausted`. + [Theory] + [InlineData(true, true, false, AgentIdentityErrorCode.TokenInvalid)] + [InlineData(true, false, true, AgentIdentityErrorCode.TokenInvalid)] + [InlineData(true, true, true, AgentIdentityErrorCode.TokenInvalid)] + [InlineData(false, true, true, AgentIdentityErrorCode.TokenExpired)] + public void RejectsANewKeyWithTheEarliestTokenOutcome( + bool deleted, + bool expired, + bool exhausted, + AgentIdentityErrorCode expected) + { + EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted, expired, exhausted), null, Now); + + AssertRejected(expected, decision); + } + + // CONTRACT.md §2.1: the key of the current certificate of a non-revoked identity enrolled with the same token + // → 200 with that identity, without counting a use. + // The key lookup (3) comes before (4) and (5), and a deleted token authenticates for this replay, + // so the replay wins whether the token is deleted, expired or exhausted. + [Theory] + [InlineData(false, false, false)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + [InlineData(true, true, true)] + public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, bool expired, bool exhausted) + { + IssuedKeyMatch keyMatch = IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current); + + EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted, expired, exhausted), keyMatch, Now); + + Assert.Equal(EnrollmentOutcome.ReturnExistingIdentity, decision.Outcome); + Assert.Equal(IdentityId, decision.ExistingIdentityId); + Assert.Null(decision.Error); + } + + // CONTRACT.md §2.1: the first matching key rule decides. + // The key of an identity enrolled with a different token → `invalid_request`, before the revocation rule; + // any certificate key of a revoked identity → `identity_revoked`, before the pending-or-retired rule + // and instead of the replay, which needs a non-revoked identity; + // the key of a pending or retired certificate of a non-revoked identity → `invalid_request`, + // instead of the replay, which needs the current certificate. + [Theory] + [InlineData(false, false, CertificateStatus.Current, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(false, false, CertificateStatus.Pending, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(false, false, CertificateStatus.Retired, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(false, true, CertificateStatus.Current, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(true, true, CertificateStatus.Current, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, true, CertificateStatus.Pending, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, true, CertificateStatus.Retired, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, false, CertificateStatus.Pending, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(true, false, CertificateStatus.Retired, AgentIdentityErrorCode.InvalidRequest)] + public void RejectsAnIssuedKeyByTheFirstMatchingRule( + bool enrolledWithPresentedToken, + bool identityRevoked, + CertificateStatus status, + AgentIdentityErrorCode expected) + { + IssuedKeyMatch keyMatch = + IssuedKeyMatch.ForIdentity(IdentityId, enrolledWithPresentedToken, identityRevoked, status); + + EnrollmentDecision decision = EnrollmentRules.Decide(Token(), keyMatch, Now); + + AssertRejected(expected, decision); + } + + // CONTRACT.md §2.1: the key of a certificate of a deleted identity → `identity_revoked`. + // The authority keeps only the SPKI SHA-256 and issuance time of a deleted identity's keys, + // so no other key rule applies to them. + [Fact] + public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked() + { + EnrollmentDecision decision = EnrollmentRules.Decide(Token(), IssuedKeyMatch.ForDeletedIdentity(), Now); + + AssertRejected(AgentIdentityErrorCode.IdentityRevoked, decision); + } + + // CONTRACT.md §2.1: (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. + [Theory] + [InlineData(false, false, CertificateStatus.Current, true, false, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(false, false, CertificateStatus.Current, false, true, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(true, true, CertificateStatus.Current, true, false, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, true, CertificateStatus.Retired, false, true, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, false, CertificateStatus.Pending, true, true, AgentIdentityErrorCode.InvalidRequest)] + public void RejectsAnIssuedKeyBeforeAnExpiredOrExhaustedToken( + bool enrolledWithPresentedToken, + bool identityRevoked, + CertificateStatus status, + bool expired, + bool exhausted, + AgentIdentityErrorCode expected) + { + IssuedKeyMatch keyMatch = + IssuedKeyMatch.ForIdentity(IdentityId, enrolledWithPresentedToken, identityRevoked, status); + + EnrollmentDecision decision = + EnrollmentRules.Decide(Token(expired: expired, exhausted: exhausted), keyMatch, Now); + + AssertRejected(expected, decision); + } + + [Fact] + public void RejectsTheKeyOfADeletedIdentityBeforeAnExpiredAndExhaustedToken() + { + EnrollmentDecision decision = EnrollmentRules.Decide( + Token(expired: true, exhausted: true), + IssuedKeyMatch.ForDeletedIdentity(), + Now); + + AssertRejected(AgentIdentityErrorCode.IdentityRevoked, decision); + } + + // CONTRACT.md §2.1 (3): a deleted token gets `token_invalid` for anything but an idempotent replay, + // whatever the CSR public key matches. + [Theory] + [InlineData(false, false, CertificateStatus.Current)] + [InlineData(true, true, CertificateStatus.Current)] + [InlineData(true, false, CertificateStatus.Pending)] + [InlineData(true, false, CertificateStatus.Retired)] + public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay( + bool enrolledWithPresentedToken, + bool identityRevoked, + CertificateStatus status) + { + IssuedKeyMatch keyMatch = + IssuedKeyMatch.ForIdentity(IdentityId, enrolledWithPresentedToken, identityRevoked, status); + + EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted: true), keyMatch, Now); + + AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); + } + + [Fact] + public void RejectsADeletedTokenWithTokenInvalidForTheKeyOfADeletedIdentity() + { + EnrollmentDecision decision = EnrollmentRules.Decide( + Token(deleted: true), + IssuedKeyMatch.ForDeletedIdentity(), + Now); + + AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); + } + + [Theory] + [InlineData(-1, 0, "maxUses")] + [InlineData(1, -1, "identitiesCreated")] + public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, string parameter) + { + ArgumentOutOfRangeException exception = Assert.Throws( + () => new EnrollmentTokenState(false, Now, maxUses, identitiesCreated)); + + Assert.Equal(parameter, exception.ParamName); + } + + [Fact] + public void KeyMatchRejectsAnUndefinedCertificateStatus() + { + ArgumentOutOfRangeException exception = Assert.Throws( + () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, (CertificateStatus)3)); + + Assert.Equal("status", exception.ParamName); + } + + [Fact] + public void DecideRejectsANullToken() + { + ArgumentNullException exception = Assert.Throws( + () => EnrollmentRules.Decide(null!, null, Now)); + + Assert.Equal("token", exception.ParamName); + } + + private static void AssertRejected(AgentIdentityErrorCode expected, EnrollmentDecision decision) + { + Assert.Equal(EnrollmentOutcome.Reject, decision.Outcome); + Assert.Equal(expected, decision.Error?.Code); + Assert.Null(decision.ExistingIdentityId); + Assert.False(string.IsNullOrEmpty(decision.FailureDetail)); + } + + // A token that is neither deleted, expired nor exhausted, unless a flag says otherwise. + private static EnrollmentTokenState Token(bool deleted = false, bool expired = false, bool exhausted = false) + { + return new EnrollmentTokenState( + deleted, + expiresAt: expired ? Now : Now.AddDays(1), + maxUses: 10, + identitiesCreated: exhausted ? 10 : 3); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs index 683956c314..3f35074c2f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs @@ -25,6 +25,8 @@ public static class EnrollmentAuthorization /// /// /// The authority finds a token by its secret alone, so the bag is not decoded. + /// The lookup includes deleted tokens, which keep their hash, + /// and refuses them except for a replay. /// When no stored token has , /// the request gets token_invalid too. /// Both checks come before any check of the request body. @@ -60,7 +62,8 @@ public static EnrollmentAuthorizationResult ReadBearerToken(IReadOnlyList token = value[separator..].TrimStart(' '); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentDecision.cs new file mode 100644 index 0000000000..ed453872be --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentDecision.cs @@ -0,0 +1,55 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with an enroll request, as decides it. +/// +public sealed class EnrollmentDecision +{ + private EnrollmentDecision( + EnrollmentOutcome outcome, + Guid? existingIdentityId, + AgentIdentityError? error, + string? failureDetail) + { + Outcome = outcome; + ExistingIdentityId = existingIdentityId; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// What the authority does with the request. + /// + public EnrollmentOutcome Outcome { get; } + + /// + /// The identity whose enrollment the request replays, + /// when is ; otherwise null. + /// + public Guid? ExistingIdentityId { get; } + + /// + /// The error that refuses the request, when is ; + /// otherwise null. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that refuses the request, for the host's logs, + /// when is ; otherwise null. + /// + public string? FailureDetail { get; } + + internal static EnrollmentDecision CreateIdentity { get; } = + new(EnrollmentOutcome.CreateIdentity, null, null, null); + + internal static EnrollmentDecision ReturnExistingIdentity(Guid identityId) + { + return new EnrollmentDecision(EnrollmentOutcome.ReturnExistingIdentity, identityId, null, null); + } + + internal static EnrollmentDecision Reject(AgentIdentityErrorCode code, string failureDetail) + { + return new EnrollmentDecision(EnrollmentOutcome.Reject, null, AgentIdentityError.From(code), failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentOutcome.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentOutcome.cs new file mode 100644 index 0000000000..2cb5e36541 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentOutcome.cs @@ -0,0 +1,32 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with an enroll request. +/// +public enum EnrollmentOutcome +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering an outcome would change what an existing host does. + // When adding an outcome, give it the next unused value; + // values start at 1 so that an uninitialized outcome is not a valid one. + + /// + /// Create a new identity, which counts one use of the token. + /// + CreateIdentity = 1, + + /// + /// Return with its current certificate chain, + /// so that a device recovers a lost enrollment response. + /// + /// + /// The replay counts no use of the token and changes no state of the identity, such as its metadata, + /// because the CSR proves past possession of the key, not current possession. + /// + ReturnExistingIdentity = 2, + + /// + /// Refuse the request with . + /// + Reject = 3, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentRules.cs new file mode 100644 index 0000000000..aca931e6e8 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentRules.cs @@ -0,0 +1,113 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Applies the enrollment rules that every authority shares to an enroll request. +/// +public static class EnrollmentRules +{ + /// + /// Decides whether an enroll request creates an identity, replays the enrollment of an existing one, + /// or is refused. + /// + /// + /// + /// The host calls this once and its token lookup succeed + /// and the request body, CSR and metadata are valid. + /// When several outcomes apply, the earliest of these wins: + /// the result of the CSR key lookup, + /// except that a deleted token gets token_invalid for anything but a replay; + /// token_invalid for a deleted token, + /// then token_expired once reaches its expiry; + /// token_exhausted once it has created identities; + /// a new identity. + /// + /// + /// The CSR key lookup takes the first matching rule: + /// the current key of a non-revoked identity enrolled with the presented token replays that identity's enrollment; + /// the key of an identity enrolled with another token gets invalid_request; + /// any key of a revoked identity gets identity_revoked; + /// a pending or retired key of a non-revoked identity gets invalid_request; + /// the key of a deleted identity gets identity_revoked. + /// + /// + /// The decision holds only for the state it was made from. + /// The host creates the identity and counts the use atomically, + /// so that concurrent enrollments never create more than identities + /// with one token, and of two concurrent enrollments with one CSR key, exactly one creates the identity + /// and the other gets the replay. + /// + /// + /// The state of the token that the request presents. + /// The issued certificate whose public key is the CSR key, or null when none has it. + /// The authority's current time, read once for the request. + /// is null. + public static EnrollmentDecision Decide(EnrollmentTokenState token, IssuedKeyMatch? keyMatch, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(token); + + if (keyMatch is not null) + { + if (keyMatch.IsReplay) + { + return EnrollmentDecision.ReturnExistingIdentity(keyMatch.IdentityId); + } + + if (token.Deleted) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.TokenInvalid, + "The token is deleted, and the CSR key is not the current key of an identity that it created."); + } + + return RejectIssuedKey(keyMatch); + } + + if (token.Deleted) + { + return EnrollmentDecision.Reject(AgentIdentityErrorCode.TokenInvalid, "The token is deleted."); + } + + if (!(now < token.ExpiresAt)) + { + return EnrollmentDecision.Reject(AgentIdentityErrorCode.TokenExpired, "The token has expired."); + } + + if (!(token.IdentitiesCreated < token.MaxUses)) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.TokenExhausted, + "The token has created its maximum number of identities."); + } + + return EnrollmentDecision.CreateIdentity; + } + + // The key rules after the replay, in their order; a deleted identity matches only its own rule. + private static EnrollmentDecision RejectIssuedKey(IssuedKeyMatch keyMatch) + { + if (keyMatch.IdentityDeleted) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.IdentityRevoked, + "The CSR key belongs to a deleted identity."); + } + + if (!keyMatch.EnrolledWithPresentedToken) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.InvalidRequest, + "The CSR key belongs to an identity enrolled with another token."); + } + + if (keyMatch.IdentityRevoked) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.IdentityRevoked, + "The CSR key belongs to a revoked identity."); + } + + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.InvalidRequest, + "The CSR key belongs to a pending or retired certificate."); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentTokenState.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentTokenState.cs new file mode 100644 index 0000000000..fd7c5d8873 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentTokenState.cs @@ -0,0 +1,58 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The stored state of the enrollment token that an enroll request presents. +/// +/// +/// None of it changes after the token is created, except the count of identities it created and its deletion. +/// +public sealed class EnrollmentTokenState +{ + /// + /// Creates the state of a token. + /// + /// Whether the token was deleted. + /// The instant from which the token is expired. + /// The number of identities that the token may create. + /// The number of identities that the token has created. + /// + /// or is negative. + /// + public EnrollmentTokenState(bool deleted, DateTimeOffset expiresAt, int maxUses, int identitiesCreated) + { + ArgumentOutOfRangeException.ThrowIfNegative(maxUses); + ArgumentOutOfRangeException.ThrowIfNegative(identitiesCreated); + + Deleted = deleted; + ExpiresAt = expiresAt; + MaxUses = maxUses; + IdentitiesCreated = identitiesCreated; + } + + /// + /// Whether the token was deleted. + /// + /// + /// A deleted token keeps its secret hash and deletion time as a tombstone, + /// so that a device can still recover the enrollment response of an identity that the token created. + /// + public bool Deleted { get; } + + /// + /// The instant from which the token is expired. + /// + public DateTimeOffset ExpiresAt { get; } + + /// + /// The number of identities that the token may create. + /// + public int MaxUses { get; } + + /// + /// The number of identities that the token has created. + /// + /// + /// Only the creation of a new identity counts; a replay, a failed enrollment and any later operation never do. + /// + public int IdentitiesCreated { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs new file mode 100644 index 0000000000..5990648438 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs @@ -0,0 +1,80 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The issued certificate whose public key is the key of an enroll request's CSR. +/// +/// +/// The host finds it by the SPKI SHA-256 of the CSR key among every certificate the authority has issued: +/// current, pending and retired certificates, and those of deleted identities. +/// +public sealed class IssuedKeyMatch +{ + private static readonly IssuedKeyMatch DeletedIdentity = new(Guid.Empty, true, false, false, default); + + private IssuedKeyMatch( + Guid identityId, + bool identityDeleted, + bool enrolledWithPresentedToken, + bool identityRevoked, + CertificateStatus status) + { + IdentityId = identityId; + IdentityDeleted = identityDeleted; + EnrolledWithPresentedToken = enrolledWithPresentedToken; + IdentityRevoked = identityRevoked; + Status = status; + } + + internal Guid IdentityId { get; } + + internal bool IdentityDeleted { get; } + + internal bool EnrolledWithPresentedToken { get; } + + internal bool IdentityRevoked { get; } + + internal CertificateStatus Status { get; } + + /// + /// Whether the key is the current key of a non-revoked identity enrolled with the presented token, + /// so that the request replays that identity's enrollment. + /// + internal bool IsReplay => + !IdentityDeleted && EnrolledWithPresentedToken && !IdentityRevoked && Status == CertificateStatus.Current; + + /// + /// Creates the match of a certificate of an identity that the authority holds. + /// + /// The identity that the certificate was issued to. + /// + /// Whether the identity was enrolled with the token that the request presents. + /// + /// Whether the identity is revoked. + /// The status of the certificate. + /// is not a defined status. + public static IssuedKeyMatch ForIdentity( + Guid identityId, + bool enrolledWithPresentedToken, + bool identityRevoked, + CertificateStatus status) + { + if (!Enum.IsDefined(status)) + { + throw new ArgumentOutOfRangeException(nameof(status), status, "Not a defined certificate status."); + } + + return new IssuedKeyMatch(identityId, false, enrolledWithPresentedToken, identityRevoked, status); + } + + /// + /// Returns the match of a certificate of a deleted identity. + /// + /// + /// After an identity is deleted, the authority keeps only the SPKI SHA-256 and the issuance time of its keys, + /// so the key gets identity_revoked whatever token enrolled the identity. + /// + public static IssuedKeyMatch ForDeletedIdentity() + { + return DeletedIdentity; + } +} From eaeabf6714e1f7aa3fec342ffc9b3b994928f344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 18:47:05 +0900 Subject: [PATCH 16/93] feat(dotnet-agent-identity): evaluate friendly-name formats Every authority must name a new identity the same way from its token's friendly-name format, and reject the same formats when a token is created. Add FriendlyNameFormat. TryParse accepts literal text, {{ and }}, and the placeholders of the known metadata keys and {token_name}, and explains an unpaired brace or an unknown placeholder to the administrator. Evaluate substitutes the values, with a missing metadata value empty, trims Unicode White_Space, truncates to 255 Unicode scalar values without splitting one, trims again, and falls back to the identity ID when the name is empty. A lone surrogate becomes U+FFFD, so the name stays well-formed. Default is {hostname}. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FriendlyNameFormatTests.cs | 282 ++++++++++++++++++ .../FriendlyNameFormat.cs | 200 +++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs new file mode 100644 index 0000000000..57be5a5652 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs @@ -0,0 +1,282 @@ +namespace Devolutions.AgentIdentity.Tests; + +public class FriendlyNameFormatTests +{ + // Parsed from uppercase text, so that a lowercase identity ID in a result comes from the evaluation. + private static readonly Guid IdentityId = Guid.Parse("0F8FAD5B-D9CB-469F-A165-70867728950E"); + + private const string TokenName = "Fleet"; + + // A value for each known metadata key of CONTRACT.md §3. + private static readonly Dictionary Metadata = new() + { + ["hostname"] = "pc-042", + ["fqdn"] = "pc-042.corp.example", + ["domain"] = "corp.example", + ["os_name"] = "Windows", + ["os_version"] = "10.0.26100", + ["arch"] = "x86_64", + ["agent_version"] = "2026.3.0", + ["machine_id"] = "4c4c4544-0042-3510-8052-b4c04f4a4e32", + }; + + // INTENT.agent-identity.md, "Enrollment": the placeholders are `{}` and `{token_name}`, + // the enrollment token's name; CONTRACT.md §3 lists the known metadata keys. + [Theory] + [InlineData("{hostname}", "pc-042")] + [InlineData("{fqdn}", "pc-042.corp.example")] + [InlineData("{domain}", "corp.example")] + [InlineData("{os_name}", "Windows")] + [InlineData("{os_version}", "10.0.26100")] + [InlineData("{arch}", "x86_64")] + [InlineData("{agent_version}", "2026.3.0")] + [InlineData("{machine_id}", "4c4c4544-0042-3510-8052-b4c04f4a4e32")] + [InlineData("{token_name}", "Fleet")] + public void EvaluatesEachPlaceholderToItsValue(string format, string expected) + { + Assert.Equal(expected, Evaluate(format)); + } + + // INTENT.agent-identity.md, "Enrollment": `{{` and `}}` escape braces, and other text is literal. + [Theory] + [InlineData("{{", "{")] + [InlineData("}}", "}")] + [InlineData("}}{{", "}{")] + [InlineData("{{hostname}}", "{hostname}")] + [InlineData("{{{hostname}}}", "{pc-042}")] + [InlineData("Laptop {hostname} ({os_name}, {arch})", "Laptop pc-042 (Windows, x86_64)")] + [InlineData("{token_name}/{hostname}{hostname}", "Fleet/pc-042pc-042")] + public void EvaluatesLiteralTextAndEscapedBraces(string format, string expected) + { + Assert.Equal(expected, Evaluate(format)); + } + + // INTENT.agent-identity.md, "Enrollment": a format with an unpaired brace or an unknown placeholder is rejected. + // A placeholder names a known metadata key of CONTRACT.md §3 or `token_name` exactly, + // so another case, white space or another key, such as one that §3 allows but does not list, is unknown. + [Theory] + [InlineData("{")] + [InlineData("}")] + [InlineData("{hostname")] + [InlineData("hostname}")] + [InlineData("{hostname}}")] + [InlineData("{{hostname}")] + [InlineData("{{hostname}}}")] + [InlineData("{{}")] + [InlineData("}{")] + [InlineData("{}")] + [InlineData("{Hostname}")] + [InlineData("{HOSTNAME}")] + [InlineData("{ hostname }")] + [InlineData("{hostname }")] + [InlineData("{host{name}")] + [InlineData("{token}")] + [InlineData("{name}")] + [InlineData("{custom_key}")] + [InlineData("{0}")] + public void TryParseRejectsAnUnpairedBraceOrAnUnknownPlaceholder(string format) + { + Assert.False(FriendlyNameFormat.TryParse(format, out FriendlyNameFormat? parsed, out string? error)); + Assert.Null(parsed); + Assert.False(string.IsNullOrWhiteSpace(error)); + } + + // INTENT.agent-identity.md, "Enrollment": a missing metadata value evaluates to empty. + [Fact] + public void EvaluatesAMissingMetadataValueToEmpty() + { + Dictionary metadata = new() { ["hostname"] = "pc-042", ["arch"] = "x86_64" }; + + Assert.Equal("pc-042..x86_64", Evaluate("{hostname}.{domain}.{arch}", metadata)); + } + + // `{token_name}` is the enrollment token's name, + // and CONTRACT.md §3 lets an agent report an unknown key such as `token_name`, which is never used for anything. + [Fact] + public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue() + { + Dictionary metadata = new(Metadata) { ["token_name"] = "Reported" }; + + Assert.Equal("Fleet", Evaluate("{token_name}", metadata)); + } + + // A value is substituted once, so braces that a device reports are text, not placeholders. + [Fact] + public void EvaluatesBracesInAValueAsText() + { + Dictionary metadata = new() { ["hostname"] = "{token_name}}}" }; + + Assert.Equal("{token_name}}}", Evaluate("{hostname}", metadata)); + } + + // The White_Space code points of Unicode PropList.txt (18.0.0): + // 0009..000D, 0020, 0085, 00A0, 1680, 2000..200A, 2028, 2029, 202F, 205F and 3000; 25 code points in all. + private static readonly int[] WhiteSpace = + [ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, 0x1680, + 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, + 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, + ]; + + public static TheoryData WhiteSpaceRows => new(WhiteSpace); + + // INTENT.agent-identity.md, "Enrollment": the result is trimmed of Unicode `White_Space`. + [Theory] + [MemberData(nameof(WhiteSpaceRows))] + public void TrimsAWhiteSpaceCodePointFromBothEnds(int codePoint) + { + string space = char.ConvertFromUtf32(codePoint); + + Assert.Equal("pc 042", Evaluate("{token_name}", tokenName: space + space + "pc 042" + space)); + } + + // No code point outside `White_Space` is trimmed, such as U+180E MONGOLIAN VOWEL SEPARATOR, + // which left `White_Space` in Unicode 6.3, U+200B ZERO WIDTH SPACE and U+FEFF ZERO WIDTH NO-BREAK SPACE. + // Every other scalar value of the Basic Multilingual Plane is checked, and a few beyond it. + [Fact] + public void KeepsEveryOtherCodePointAtBothEnds() + { + IEnumerable others = Enumerable.Range(0, 0x10000) + .Where(codePoint => !(0xD800 <= codePoint && codePoint <= 0xDFFF) && !WhiteSpace.Contains(codePoint)) + .Concat([0x1F600, 0xE0020, 0x10FFFF]); + + List trimmed = []; + + foreach (int codePoint in others) + { + string text = char.ConvertFromUtf32(codePoint); + + if (Evaluate("{token_name}", tokenName: text + "x" + text) != text + "x" + text) + { + trimmed.Add($"U+{codePoint:X4}"); + } + } + + Assert.Empty(trimmed); + } + + // INTENT.agent-identity.md, "Enrollment": the result is truncated to 255 Unicode scalar values + // without splitting one. + // U+1F600 is one scalar value that UTF-16 writes as a surrogate pair, so a cut by UTF-16 code unit would split it, + // and U+0301 COMBINING ACUTE ACCENT is a scalar value of its own. + public static TheoryData TruncationRows => new() + { + { new string('a', 255), new string('a', 255) }, + { new string('a', 256), new string('a', 255) }, + { new string('a', 254) + "\U0001F600b", new string('a', 254) + "\U0001F600" }, + { new string('a', 255) + "\U0001F600", new string('a', 255) }, + { Repeat("\U0001F600", 300), Repeat("\U0001F600", 255) }, + { Repeat("e\u0301", 200), Repeat("e\u0301", 127) + "e" }, + }; + + [Theory] + [MemberData(nameof(TruncationRows))] + public void TruncatesTo255ScalarValues(string tokenName, string expected) + { + Assert.Equal(expected, Evaluate("{token_name}", tokenName: tokenName)); + } + + // INTENT.agent-identity.md, "Enrollment": the result is trimmed, truncated and trimmed again, + // so a stored name never starts or ends with white space. + [Fact] + public void TrimsTheWhiteSpaceThatTruncationLeavesAtTheEnd() + { + string tokenName = new string('a', 250) + new string(' ', 10) + "bbbbb"; + + Assert.Equal(new string('a', 250), Evaluate("{token_name}", tokenName: tokenName)); + } + + // Leading white space is trimmed before truncation, so it never takes the place of a kept scalar value. + [Fact] + public void TrimsBeforeTruncating() + { + string tokenName = "\u3000" + new string(' ', 9) + new string('a', 255); + + Assert.Equal(new string('a', 255), Evaluate("{token_name}", tokenName: tokenName)); + } + + // INTENT.agent-identity.md, "Enrollment": an empty result falls back to the identity ID, + // which CONTRACT.md §1 writes as a lowercase hyphenated UUID. + [Theory] + [InlineData("{fqdn}", TokenName)] + [InlineData("", TokenName)] + [InlineData(" \t\u00A0", TokenName)] + [InlineData("{token_name}", "\u2028\u3000")] + public void FallsBackToTheIdentityIdForAnEmptyName(string format, string tokenName) + { + Dictionary metadata = new() { ["hostname"] = "pc-042" }; + + Assert.Equal("0f8fad5b-d9cb-469f-a165-70867728950e", Evaluate(format, metadata, tokenName)); + } + + // A lone surrogate is no Unicode scalar value, so it counts as one U+FFFD REPLACEMENT CHARACTER, + // which Unicode §3.9 recommends for an ill-formed code unit, and the name stays well-formed UTF-16. + [Fact] + public void ReplacesALoneSurrogateWithTheReplacementCharacter() + { + Assert.Equal("\uFFFDpc", Evaluate("{token_name}", tokenName: "\uD83Dpc")); + Assert.Equal("pc\uFFFD", Evaluate("{token_name}", tokenName: "pc\uDE00")); + Assert.Equal("\uFFFD\uFFFD", Evaluate("{token_name}", tokenName: "\uDE00\uD83D")); + Assert.Equal("pc\uFFFD", Evaluate("pc\uD83D", tokenName: string.Empty)); + } + + // INTENT.agent-identity.md, "Enrollment": the default format is `{hostname}`. + [Fact] + public void DefaultFormatIsTheHostname() + { + Assert.Equal("{hostname}", FriendlyNameFormat.Default.ToString()); + Assert.Equal("pc-042", FriendlyNameFormat.Default.Evaluate(Metadata, TokenName, IdentityId)); + } + + // The authority stores the format text with the token and parses it again at each enrollment. + [Fact] + public void ToStringReturnsTheParsedText() + { + const string format = "{{{token_name}}} {hostname}"; + + Assert.True(FriendlyNameFormat.TryParse(format, out FriendlyNameFormat? parsed, out _)); + Assert.Equal(format, parsed.ToString()); + } + + [Fact] + public void TryParseRejectsANullFormat() + { + ArgumentNullException exception = Assert.Throws( + () => FriendlyNameFormat.TryParse(null!, out _, out _)); + + Assert.Equal("format", exception.ParamName); + } + + [Fact] + public void EvaluateRejectsNullMetadata() + { + ArgumentNullException exception = Assert.Throws( + () => FriendlyNameFormat.Default.Evaluate(null!, TokenName, IdentityId)); + + Assert.Equal("metadata", exception.ParamName); + } + + [Fact] + public void EvaluateRejectsANullTokenName() + { + ArgumentNullException exception = Assert.Throws( + () => FriendlyNameFormat.Default.Evaluate(Metadata, null!, IdentityId)); + + Assert.Equal("tokenName", exception.ParamName); + } + + private static string Evaluate( + string format, + IReadOnlyDictionary? metadata = null, + string tokenName = TokenName) + { + Assert.True(FriendlyNameFormat.TryParse(format, out FriendlyNameFormat? parsed, out string? error), error); + + return parsed.Evaluate(metadata ?? Metadata, tokenName, IdentityId); + } + + private static string Repeat(string text, int count) + { + return string.Concat(Enumerable.Repeat(text, count)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs new file mode 100644 index 0000000000..0493d820f1 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs @@ -0,0 +1,200 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// The format from which the authority creates an identity's friendly name once, at enrollment. +/// +/// +/// A format is text with placeholders: {hostname}, {fqdn}, {domain}, {os_name}, +/// {os_version}, {arch}, {agent_version} and {machine_id} for the reported metadata, +/// and {token_name} for the name of the enrollment token. +/// {{ and }} write a literal brace. +/// The name is never sent to the agent, and later metadata updates never rename the identity. +/// +public sealed class FriendlyNameFormat +{ + // The known metadata keys of CONTRACT.md §3, and the token's name. + private static readonly string[] Placeholders = + ["hostname", "fqdn", "domain", "os_name", "os_version", "arch", "agent_version", "machine_id", "token_name"]; + + private const string TokenNamePlaceholder = "token_name"; + + private const int MaxScalarValues = 255; + + private readonly string _format; + private readonly Segment[] _segments; + + private FriendlyNameFormat(string format, Segment[] segments) + { + _format = format; + _segments = segments; + } + + /// + /// The format that a token without one uses: {hostname}. + /// + public static FriendlyNameFormat Default { get; } = new("{hostname}", [new Segment("hostname", true)]); + + /// + /// Parses a format, which the authority does when an administrator creates a token with it. + /// + /// The format text. + /// The format, when the text is valid. + /// + /// Why the text is invalid, for the administrator: an unpaired brace or an unknown placeholder. + /// + /// Whether the text is a valid format. + /// is null. + public static bool TryParse( + string format, + [NotNullWhen(true)] out FriendlyNameFormat? result, + [NotNullWhen(false)] out string? error) + { + ArgumentNullException.ThrowIfNull(format); + + List segments = []; + string? problem = ParseSegments(format, segments); + + if (problem is not null) + { + result = null; + error = problem; + + return false; + } + + result = new FriendlyNameFormat(format, [.. segments]); + error = null; + + return true; + } + + /// + /// Evaluates the friendly name of a new identity. + /// + /// + /// Each placeholder takes its value, and a missing metadata value is empty. + /// The result is trimmed of Unicode White_Space, + /// truncated to 255 Unicode scalar values without splitting one, and trimmed again, + /// so a name never starts or ends with white space. + /// A lone surrogate is no scalar value, so it becomes U+FFFD REPLACEMENT CHARACTER. + /// An empty result falls back to the identity ID. + /// + /// The metadata that the enroll request reports. + /// The name of the token that the request presents. + /// The ID of the new identity. + /// The friendly name, or the identity ID in lowercase hyphenated form. + /// + /// or is null. + /// + public string Evaluate(IReadOnlyDictionary metadata, string tokenName, Guid identityId) + { + ArgumentNullException.ThrowIfNull(metadata); + ArgumentNullException.ThrowIfNull(tokenName); + + StringBuilder text = new(); + + foreach (Segment segment in _segments) + { + if (!segment.IsPlaceholder) + { + text.Append(segment.Text); + } + else if (segment.Text == TokenNamePlaceholder) + { + text.Append(tokenName); + } + else if (metadata.TryGetValue(segment.Text, out string? value)) + { + text.Append(value); + } + } + + string name = Truncate(text.ToString().Trim()).Trim(); + + return name.Length == 0 ? identityId.ToString("D") : name; + } + + /// + /// Returns the format text, which the authority stores with the token. + /// + public override string ToString() + { + return _format; + } + + // Adds the segments of the format, or returns why the format is invalid. + private static string? ParseSegments(string format, List segments) + { + StringBuilder literal = new(); + int position = 0; + + while (position < format.Length) + { + char c = format[position]; + + if ((c == '{' || c == '}') && position + 1 < format.Length && format[position + 1] == c) + { + literal.Append(c); + position += 2; + } + else if (c == '{') + { + int end = format.IndexOf('}', position + 1); + + if (end < 0) + { + return "The format has a '{' without a matching '}'; write '{{' for a literal '{'."; + } + + string name = format[(position + 1)..end]; + + if (!Placeholders.Contains(name, StringComparer.Ordinal)) + { + return "The format has the unknown placeholder '{" + name + "}'; the placeholders are " + + string.Join(", ", Placeholders.Select(placeholder => "{" + placeholder + "}")) + "."; + } + + segments.Add(new Segment(literal.ToString(), false)); + literal.Clear(); + segments.Add(new Segment(name, true)); + position = end + 1; + } + else if (c == '}') + { + return "The format has a '}' without a matching '{'; write '}}' for a literal '}'."; + } + else + { + literal.Append(c); + position++; + } + } + + segments.Add(new Segment(literal.ToString(), false)); + + return null; + } + + // Keeps the first 255 Unicode scalar values, so that a surrogate pair is kept or dropped whole. + private static string Truncate(ReadOnlySpan text) + { + StringBuilder truncated = new(); + Span utf16 = stackalloc char[2]; + + for (int count = 0; count < MaxScalarValues && !text.IsEmpty; count++) + { + // An invalid code unit decodes to U+FFFD and is consumed alone. + Rune.DecodeFromUtf16(text, out Rune scalar, out int consumed); + truncated.Append(utf16[..scalar.EncodeToUtf16(utf16)]); + text = text[consumed..]; + } + + return truncated.ToString(); + } + + // Literal text, or the name of a placeholder. + private readonly record struct Segment(string Text, bool IsPlaceholder); +} From 474a95716fc923f539ae0bf54db6b2bfa7fc0124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:01:33 +0900 Subject: [PATCH 17/93] fix(dotnet-agent-identity): start certificate statuses at 1 A default-initialized CertificateStatus was Pending, so a host that never set a status passed IssuedKeyMatch.ForIdentity a valid one. Number the statuses from 1, like the library's other public enums, so that an uninitialized status is undefined and ForIdentity rejects it. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EnrollmentRulesTests.cs | 12 +++++++++++- .../Devolutions.AgentIdentity/CertificateStatus.cs | 11 ++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs index e213a372c7..dd3f05f480 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs @@ -235,7 +235,17 @@ public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, public void KeyMatchRejectsAnUndefinedCertificateStatus() { ArgumentOutOfRangeException exception = Assert.Throws( - () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, (CertificateStatus)3)); + () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, (CertificateStatus)4)); + + Assert.Equal("status", exception.ParamName); + } + + // Statuses start at 1, so a status that the host never set is not a valid one. + [Fact] + public void KeyMatchRejectsAnUninitializedCertificateStatus() + { + ArgumentOutOfRangeException exception = Assert.Throws( + () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, default)); Assert.Equal("status", exception.ParamName); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs index 3a6f166abe..8d5863a4b9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs @@ -5,18 +5,23 @@ namespace Devolutions.AgentIdentity; /// public enum CertificateStatus { + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a status would change what an existing host reports. + // When adding a status, give it the next unused value; + // values start at 1 so that an uninitialized status is not a valid one. + /// /// Issued by renew and not yet confirmed. /// - Pending, + Pending = 1, /// /// The certificate the identity authenticates with. /// - Current, + Current = 2, /// /// Replaced by a confirmed certificate. /// - Retired, + Retired = 3, } From 527354802b2be1952b6e0668d71be4eaa295a400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:04:30 +0900 Subject: [PATCH 18/93] fix(dotnet-agent-identity): keep format text out of logs A friendly-name format is administrator input that may hold a pasted enrollment token, and tokens never appear in logs, not even partially. A parse error repeated an unknown placeholder, and ToString returned the whole format, so either could carry a token into a log. A parse error now says what is wrong and at which character, counted in Unicode scalar values from 1, and lists the valid placeholders, without any part of the format text. ToString returns the type name, and the host stores the text that it parsed. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FriendlyNameFormatTests.cs | 90 ++++++++++++++++--- .../FriendlyNameFormat.cs | 58 +++++++++--- 2 files changed, 124 insertions(+), 24 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs index 57be5a5652..594c3f4549 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs @@ -7,6 +7,19 @@ public class FriendlyNameFormatTests private const string TokenName = "Fleet"; + // A dvaet1 token for https://host/dvls (CONTRACT.md §2), which an administrator might paste into a format. + private const string Bag = "eyJ1IjoiaHR0cHM6Ly9ob3N0L2R2bHMifQ"; + private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + private const string Token = "dvaet1." + Bag + "." + Secret; + + // The placeholders of INTENT.agent-identity.md, "Enrollment": the known metadata keys of CONTRACT.md §3, + // and the token's name. + private static readonly string[] Placeholders = + [ + "{hostname}", "{fqdn}", "{domain}", "{os_name}", "{os_version}", "{arch}", "{agent_version}", "{machine_id}", + "{token_name}", + ]; + // A value for each known metadata key of CONTRACT.md §3. private static readonly Dictionary Metadata = new() { @@ -81,6 +94,67 @@ public void TryParseRejectsAnUnpairedBraceOrAnUnknownPlaceholder(string format) Assert.False(string.IsNullOrWhiteSpace(error)); } + // The error says where the problem is, counting Unicode scalar values from 1, + // so that U+1F600, which UTF-16 writes as two code units, counts as one character. + [Theory] + [InlineData("{", 1)] + [InlineData("ab}", 3)] + [InlineData("{hostname} {", 12)] + [InlineData("{{x}}}", 6)] + [InlineData("x{Hostname}", 2)] + [InlineData("\U0001F600{", 2)] + [InlineData("\U0001F600\U0001F600 {Hostname}", 4)] + public void TryParseErrorGivesThePositionOfTheProblem(string format, int position) + { + Assert.False(FriendlyNameFormat.TryParse(format, out _, out string? error)); + Assert.Matches($@"\bcharacter {position}\b", error); + } + + // Every error lists the placeholders, so that the administrator can correct the format. + [Theory] + [InlineData("{")] + [InlineData("}")] + [InlineData("{Hostname}")] + public void TryParseErrorListsThePlaceholders(string format) + { + Assert.False(FriendlyNameFormat.TryParse(format, out _, out string? error)); + + foreach (string placeholder in Placeholders) + { + Assert.Contains(placeholder, error, StringComparison.Ordinal); + } + } + + // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially. + // A format is administrator input that may hold a pasted token, and a host may log why a format was refused. + // Each row holds the token in an unknown placeholder, or as literal text before each kind of error. + public static TheoryData FormatsHoldingAToken => new() + { + "{" + Token + "}", + "{hostname} " + Token + " {Hostname}", + "{hostname} " + Token + " {", + Token + "}", + }; + + [Theory] + [MemberData(nameof(FormatsHoldingAToken))] + public void TryParseErrorRevealsNoPartOfTheFormat(string format) + { + Assert.False(FriendlyNameFormat.TryParse(format, out _, out string? error)); + EnrollmentTokenAssert.RevealsNoPart(error, Bag, Secret); + } + + // A log statement may format the object of a valid format that holds a pasted token as literal text. + [Fact] + public void ToStringRevealsNoPartOfTheFormat() + { + Assert.True( + FriendlyNameFormat.TryParse("{hostname} " + Token, out FriendlyNameFormat? parsed, out string? error), + error); + + EnrollmentTokenAssert.RevealsNoPart($"{parsed}", Bag, Secret); + } + // INTENT.agent-identity.md, "Enrollment": a missing metadata value evaluates to empty. [Fact] public void EvaluatesAMissingMetadataValueToEmpty() @@ -224,18 +298,12 @@ public void ReplacesALoneSurrogateWithTheReplacementCharacter() [Fact] public void DefaultFormatIsTheHostname() { - Assert.Equal("{hostname}", FriendlyNameFormat.Default.ToString()); - Assert.Equal("pc-042", FriendlyNameFormat.Default.Evaluate(Metadata, TokenName, IdentityId)); - } - - // The authority stores the format text with the token and parses it again at each enrollment. - [Fact] - public void ToStringReturnsTheParsedText() - { - const string format = "{{{token_name}}} {hostname}"; + Dictionary withoutHostname = new() { ["fqdn"] = "pc-042.corp.example" }; - Assert.True(FriendlyNameFormat.TryParse(format, out FriendlyNameFormat? parsed, out _)); - Assert.Equal(format, parsed.ToString()); + Assert.Equal("pc-042", FriendlyNameFormat.Default.Evaluate(Metadata, TokenName, IdentityId)); + Assert.Equal( + "0f8fad5b-d9cb-469f-a165-70867728950e", + FriendlyNameFormat.Default.Evaluate(withoutHostname, TokenName, IdentityId)); } [Fact] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs index 0493d820f1..aad31c97a7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; namespace Devolutions.AgentIdentity; @@ -7,11 +8,18 @@ namespace Devolutions.AgentIdentity; /// The format from which the authority creates an identity's friendly name once, at enrollment. /// /// +/// /// A format is text with placeholders: {hostname}, {fqdn}, {domain}, {os_name}, /// {os_version}, {arch}, {agent_version} and {machine_id} for the reported metadata, /// and {token_name} for the name of the enrollment token. /// {{ and }} write a literal brace. /// The name is never sent to the agent, and later metadata updates never rename the identity. +/// +/// +/// A format is administrator input that may hold a pasted token, which never appears in logs, +/// so no member returns any part of the format text. +/// The host stores the text that it parsed with the token. +/// /// public sealed class FriendlyNameFormat { @@ -19,23 +27,27 @@ public sealed class FriendlyNameFormat private static readonly string[] Placeholders = ["hostname", "fqdn", "domain", "os_name", "os_version", "arch", "agent_version", "machine_id", "token_name"]; + // Ends every parse error without repeating the format text. + private static readonly string ErrorGuidance = + " The placeholders are " + + string.Join(", ", Placeholders[..^1].Select(placeholder => "{" + placeholder + "}")) + + " and {" + Placeholders[^1] + "}, and '{{' and '}}' write literal braces."; + private const string TokenNamePlaceholder = "token_name"; private const int MaxScalarValues = 255; - private readonly string _format; private readonly Segment[] _segments; - private FriendlyNameFormat(string format, Segment[] segments) + private FriendlyNameFormat(Segment[] segments) { - _format = format; _segments = segments; } /// - /// The format that a token without one uses: {hostname}. + /// The format of a token created without one: {hostname}. /// - public static FriendlyNameFormat Default { get; } = new("{hostname}", [new Segment("hostname", true)]); + public static FriendlyNameFormat Default { get; } = new([new Segment("hostname", true)]); /// /// Parses a format, which the authority does when an administrator creates a token with it. @@ -43,7 +55,9 @@ private FriendlyNameFormat(string format, Segment[] segments) /// The format text. /// The format, when the text is valid. /// - /// Why the text is invalid, for the administrator: an unpaired brace or an unknown placeholder. + /// Why the text is invalid, for the administrator: an unpaired brace or an unknown placeholder, + /// its position counted in Unicode scalar values from 1, and the valid placeholders. + /// The error holds no part of the format text. /// /// Whether the text is a valid format. /// is null. @@ -65,7 +79,7 @@ public static bool TryParse( return false; } - result = new FriendlyNameFormat(format, [.. segments]); + result = new FriendlyNameFormat([.. segments]); error = null; return true; @@ -118,11 +132,11 @@ public string Evaluate(IReadOnlyDictionary metadata, string toke } /// - /// Returns the format text, which the authority stores with the token. + /// Returns the type name, never the format text, which may hold a pasted token. /// public override string ToString() { - return _format; + return nameof(FriendlyNameFormat); } // Adds the segments of the format, or returns why the format is invalid. @@ -146,15 +160,14 @@ public override string ToString() if (end < 0) { - return "The format has a '{' without a matching '}'; write '{{' for a literal '{'."; + return ParseError("an unpaired '{'", format, position); } string name = format[(position + 1)..end]; if (!Placeholders.Contains(name, StringComparer.Ordinal)) { - return "The format has the unknown placeholder '{" + name + "}'; the placeholders are " - + string.Join(", ", Placeholders.Select(placeholder => "{" + placeholder + "}")) + "."; + return ParseError("an unknown placeholder", format, position); } segments.Add(new Segment(literal.ToString(), false)); @@ -164,7 +177,7 @@ public override string ToString() } else if (c == '}') { - return "The format has a '}' without a matching '{'; write '}}' for a literal '}'."; + return ParseError("an unpaired '}'", format, position); } else { @@ -178,6 +191,25 @@ public override string ToString() return null; } + // Says what is wrong and where, without any part of the format text. + // The position counts Unicode scalar values from 1, as an administrator counts characters, + // and a lone surrogate counts as one. + private static string ParseError(string problem, string format, int index) + { + int character = 1; + ReadOnlySpan before = format.AsSpan(0, index); + + while (!before.IsEmpty) + { + Rune.DecodeFromUtf16(before, out _, out int consumed); + before = before[consumed..]; + character++; + } + + return "The format has " + problem + " at character " + character.ToString(CultureInfo.InvariantCulture) + "." + + ErrorGuidance; + } + // Keeps the first 255 Unicode scalar values, so that a surrogate pair is kept or dropped whole. private static string Truncate(ReadOnlySpan text) { From 8638587b4e73efa6f979481e9e9a80f67030379a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:12:40 +0900 Subject: [PATCH 19/93] test(dotnet-agent-identity): refuse the default certificate status Certificate statuses start at 1, so 3 is Retired, a defined status. The test for an undefined status checks the default value, which an uninitialized status holds, and 4, which follows the last status. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RegisteredCertificateTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs index 2f6b0f3943..b5d7ba529d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs @@ -4,11 +4,13 @@ public class RegisteredCertificateTests { // An undefined status from the host's storage must fail as a host bug, // rather than draw identity_unknown, which ends the identity for the agent. - [Fact] - public void RefusesAnUndefinedStatus() + // The default is the value of an uninitialized status, and 4 follows the last of the three statuses. + [Theory] + [InlineData(default(CertificateStatus))] + [InlineData((CertificateStatus)4)] + public void RefusesAnUndefinedStatus(CertificateStatus undefined) { KeyVector key = TestVectors.Instance.Key("device-a"); - CertificateStatus undefined = (CertificateStatus)3; Assert.Throws( () => new RegisteredCertificate(key.IdentityId, key.Certificate, undefined, identityRevoked: false)); From 1bbe991387601f227c64b6683902dd14ba19aca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:14:14 +0900 Subject: [PATCH 20/93] fix(dotnet-agent-identity): name identities from validated metadata FriendlyNameFormat.Evaluate took any string dictionary, so a host could name an identity from metadata that never passed the protocol's limits on keys and values. Evaluate now takes AgentMetadata, which only validation and the request body parsers create. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FriendlyNameFormatTests.cs | 30 ++++++++++++------- .../FriendlyNameFormat.cs | 4 +-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs index 594c3f4549..80f7a860ae 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs @@ -21,7 +21,7 @@ public class FriendlyNameFormatTests ]; // A value for each known metadata key of CONTRACT.md §3. - private static readonly Dictionary Metadata = new() + private static readonly AgentMetadata Metadata = Validated(new Dictionary { ["hostname"] = "pc-042", ["fqdn"] = "pc-042.corp.example", @@ -31,7 +31,7 @@ public class FriendlyNameFormatTests ["arch"] = "x86_64", ["agent_version"] = "2026.3.0", ["machine_id"] = "4c4c4544-0042-3510-8052-b4c04f4a4e32", - }; + }); // INTENT.agent-identity.md, "Enrollment": the placeholders are `{}` and `{token_name}`, // the enrollment token's name; CONTRACT.md §3 lists the known metadata keys. @@ -159,7 +159,8 @@ public void ToStringRevealsNoPartOfTheFormat() [Fact] public void EvaluatesAMissingMetadataValueToEmpty() { - Dictionary metadata = new() { ["hostname"] = "pc-042", ["arch"] = "x86_64" }; + AgentMetadata metadata = + Validated(new Dictionary { ["hostname"] = "pc-042", ["arch"] = "x86_64" }); Assert.Equal("pc-042..x86_64", Evaluate("{hostname}.{domain}.{arch}", metadata)); } @@ -169,7 +170,7 @@ public void EvaluatesAMissingMetadataValueToEmpty() [Fact] public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue() { - Dictionary metadata = new(Metadata) { ["token_name"] = "Reported" }; + AgentMetadata metadata = Validated(new Dictionary(Metadata) { ["token_name"] = "Reported" }); Assert.Equal("Fleet", Evaluate("{token_name}", metadata)); } @@ -178,7 +179,7 @@ public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue() [Fact] public void EvaluatesBracesInAValueAsText() { - Dictionary metadata = new() { ["hostname"] = "{token_name}}}" }; + AgentMetadata metadata = Validated(new Dictionary { ["hostname"] = "{token_name}}}" }); Assert.Equal("{token_name}}}", Evaluate("{hostname}", metadata)); } @@ -278,13 +279,14 @@ public void TrimsBeforeTruncating() [InlineData("{token_name}", "\u2028\u3000")] public void FallsBackToTheIdentityIdForAnEmptyName(string format, string tokenName) { - Dictionary metadata = new() { ["hostname"] = "pc-042" }; + AgentMetadata metadata = Validated(new Dictionary { ["hostname"] = "pc-042" }); Assert.Equal("0f8fad5b-d9cb-469f-a165-70867728950e", Evaluate(format, metadata, tokenName)); } // A lone surrogate is no Unicode scalar value, so it counts as one U+FFFD REPLACEMENT CHARACTER, // which Unicode §3.9 recommends for an ill-formed code unit, and the name stays well-formed UTF-16. + // Validated metadata holds no lone surrogate, but a token name or the format text can. [Fact] public void ReplacesALoneSurrogateWithTheReplacementCharacter() { @@ -298,7 +300,7 @@ public void ReplacesALoneSurrogateWithTheReplacementCharacter() [Fact] public void DefaultFormatIsTheHostname() { - Dictionary withoutHostname = new() { ["fqdn"] = "pc-042.corp.example" }; + AgentMetadata withoutHostname = Validated(new Dictionary { ["fqdn"] = "pc-042.corp.example" }); Assert.Equal("pc-042", FriendlyNameFormat.Default.Evaluate(Metadata, TokenName, IdentityId)); Assert.Equal( @@ -333,16 +335,22 @@ public void EvaluateRejectsANullTokenName() Assert.Equal("tokenName", exception.ParamName); } - private static string Evaluate( - string format, - IReadOnlyDictionary? metadata = null, - string tokenName = TokenName) + private static string Evaluate(string format, AgentMetadata? metadata = null, string tokenName = TokenName) { Assert.True(FriendlyNameFormat.TryParse(format, out FriendlyNameFormat? parsed, out string? error), error); return parsed.Evaluate(metadata ?? Metadata, tokenName, IdentityId); } + // Evaluate takes only validated metadata, as the request body parsers produce it. + private static AgentMetadata Validated(IEnumerable> entries) + { + MetadataValidationResult result = AgentMetadata.Validate(entries); + Assert.True(result.Succeeded, result.FailureDetail); + + return result.Metadata; + } + private static string Repeat(string text, int count) { return string.Concat(Enumerable.Repeat(text, count)); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs index aad31c97a7..a08d42ab0e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs @@ -96,14 +96,14 @@ public static bool TryParse( /// A lone surrogate is no scalar value, so it becomes U+FFFD REPLACEMENT CHARACTER. /// An empty result falls back to the identity ID. /// - /// The metadata that the enroll request reports. + /// The metadata that the enroll request reports, as its body parser validated it. /// The name of the token that the request presents. /// The ID of the new identity. /// The friendly name, or the identity ID in lowercase hyphenated form. /// /// or is null. /// - public string Evaluate(IReadOnlyDictionary metadata, string tokenName, Guid identityId) + public string Evaluate(AgentMetadata metadata, string tokenName, Guid identityId) { ArgumentNullException.ThrowIfNull(metadata); ArgumentNullException.ThrowIfNull(tokenName); From fa13c074a9f7f350a3fb7c26d454460849efa9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:15:06 +0900 Subject: [PATCH 21/93] docs(dotnet-agent-identity): qualify the format text guarantee The FriendlyNameFormat remarks said that no member returns any part of the format text, but Evaluate returns its literal text as part of the friendly name. Limit the guarantee to parse errors and ToString. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity/FriendlyNameFormat.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs index a08d42ab0e..917c7b8cc7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs @@ -17,7 +17,8 @@ namespace Devolutions.AgentIdentity; /// /// /// A format is administrator input that may hold a pasted token, which never appears in logs, -/// so no member returns any part of the format text. +/// so a parse error and never hold any part of the format text. +/// Only returns its literal text, by design, as part of the friendly name. /// The host stores the text that it parsed with the token. /// /// From c78c61c85684097e828fe2b13211423c0e24f9a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:41:55 +0900 Subject: [PATCH 22/93] test(dotnet-agent-identity): accept an empty extensionRequest The contract now has the server ignore a CSR's attributes, including an extensionRequest with an empty Extensions list, and the valid CSR vector will be regenerated with an empty attribute set. Add tests, built without the vector file, that accept a CSR whose attributes hold an empty extensionRequest and one with an empty attribute set. The tests that alter the valid vector now check that it is accepted and locate its outer length with System.Formats.Asn1, so they hold no octet or length of it. The non-canonical base64 tests use requests built to have pad bits and both + and /, which a regenerated vector may lack. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CertificateSigningRequestTests.cs | 77 +++++++++++++--- .../RequestBodyTests.cs | 92 +++++++++++++++---- .../TestCsr.cs | 16 +++- 3 files changed, 152 insertions(+), 33 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs index 53c71603a3..4be81dd175 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs @@ -26,7 +26,8 @@ public void CsrVectorHasItsExpectedOutcome(string name) public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() { CsrVector vector = TestVectors.Instance.Csr["valid"]; - byte[] expected = [.. P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1!]; + Assert.NotNull(vector.PublicKeySec1); + byte[] expected = [.. P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1]; CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(vector.Csr)); @@ -46,7 +47,7 @@ public void AcceptsRequestWrittenByDotNet() Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } - // CONTRACT.md §4: the server ignores the CSR subject and extensions, + // CONTRACT.md §4: the server ignores the CSR's subject and attributes, including requested extensions, // and security invariant 2 lets a CSR supply only a verified public key. [Fact] public void AcceptsAnySubjectAttributesAndExtensions() @@ -66,6 +67,37 @@ public void AcceptsAnySubjectAttributesAndExtensions() Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } + // CONTRACT.md §4: the agent's CSR carries an empty attribute set, A0 00. + [Fact] + public void AcceptsEmptyAttributeSet() + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: [0xA0, 0x00]); + + CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + + Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); + } + + // CONTRACT.md §4: the server ignores an extensionRequest with an empty Extensions list, + // which common CSR builders emit although RFC 5280 §4.1 gives Extensions SIZE (1..MAX). + // The attribute is the PKCS #9 extensionRequest (RFC 2985 §5.4.2) with one value, an empty SEQUENCE. + [Fact] + public void AcceptsExtensionRequestWithEmptyExtensions() + { + using ECDsa key = NewKey(); + byte[] extensionRequest = TestCsr.Sequence( + TestCsr.Oid("1.2.840.113549.1.9.14"), + TestCsr.Tlv(0x31, TestCsr.Sequence())); + byte[] info = TestCsr.RequestInfo( + key.ExportSubjectPublicKeyInfo(), + attributes: TestCsr.Tlv(0xA0, extensionRequest)); + + CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + + Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); + } + [Theory] [InlineData("")] [InlineData("00")] @@ -80,30 +112,28 @@ public void RejectsBytesThatAreNotARequest(string hex) [Fact] public void RejectsTrailingBytes() { - byte[] valid = TestVectors.Instance.Csr["valid"].Csr; + byte[] valid = AcceptedValidVector(); AssertInvalid(CertificateSigningRequest.Validate([.. valid, 0x00])); } // X.690 §10.1: DER writes each length in the fewest octets. - // The vector request is 223 octets long, which DER writes 81 DF; - // its self-signature covers only the request info, so it still verifies. + // The self-signature covers only the request info, so the request still verifies with a longer outer length. [Fact] public void RejectsLengthInMoreOctetsThanNeeded() { - byte[] valid = TestVectors.Instance.Csr["valid"].Csr; - Assert.Equal([0x30, 0x81, 0xDF], valid[..3]); + byte[] contents = ValidVectorContents(); - AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x82, 0x00, 0xDF, .. valid[3..]])); + AssertInvalid(CertificateSigningRequest.Validate(TestCsr.TlvWithLongerLength(0x30, contents))); } // X.690 §10.1: DER uses the definite form of length. [Fact] public void RejectsIndefiniteLength() { - byte[] valid = TestVectors.Instance.Csr["valid"].Csr; + byte[] contents = ValidVectorContents(); - AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x80, .. valid[3..], 0x00, 0x00])); + AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x80, .. contents, 0x00, 0x00])); } // The same rule inside the request info, which a test can only change by signing the request again. @@ -111,7 +141,8 @@ public void RejectsIndefiniteLength() public void RejectsLengthInMoreOctetsThanNeededInsideTheRequestInfo() { using ECDsa key = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject: [0x30, 0x81, 0x00]); + byte[] subject = TestCsr.TlvWithLongerLength(0x30, []); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject); AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); } @@ -462,6 +493,30 @@ private static void AssertInvalid(CsrValidationResult result) Assert.Null(result.Csr); } + // The valid vector, checked to be accepted, so that only the rule a test breaks in it can reject the result. + private static byte[] AcceptedValidVector() + { + byte[] valid = TestVectors.Instance.Csr["valid"].Csr; + AssertValid(CertificateSigningRequest.Validate(valid)); + + return valid; + } + + // The contents of the valid vector's outer SEQUENCE, located by System.Formats.Asn1, + // so that a test depends on no octet or length of the vector. + private static byte[] ValidVectorContents() + { + byte[] valid = AcceptedValidVector(); + _ = AsnDecoder.ReadEncodedValue(valid, AsnEncodingRules.DER, out int offset, out int length, out _); + byte[] contents = valid[offset..(offset + length)]; + + // Wrapped again with the fewest length octets, the contents give back the vector, + // so a test that wraps them otherwise changes only the form of the length. + Assert.Equal(valid, TestCsr.Sequence(contents)); + + return contents; + } + private static ECDsa NewKey() { return ECDsa.Create(ECCurve.NamedCurves.nistP256); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs index b370521516..37788963a5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; namespace Devolutions.AgentIdentity.Tests; @@ -289,7 +290,8 @@ protected sealed override string Body(string? metadata, string extraMembers) public void ReturnsTheValidatedCsr() { CsrVector vector = TestVectors.Instance.Csr["valid"]; - byte[] expected = [.. CertificateSigningRequestTests.P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1!]; + Assert.NotNull(vector.PublicKeySec1); + byte[] expected = [.. CertificateSigningRequestTests.P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1]; CertificateSigningRequest csr = AssertValid(ParseText(Body(ValidMetadata, ""))).Csr!; @@ -322,33 +324,53 @@ public void RejectsCsrThatIsNotAString(string csr) } // CONTRACT.md §1: base64 in any form but the canonical one is invalid. - // Each form decodes, under a lenient decoder, to the valid request. - public static TheoryData NonCanonicalCsrs() + // Each test alters the canonical base64 of a request that the body accepts, + // into a form that a lenient decoder would still read as that request. + // The requests are built here rather than read from the vector file, + // so that each form applies to them whatever request the file holds. + [Theory] + [InlineData(1)] + [InlineData(2)] + public void RejectsCsrWithoutPadding(int octetsInFinalQuantum) + { + string canonical = CanonicalCsr(octetsInFinalQuantum); + + AssertOnlyCanonicalCsrIsValid(canonical, canonical.TrimEnd('=')); + } + + // RFC 4648 §3.5: the pad bits of the final quantum, four after one octet and two after two octets, are zero. + // The character before the padding holds them, so setting its lowest bit sets one pad bit. + [Theory] + [InlineData(1)] + [InlineData(2)] + public void RejectsCsrWithNonZeroPadBit(int octetsInFinalQuantum) { - // The request is 226 octets long, so its final quantum holds one octet, two padding characters, - // and four pad bits in the character before them, which the canonical form sets to zero. const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - string valid = ValidCsr; - Assert.EndsWith("==", valid); - char withPadBit = Alphabet[Alphabet.IndexOf(valid[^3]) | 1]; + string canonical = CanonicalCsr(octetsInFinalQuantum); + int last = canonical.TrimEnd('=').Length - 1; + char withPadBit = Alphabet[Alphabet.IndexOf(canonical[last]) | 1]; - return new TheoryData - { - { "missing padding", valid.TrimEnd('=') }, - { "non-zero pad bit", valid[..^3] + withPadBit + "==" }, - { "line break", valid[..64] + "\n" + valid[64..] }, - { "space", valid[..64] + " " + valid[64..] }, - { "base64url alphabet", valid.Replace('+', '-').Replace('/', '_') }, - }; + AssertOnlyCanonicalCsrIsValid(canonical, canonical[..last] + withPadBit + canonical[(last + 1)..]); } + // A lenient decoder skips white space, such as the line breaks that RFC 7468 places every 64 characters. [Theory] - [MemberData(nameof(NonCanonicalCsrs))] - public void RejectsCsrInNonCanonicalBase64(string form, string csr) + [InlineData("\n")] + [InlineData(" ")] + public void RejectsCsrWithWhiteSpace(string whiteSpace) + { + string canonical = CanonicalCsr(1); + + AssertOnlyCanonicalCsrIsValid(canonical, canonical[..64] + whiteSpace + canonical[64..]); + } + + // RFC 4648 §5: the base64url alphabet writes - and _ for the + and / of base64. + [Fact] + public void RejectsCsrInTheBase64UrlAlphabet() { - Assert.NotEqual(ValidCsr, csr); + string canonical = CanonicalCsr(1); - AssertInvalid(ParseText(BodyWithCsr(Quote(csr), ValidMetadata)), form); + AssertOnlyCanonicalCsrIsValid(canonical, canonical.Replace('+', '-').Replace('/', '_')); } [Fact] @@ -376,6 +398,36 @@ private static string BodyWithCsr(string? csr, string? metadata, string extraMem return JsonObject(csr is null ? null : $"\"csr\": {csr}", MetadataMember(metadata), extraMembers); } + // The canonical base64 of a request whose DER length leaves the given number of octets in the final quantum, + // and whose encoding uses both + and /. + // Each attempt signs another subject, so that the search does not rely on random signatures. + private static string CanonicalCsr(int octetsInFinalQuantum) + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + + for (int attempt = 0; attempt < 64; attempt++) + { + byte[] subject = new X500DistinguishedName($"CN=attempt {attempt}").RawData; + byte[] der = TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject), key); + string base64 = Convert.ToBase64String(der); + + if (der.Length % 3 == octetsInFinalQuantum && base64.Contains('+') && base64.Contains('/')) + { + return base64; + } + } + + throw new InvalidOperationException("No request met the conditions in 64 attempts."); + } + + private void AssertOnlyCanonicalCsrIsValid(string canonical, string altered) + { + Assert.NotEqual(canonical, altered); + AssertValid(ParseText(BodyWithCsr(Quote(canonical), ValidMetadata))); + + AssertInvalid(ParseText(BodyWithCsr(Quote(altered), ValidMetadata))); + } + // The base64 alphabet needs no escape in a JSON string; a line break does. private static string Quote(string text) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs index 86dac219cb..68bd3ec135 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs @@ -34,6 +34,18 @@ public static byte[] Tlv(byte tag, params byte[][] contents) return [tag, .. Length(body.Length), .. body]; } + /// + /// An element whose length takes one octet more than DER allows (X.690 §10.1): + /// the long form for a length below 128, or else a leading zero octet. + /// + public static byte[] TlvWithLongerLength(byte tag, byte[] contents) + { + byte[] length = Length(contents.Length); + byte[] longer = length.Length == 1 ? [0x81, length[0]] : [(byte)(0x80 | length.Length), 0x00, .. length[1..]]; + + return [tag, .. longer, .. contents]; + } + public static byte[] Sequence(params byte[][] elements) { return Tlv(0x30, elements); @@ -77,9 +89,9 @@ public static byte[] UncompressedPoint(ECDsa key) return [0x04, .. parameters.Q.X!, .. parameters.Q.Y!]; } - public static byte[] RequestInfo(byte[] subjectPublicKeyInfo, byte[]? subject = null) + public static byte[] RequestInfo(byte[] subjectPublicKeyInfo, byte[]? subject = null, byte[]? attributes = null) { - return Sequence(Integer(0), subject ?? EmptySubject, subjectPublicKeyInfo, EmptyAttributes); + return Sequence(Integer(0), subject ?? EmptySubject, subjectPublicKeyInfo, attributes ?? EmptyAttributes); } public static byte[] SignatureAlgorithm(string oid) From 6b36803dfa1b044504b76109b0c11358fd5b47c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:40:49 +0900 Subject: [PATCH 23/93] feat(dotnet-agent-identity): verify the agent channel handshake A signed channel opening never authenticates a stream on its own, so every authority must check the agent's first message against the stream's challenge the same way before it acts for the identity. ChannelOpening.VerifyHello requires a Hello whose correlation_id is the Challenge id and whose proof is a raw 64-byte P-256 signature, by the key of the certificate that opened the stream, over the proof domain, a zero byte, the challenge and the connect nonce. A message failing any of these is signature_invalid, as is HelloTimedOut, which the host calls when no message arrives within ChannelHandshake.HelloTimeout of 10 s; only then does invalid metadata draw invalid_request. Success yields the identity of the opening certificate for the connect operation, the validated metadata, and a Welcome that answers the Hello with the server time. The first call to either method ends the handshake, and any later call throws. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AuthenticationTypeTests.cs | 6 +- .../ChannelHandshakeTests.cs | 350 ++++++++++++++++++ .../ChannelHandshakeVectorTests.cs | 35 ++ .../TestDoubles.cs | 25 ++ .../VectorChannel.cs | 103 ++++++ .../AuthenticatedIdentity.cs | 6 +- .../ChannelHandshake.cs | 16 + .../ChannelHandshakeResult.cs | 91 +++++ .../ChannelOpening.cs | 111 +++++- .../Devolutions.AgentIdentity/ChannelProof.cs | 45 +++ .../RequestAuthenticator.cs | 5 +- 11 files changed, 786 insertions(+), 7 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelProof.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs index dcc2d25768..f8b0293784 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs @@ -3,7 +3,7 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The types that only request authentication creates. +/// The types that only request authentication and the channel handshake create. /// /// /// INTENT.md: code that acts for an identity takes it as a type only the signature check can create, @@ -17,6 +17,7 @@ public class AuthenticationTypeTests typeof(ChannelOpening), typeof(AuthenticationResult), typeof(ChannelOpeningResult), + typeof(ChannelHandshakeResult), ]; public static TheoryData AuthenticationTypeData => new(AuthenticationTypes); @@ -55,6 +56,9 @@ public void OnlyAuthenticationProducesTheseTypes() Assert.Equal( [ "AuthenticationResult.get_Identity", + "ChannelHandshakeResult.get_Identity", + "ChannelOpening.HelloTimedOut", + "ChannelOpening.VerifyHello", "ChannelOpeningResult.get_Opening", "RequestAuthenticator.AuthenticateAsync", "RequestAuthenticator.AuthenticateChannelOpeningAsync", diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs new file mode 100644 index 0000000000..38bab3011a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs @@ -0,0 +1,350 @@ +using Devolutions.Agent.Channel.V1; + +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +using Grpc.Core; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The channel handshake of CONTRACT.md §7.3 steps 3 and 4: the first agent message and the Welcome that answers it. +/// +public class ChannelHandshakeTests +{ + private const string UuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + + // Messages that break exactly one rule of §7.3 step 4 that closes the stream with signature_invalid. + private static readonly Dictionary> SignatureFailures = new() + { + ["an Ack"] = opening => new AgentMessage + { + Id = NewId(), + CorrelationId = opening.Challenge.Id, + Ack = new Ack(), + }, + ["a message without payload"] = opening => new AgentMessage + { + Id = NewId(), + CorrelationId = opening.Challenge.Id, + }, + ["a Hello without correlation_id"] = opening => + Altered(VectorChannel.ValidHelloMessage(opening), message => message.ClearCorrelationId()), + ["a Hello correlated to another id"] = opening => + Altered(VectorChannel.ValidHelloMessage(opening), message => message.CorrelationId = NewId()), + + // The Hello must carry the Challenge id as sent, so the same UUID in uppercase is another id. + ["a Hello correlated to the Challenge id in uppercase"] = opening => Altered( + VectorChannel.ValidHelloMessage(opening), + message => message.CorrelationId = opening.Challenge.Id.ToUpperInvariant()), + + // The shared vectors cover the other ways a proof fails, one of which is repeated here to pair it below. + ["a Hello with the proof of another device"] = opening => VectorChannel.HelloMessage(opening, OtherKeyProof), + ["a Hello with a 63-byte proof"] = opening => VectorChannel.HelloMessage(opening, ValidProof[..63]), + ["a Hello with a valid proof and one more byte"] = opening => + VectorChannel.HelloMessage(opening, [.. ValidProof, 0x00]), + }; + + // Alterations that break a rule which §7.3 step 4 checks after the one a message of SignatureFailures breaks. + private static readonly Dictionary> LaterFailures = new() + { + ["no correlation_id"] = message => message.ClearCorrelationId(), + ["a proof by another device"] = message => message.Hello.Proof = ByteString.CopyFrom(OtherKeyProof), + ["invalid metadata"] = message => message.Hello.Metadata.Add("Hostname", "device-a"), + }; + + // Calls that end the handshake of an opening, one per outcome. + private static readonly Dictionary> HandshakeEnds = new() + { + ["a valid Hello"] = opening => opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)), + ["a Hello with the proof of another device"] = opening => + opening.VerifyHello(VectorChannel.HelloMessage(opening, OtherKeyProof)), + ["a Hello with invalid metadata"] = opening => + opening.VerifyHello(Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"])), + ["no Hello in time"] = opening => opening.HelloTimedOut(), + }; + + public static TheoryData SignatureFailureNames => new(SignatureFailures.Keys); + + // Every way to end a handshake, followed by each of the two calls a host makes. + public static TheoryData HandshakeEndsThenAnotherCall + { + get + { + TheoryData cases = new(); + + foreach (string first in HandshakeEnds.Keys) + { + cases.Add(first, "a valid Hello"); + cases.Add(first, "no Hello in time"); + } + + return cases; + } + } + + public static TheoryData FailurePairs => new() + { + { "an Ack", "no correlation_id" }, + { "a message without payload", "no correlation_id" }, + { "a Hello without correlation_id", "a proof by another device" }, + { "a Hello correlated to another id", "a proof by another device" }, + { "a Hello without correlation_id", "invalid metadata" }, + { "a Hello correlated to another id", "invalid metadata" }, + { "a Hello with the proof of another device", "invalid metadata" }, + { "a Hello with a 63-byte proof", "invalid metadata" }, + }; + + private static byte[] ValidProof => TestVectors.Instance.ChannelProof.Case("valid").Proof; + + // A raw 64-byte signature over the right bytes, by the key of device-b. + private static byte[] OtherKeyProof => TestVectors.Instance.ChannelProof.Case("wrong_key").Proof; + + // CONTRACT.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. + [Theory] + [MemberData(nameof(SignatureFailureNames))] + public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string failure) + { + ChannelHandshakeResult result = await VerifyAsync(SignatureFailures[failure]); + + AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); + } + + // CONTRACT.md §7.3 step 4: a Hello that proves the key but carries metadata violating §3 is + // INVALID_ARGUMENT with error-code invalid_request. + [Fact] + public async Task HelloWithInvalidMetadataIsInvalidRequest() + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + AgentMessage hello = VectorChannel.ValidHelloMessage(opening); + LaterFailures["invalid metadata"](hello); + + ChannelHandshakeResult result = opening.VerifyHello(hello); + + AssertFailed(result, AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument); + } + + // CONTRACT.md §7.3 step 4 checks the payload, the correlation_id, the proof, then the metadata, + // and a message breaking two rules fails as it would for the earlier rule alone. + // An agent that has not proved the key never learns whether its metadata would pass. + [Theory] + [MemberData(nameof(FailurePairs))] + public async Task EarlierFailureWins(string earlier, string later) + { + ChannelHandshakeResult earlierAlone = await VerifyAsync(SignatureFailures[earlier]); + ChannelHandshakeResult laterAlone = + await VerifyAsync(opening => Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures[later])); + + ChannelHandshakeResult result = + await VerifyAsync(opening => Altered(SignatureFailures[earlier](opening), LaterFailures[later])); + + Assert.NotEqual(laterAlone.FailureDetail, earlierAlone.FailureDetail); + AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); + Assert.Equal(earlierAlone.FailureDetail, result.FailureDetail); + } + + // CONTRACT.md §7.3 step 4: the server verifies the proof with the key of the certificate that authenticated the + // opening request, so the stream is authenticated for that certificate and its identity. + // The vector file states the facts of the certificate, in Unix seconds, independently of any time zone. + [Fact] + public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() + { + RegisteredCertificateVector registered = + TestVectors.Instance.HttpSignature.RegisteredCertificate(TestVectors.Instance.ChannelProof.Device); + ChannelOpening opening = await VectorChannel.OpenAsync(); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + + Assert.True(result.Succeeded, result.FailureDetail); + AuthenticatedIdentity identity = result.Identity; + Assert.Equal(registered.IdentityId, identity.IdentityId); + Assert.Equal(registered.KeyId, identity.CertificateThumbprint); + Assert.Equal(registered.CertificateStatus, identity.CertificateStatus.ToString().ToLowerInvariant()); + Assert.Equal(registered.NotBefore, identity.CertificateNotBefore); + Assert.Equal(registered.NotAfter, identity.CertificateNotAfter); + Assert.Equal(AgentOperation.Connect, identity.Operation); + Assert.Null(result.Error); + Assert.Null(result.FailureDetail); + } + + // CONTRACT.md §6 table: a channel opening accepts a pending certificate as well as a current one, + // and the identity reports which of the two opened the stream. + [Fact] + public async Task IdentityHasTheStatusOfTheOpeningCertificate() + { + ChannelOpening opening = await VectorChannel.OpenAsync(status: CertificateStatus.Pending); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(CertificateStatus.Pending, result.Identity.CertificateStatus); + } + + // CONTRACT.md §3: the server replaces the identity's metadata with the metadata of each authenticated Hello. + [Fact] + public async Task ValidHelloYieldsItsMetadata() + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(VectorChannel.ValidMetadata, result.Metadata); + } + + // CONTRACT.md §3: metadata may be an empty object. + // A protobuf map without entries is also how a Hello without metadata arrives, + // so the rule that rejects a JSON body without metadata cannot apply here. + [Fact] + public async Task HelloWithoutMetadataSucceeds() + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + AgentMessage hello = VectorChannel.ValidHelloMessage(opening); + hello.Hello.Metadata.Clear(); + + ChannelHandshakeResult result = opening.VerifyHello(hello); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Empty(result.Metadata); + } + + // CONTRACT.md §7.3 step 4: Welcome has correlation_id = Hello id. + // CONTRACT.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. + // The Hello id is in uppercase, so a Welcome that normalized it would no longer match what the agent sent. + [Fact] + public async Task WelcomeAnswersTheHello() + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + AgentMessage hello = VectorChannel.ValidHelloMessage(opening); + hello.Id = NewId().ToUpperInvariant(); + + ChannelHandshakeResult result = opening.VerifyHello(hello); + + Assert.True(result.Succeeded, result.FailureDetail); + ServerMessage welcome = result.Welcome; + Assert.Equal(ServerMessage.PayloadOneofCase.Welcome, welcome.PayloadCase); + Assert.Matches(UuidPattern, welcome.Id); + Assert.NotEqual(opening.Challenge.Id, welcome.Id); + Assert.True(welcome.HasCorrelationId); + Assert.Equal(hello.Id, welcome.CorrelationId); + } + + // The clock returns the time of the opening, then the time of the handshake, then a later time. + // The Welcome carries the time at which the server answers, from that one reading. + [Fact] + public async Task WelcomeCarriesTheServerTimeOfTheHandshake() + { + DateTimeOffset opened = VectorChannel.OpeningTime; + DateTimeOffset answered = opened.AddMilliseconds(3_250); + ChannelOpening opening = await VectorChannel.OpenAsync( + new SteppingTimeProvider(opened, answered, answered.AddDays(1).AddMilliseconds(500))); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + + Assert.True(result.Succeeded, result.FailureDetail); + Timestamp serverTime = result.Welcome.Welcome.ServerTime; + Assert.Equal(answered.ToUnixTimeSeconds(), serverTime.Seconds); + Assert.Equal(250_000_000, serverTime.Nanos); + } + + // The agent answers the Challenge it received, so the handshake holds the Hello to the id and bytes as sent, + // even if the host later edits the message object it sent. + [Fact] + public async Task HandshakeChecksTheChallengeAsSent() + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + AgentMessage hello = VectorChannel.ValidHelloMessage(opening); + opening.Challenge.Id = NewId(); + opening.Challenge.Challenge.Challenge_ = ByteString.CopyFrom(new byte[32]); + + ChannelHandshakeResult result = opening.VerifyHello(hello); + + Assert.True(result.Succeeded, result.FailureDetail); + } + + // CONTRACT.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. + [Fact] + public async Task NoHelloInTimeIsSignatureInvalid() + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + + ChannelHandshakeResult result = opening.HelloTimedOut(); + + AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); + } + + // CONTRACT.md §7.3 step 4: the server waits 10 s for the Hello. + [Fact] + public void HelloTimeoutIs10Seconds() + { + Assert.Equal(TimeSpan.FromSeconds(10), ChannelHandshake.HelloTimeout); + } + + // The first agent message decides the stream: a second one could retry a failed proof, + // and a late Hello could authenticate a stream that already timed out. + [Theory] + [MemberData(nameof(HandshakeEndsThenAnotherCall))] + public async Task HandshakeEndsAtTheFirstCall(string first, string second) + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + HandshakeEnds[first](opening); + + Assert.Throws(() => HandshakeEnds[second](opening)); + } + + // CONTRACT.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure + // and the trailers `error-code: <§5.4 code>` and `server-time: `, which the agent reads. + [Theory] + [InlineData("a Hello with the proof of another device", StatusCode.Unauthenticated, "signature_invalid")] + [InlineData("a Hello with invalid metadata", StatusCode.InvalidArgument, "invalid_request")] + [InlineData("no Hello in time", StatusCode.Unauthenticated, "signature_invalid")] + public async Task FailureClosesTheStreamWithTheContractStatusAndTrailers( + string end, + StatusCode expectedStatus, + string expectedCode) + { + DateTimeOffset serverTime = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + ChannelOpening opening = await VectorChannel.OpenAsync(); + ChannelHandshakeResult result = HandshakeEnds[end](opening); + Assert.False(result.Succeeded); + + RpcException exception = result.Error.ToRpcException(serverTime); + + Assert.Equal(expectedStatus, exception.StatusCode); + Assert.Equal( + [("error-code", expectedCode), ("server-time", "2026-09-24T12:00:00Z")], + exception.Trailers.Select(entry => (entry.Key, entry.Value))); + } + + private static void AssertFailed( + ChannelHandshakeResult result, + AgentIdentityErrorCode expectedCode, + StatusCode expectedStatus) + { + Assert.False(result.Succeeded); + Assert.Equal(expectedCode, result.Error.Code); + Assert.Equal(expectedStatus, result.Error.ToRpcException(DateTimeOffset.UnixEpoch).StatusCode); + Assert.False(string.IsNullOrEmpty(result.FailureDetail)); + Assert.Null(result.Identity); + Assert.Null(result.Metadata); + Assert.Null(result.Welcome); + } + + private static AgentMessage Altered(AgentMessage message, Action alteration) + { + alteration(message); + return message; + } + + // Opens the vector stream and verifies the first message built for it. + private static async Task VerifyAsync(Func firstMessage) + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + return opening.VerifyHello(firstMessage(opening)); + } + + private static string NewId() + { + return Guid.NewGuid().ToString("D"); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs new file mode 100644 index 0000000000..42646c5d7c --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs @@ -0,0 +1,35 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The shared channel proof vectors, run through the public API: +/// the vector's opening request, then a Hello carrying each case's proof. +/// +public class ChannelHandshakeVectorTests +{ + private static ChannelProofVectors Vectors => TestVectors.Instance.ChannelProof; + + // CONTRACT.md §7.3 step 4: a proof that does not verify closes the stream with signature_invalid. + // Each failing case changes the key, one part of the signed bytes, or the encoding of the proof. + [Theory] + [MemberData(nameof(VectorData.ChannelProofCaseNames), MemberType = typeof(VectorData))] + public async Task CaseMatchesItsExpectedValidity(string name) + { + ChannelProofCaseVector vector = Vectors.Case(name); + ChannelOpening opening = await VectorChannel.OpenAsync(); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.HelloMessage(opening, vector.Proof)); + + string outcome = result.Succeeded ? "ok" : result.Error.Code.ToWireValue(); + Assert.Equal(vector.ExpectedValid ? "ok" : "signature_invalid", outcome); + } + + // CONTRACT.md §7.3 step 3: the proof signs the domain, a zero byte, the challenge and the connect nonce. + // A proof input that differs from the vector's in any byte fails every agent's proof. + [Fact] + public void ProofInputIsTheVectorMessage() + { + byte[] input = ChannelProof.BuildInput(Vectors.Challenge, Vectors.ConnectNonce); + + Assert.Equal(Convert.ToHexStringLower(Vectors.Message), Convert.ToHexStringLower(input)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs index 3144d404c9..9983d4a14c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs @@ -103,3 +103,28 @@ public override void GetBytes(Span data) } } } + +/// +/// A generator that returns the given bytes in order and fails when asked for more, +/// so that a test controls every random byte the library draws. +/// +internal sealed class ScriptedRandomNumberGenerator(byte[] bytes) : RandomNumberGenerator +{ + private int _next; + + public override void GetBytes(byte[] data) + { + GetBytes(data.AsSpan()); + } + + public override void GetBytes(Span data) + { + if (data.Length > bytes.Length - _next) + { + throw new InvalidOperationException("The test supplied fewer random bytes than the library drew."); + } + + bytes.AsSpan(_next, data.Length).CopyTo(data); + _next += data.Length; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs new file mode 100644 index 0000000000..ca169ec583 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs @@ -0,0 +1,103 @@ +using Devolutions.Agent.Channel.V1; + +using Google.Protobuf; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The agent channel stream that channel_proof describes: +/// its connect_case opened at that case's now, with the vector challenge. +/// +internal static class VectorChannel +{ + private static ChannelProofVectors Vectors => TestVectors.Instance.ChannelProof; + + private static HttpSignatureCaseVector ConnectCase => TestVectors.Instance.HttpSignature.Case(Vectors.ConnectCase); + + /// + /// The now of the opening request, at which the stream opens unless a test passes another clock. + /// + public static DateTimeOffset OpeningTime => ConnectCase.Now; + + /// + /// Metadata that CONTRACT.md §3 accepts, with the keys that §3.1 says the agent always sends. + /// + public static IReadOnlyList> ValidMetadata { get; } = + [ + new("hostname", "device-a"), + new("os_name", "Windows"), + new("os_version", "10.0.26100"), + new("arch", "x86_64"), + new("agent_version", "2026.3.0"), + ]; + + /// + /// Opens the stream through the public API. + /// + /// The authority's clock, which reads when null. + /// + /// The status to register the device's certificate with, instead of the status that the vector file gives it. + /// + public static async Task OpenAsync(TimeProvider? clock = null, CertificateStatus? status = null) + { + VectorAuthority authority = new(); + + if (status is { } registeredStatus) + { + RegisteredCertificateVector device = + TestVectors.Instance.HttpSignature.RegisteredCertificate(Vectors.Device); + authority.Registry.Register( + device.KeyId, + new RegisteredCertificate( + device.IdentityId, + device.Certificate, + registeredStatus, + device.IdentityRevoked)); + } + + RequestAuthenticator authenticator = new( + authority.Registry, + authority.Nonces, + clock ?? new FixedTimeProvider(OpeningTime), + new ScriptedRandomNumberGenerator(Vectors.Challenge)); + + ChannelOpeningResult result = await authenticator.AuthenticateChannelOpeningAsync( + ConnectCase.Method, + VectorAuthority.Lines(ConnectCase.Headers.SignatureInput), + VectorAuthority.Lines(ConnectCase.Headers.Signature)); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(Vectors.Challenge, result.Opening.Challenge.Challenge.Challenge_.ToByteArray()); + return result.Opening; + } + + /// + /// A Hello that answers the Challenge of with + /// and . + /// + public static AgentMessage HelloMessage(ChannelOpening opening, byte[] proof) + { + AgentMessage message = new() + { + Id = Guid.NewGuid().ToString("D"), + CorrelationId = opening.Challenge.Id, + Hello = new Hello { Proof = ByteString.CopyFrom(proof) }, + }; + + foreach ((string key, string value) in ValidMetadata) + { + message.Hello.Metadata.Add(key, value); + } + + return message; + } + + /// + /// A Hello that the stream accepts: + /// with the proof of the vector case valid. + /// + public static AgentMessage ValidHelloMessage(ChannelOpening opening) + { + return HelloMessage(opening, Vectors.Case("valid").Proof); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs index 87b6fbb9d5..5df6c15fe1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs @@ -1,11 +1,11 @@ namespace Devolutions.AgentIdentity; /// -/// An identity that a signed request proved. +/// An identity that a signed request, or the proof that authenticates an agent channel stream, proved. /// /// -/// Only request authentication creates one, so code that takes this type acts for an identity that a signature check -/// established, never for one taken from a value that the agent or a caller sent. +/// Only request authentication and the channel handshake create one, so code that takes this type acts for an +/// identity that a signature check established, never for one taken from a value that the agent or a caller sent. /// public sealed class AuthenticatedIdentity { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs new file mode 100644 index 0000000000..22599e3012 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs @@ -0,0 +1,16 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The timing of the handshake that authenticates an agent channel stream. +/// +public static class ChannelHandshake +{ + /// + /// How long the host waits for the first agent message after it sends the challenge: 10 seconds. + /// + /// + /// When no message arrives in that time, the host calls + /// and closes the stream with its error. + /// + public static TimeSpan HelloTimeout { get; } = TimeSpan.FromSeconds(10); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs new file mode 100644 index 0000000000..d33f103b1f --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs @@ -0,0 +1,91 @@ +using System.Diagnostics.CodeAnalysis; + +using Devolutions.Agent.Channel.V1; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of the handshake that authenticates an agent channel stream: +/// the identity that the agent proved, with its metadata and the Welcome to send, +/// or the error that closes the stream. +/// +/// +/// On success, the host replaces the identity's metadata with , sends , +/// and only then treats the stream as authenticated. +/// On failure, the host closes the stream with of , +/// and changes no state of the identity. +/// +public sealed class ChannelHandshakeResult +{ + private ChannelHandshakeResult( + AuthenticatedIdentity? identity, + AgentMetadata? metadata, + ServerMessage? welcome, + AgentIdentityError? error, + string? failureDetail) + { + Succeeded = identity is not null; + Identity = identity; + Metadata = metadata; + Welcome = welcome; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the agent proved that it holds the key of the certificate that opened the stream, + /// and sent valid metadata. + /// + [MemberNotNullWhen(true, nameof(Identity), nameof(Metadata), nameof(Welcome))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Succeeded { get; } + + /// + /// The identity that the stream is authenticated for, with , + /// when the handshake succeeded. + /// + /// + /// The stream stays bound to the certificate that opened it, + /// so the host closes the stream no later than . + /// + public AuthenticatedIdentity? Identity { get; } + + /// + /// The metadata of the Hello, which replaces the identity's metadata, when the handshake succeeded. + /// + public AgentMetadata? Metadata { get; } + + /// + /// The Welcome that answers the Hello, when the handshake succeeded: + /// a fresh id, the Hello id as correlation_id, and the server time. + /// + public ServerMessage? Welcome { get; } + + /// + /// The error to close the stream with, when the handshake failed: + /// when the agent did not prove the key in time, + /// or when its proof verified but its metadata is invalid. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that the first agent message broke, for host logs, when the handshake failed. + /// + /// + /// It never repeats a value from the message, and quotes a metadata key only when the key itself is valid. + /// + public string? FailureDetail { get; } + + internal static ChannelHandshakeResult Success( + AuthenticatedIdentity identity, + AgentMetadata metadata, + ServerMessage welcome) + { + return new ChannelHandshakeResult(identity, metadata, welcome, null, null); + } + + internal static ChannelHandshakeResult Failure(AgentIdentityError error, string failureDetail) + { + return new ChannelHandshakeResult(null, null, null, error, failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs index cc317eaa9a..37c602be7c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs @@ -1,7 +1,9 @@ using Devolutions.Agent.Channel.V1; using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using ChallengePayload = Devolutions.Agent.Channel.V1.Challenge; +using WelcomePayload = Devolutions.Agent.Channel.V1.Welcome; namespace Devolutions.AgentIdentity; @@ -14,10 +16,25 @@ namespace Devolutions.AgentIdentity; /// The host sends as the first message on the stream, /// and the stream is authenticated only once the agent signs that challenge. /// The identity stays hidden until then, so that nothing acts for it on the strength of the headers alone. +/// The host passes the first agent message to , +/// or calls when none arrives within . /// public sealed class ChannelOpening { - internal ChannelOpening(VerifiedCertificate certificate, string connectNonce, Guid challengeId, byte[] challenge) + private static readonly AgentIdentityError SignatureInvalid = + AgentIdentityError.From(AgentIdentityErrorCode.SignatureInvalid); + + private readonly TimeProvider _timeProvider; + + // Set by the first call to VerifyHello or HelloTimedOut. + private int _handshakeEnded; + + internal ChannelOpening( + VerifiedCertificate certificate, + string connectNonce, + Guid challengeId, + byte[] challenge, + TimeProvider timeProvider) { Certificate = certificate; ConnectNonce = connectNonce; @@ -28,6 +45,7 @@ internal ChannelOpening(VerifiedCertificate certificate, string connectNonce, Gu Id = ChallengeId, Challenge = new ChallengePayload { Challenge_ = ByteString.CopyFrom(challenge) }, }; + _timeProvider = timeProvider; } /// @@ -63,4 +81,95 @@ internal ChannelOpening(VerifiedCertificate certificate, string connectNonce, Gu /// so that a change to the message object cannot change what the agent must sign. /// internal ReadOnlyMemory ChallengeBytes { get; } + + /// + /// Verifies the first message that the agent sent on the stream, as CONTRACT.md §7.3 step 4 specifies. + /// + /// + /// The message must be a Hello whose correlation_id is the id of , + /// and whose proof verifies with the key of the certificate that signed the opening request; + /// otherwise the handshake fails with . + /// Only then is the metadata checked, and metadata that CONTRACT.md §3 rejects fails the handshake with + /// . + /// The first call to this method or to ends the handshake, whatever its outcome. + /// + /// The first message that the agent sent on the stream. + /// is null. + /// The handshake has already ended. + public ChannelHandshakeResult VerifyHello(AgentMessage firstMessage) + { + ArgumentNullException.ThrowIfNull(firstMessage); + EndHandshake(); + + if (firstMessage.PayloadCase != AgentMessage.PayloadOneofCase.Hello) + { + return ChannelHandshakeResult.Failure(SignatureInvalid, "The first message on the stream is not a Hello."); + } + + if (!firstMessage.HasCorrelationId) + { + return ChannelHandshakeResult.Failure(SignatureInvalid, "The Hello has no correlation_id."); + } + + if (!string.Equals(firstMessage.CorrelationId, ChallengeId, StringComparison.Ordinal)) + { + return ChannelHandshakeResult.Failure( + SignatureInvalid, + "The correlation_id of the Hello is not the id of the Challenge."); + } + + Hello hello = firstMessage.Hello; + + if (!ChannelProof.Verify(Certificate.SubjectPublicKeyInfo, ChallengeBytes.Span, ConnectNonce, hello.Proof.Span)) + { + return ChannelHandshakeResult.Failure( + SignatureInvalid, + "The proof of the Hello does not verify with the key of the certificate that opened the stream."); + } + + MetadataValidationResult metadata = AgentMetadata.Validate(hello.Metadata); + if (!metadata.Succeeded) + { + return ChannelHandshakeResult.Failure(metadata.Error, metadata.FailureDetail); + } + + ServerMessage welcome = new() + { + Id = Guid.NewGuid().ToString("D"), + CorrelationId = firstMessage.Id, + Welcome = new WelcomePayload { ServerTime = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()) }, + }; + + return ChannelHandshakeResult.Success( + Certificate.ToIdentity(AgentOperation.Connect), + metadata.Metadata, + welcome); + } + + /// + /// Ends the handshake of a stream on which no agent message arrived within + /// , with . + /// + /// + /// The host also calls it when the agent ends the stream without sending a message. + /// + /// The handshake has already ended. + public ChannelHandshakeResult HelloTimedOut() + { + EndHandshake(); + + return ChannelHandshakeResult.Failure(SignatureInvalid, "No Hello arrived before the timeout."); + } + + private void EndHandshake() + { + // UNTESTED: of two calls racing on two threads, exactly one ends the handshake and the other throws. + // A host can receive the first message just as its Hello timer fires, + // and both calls deciding the stream could authenticate a stream that is being closed. + // When changing this guard, keep the read and the write of the flag in one atomic operation. + if (Interlocked.Exchange(ref _handshakeEnded, 1) != 0) + { + throw new InvalidOperationException("The handshake of this channel opening has already ended."); + } + } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelProof.cs new file mode 100644 index 0000000000..478f564d20 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelProof.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// The proof in Hello that the agent holds the key of the certificate that opened the stream, +/// as CONTRACT.md §7.3 step 3 defines it. +/// +internal static class ChannelProof +{ + // The device key also signs request signature bases, which start with a quoted component name, + // so no proof is ever a valid request signature, nor the reverse. + private static ReadOnlySpan Domain => "devolutions-agent-identity/v1/channel-proof"u8; + + /// + /// Builds the bytes that the proof signs: the domain, a zero byte, the challenge, + /// then the UTF-8 bytes of the nonce parameter of the opening request. + /// + public static byte[] BuildInput(ReadOnlySpan challenge, string connectNonce) + { + return [.. Domain, 0x00, .. challenge, .. Encoding.UTF8.GetBytes(connectNonce)]; + } + + /// + /// Verifies a proof, which fails unless it is a raw 64-byte r‖s signature by the key. + /// + /// + /// The P-256 key of the certificate that authenticated the opening request. + /// + /// The random bytes of the Challenge that the stream sent. + /// The nonce parameter of the opening request. + /// The proof of the Hello. + public static bool Verify( + byte[] subjectPublicKeyInfo, + ReadOnlySpan challenge, + string connectNonce, + ReadOnlySpan proof) + { + using ECDsa publicKey = ECDsa.Create(); + publicKey.ImportSubjectPublicKeyInfo(subjectPublicKeyInfo, out _); + + return EcdsaP256Signature.Verify(publicKey, BuildInput(challenge, connectNonce), proof); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs index 2ee8fe582f..77f381f253 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs @@ -126,7 +126,8 @@ public async ValueTask AuthenticateAsync( /// The opening request goes through the steps of CONTRACT.md §6 with tag connect, /// and its nonce is recorded only once every other step has passed. /// A verified opening does not authenticate the stream: - /// the agent must still sign . + /// the agent must still sign , + /// which checks. /// /// The request method, as received. /// The signature-input metadata entries in received order. @@ -166,7 +167,7 @@ public async ValueTask AuthenticateChannelOpeningAsync( _randomNumberGenerator.GetBytes(challenge); return ChannelOpeningResult.Success( - new ChannelOpening(verification.Certificate, verification.Nonce, Guid.NewGuid(), challenge)); + new ChannelOpening(verification.Certificate, verification.Nonce, Guid.NewGuid(), challenge, _timeProvider)); } private async ValueTask VerifyAsync( From 6409683269c1f8f21c94cbba5372b20d91db686c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 19:59:00 +0900 Subject: [PATCH 24/93] feat(dotnet-agent-identity): enforce handshake deadlines A stream closes no later than the notAfter of the certificate that opened it, and the agent must answer the challenge within 10 s, so the handshake must fail once either deadline has passed, whatever the first message holds, and the host must know when the certificate expires before the stream is authenticated. VerifyHello and HelloTimedOut read the clock once and return certificate_expired after the certificate's notAfter. VerifyHello then returns signature_invalid when it runs more than HelloTimeout after the opening was verified, before it reads the message; exactly 10 s is in time. The opening records that instant from the authority's clock once verification ends, so time spent in the registry and the nonce store does not shorten the agent's window. ChannelOpening.CertificateNotAfter lets the host close a stream still waiting for its Hello at that time. The Welcome carries the same clock reading. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ChannelHandshakeTests.cs | 190 +++++++++++++++++- .../ChannelOpeningTests.cs | 15 ++ .../TestChannelProof.cs | 35 ++++ .../ChannelHandshake.cs | 4 +- .../ChannelHandshakeResult.cs | 10 +- .../ChannelOpening.cs | 61 +++++- .../RequestAuthenticator.cs | 13 +- 7 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestChannelProof.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs index 38bab3011a..e73a84510a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs @@ -53,6 +53,16 @@ public class ChannelHandshakeTests ["invalid metadata"] = message => message.Hello.Metadata.Add("Hostname", "device-a"), }; + // The times at which VerifyHello runs on the vector stream, each breaking a rule that it checks on its clock + // reading before it reads the message. + // The certificate of device-a expires 70 days after the vector opening, so a reading after its notAfter is also + // after the Hello timeout. + private static readonly Dictionary> ClockFailures = new() + { + ["after the certificate's notAfter"] = () => DeviceANotAfter.AddTicks(1), + ["after the Hello timeout"] = () => VectorChannel.OpeningTime.AddSeconds(10).AddTicks(1), + }; + // Calls that end the handshake of an opening, one per outcome. private static readonly Dictionary> HandshakeEnds = new() { @@ -66,6 +76,23 @@ public class ChannelHandshakeTests public static TheoryData SignatureFailureNames => new(SignatureFailures.Keys); + // Each clock failure with each message failure, and the error of the clock failure. + public static TheoryData ClockAndMessageFailures + { + get + { + TheoryData cases = new(); + + foreach (string message in MessageFailures.Keys) + { + cases.Add("after the certificate's notAfter", message, AgentIdentityErrorCode.CertificateExpired); + cases.Add("after the Hello timeout", message, AgentIdentityErrorCode.SignatureInvalid); + } + + return cases; + } + } + // Every way to end a handshake, followed by each of the two calls a host makes. public static TheoryData HandshakeEndsThenAnotherCall { @@ -97,9 +124,24 @@ public static TheoryData HandshakeEndsThenAnotherCall private static byte[] ValidProof => TestVectors.Instance.ChannelProof.Case("valid").Proof; + // Every message that breaks one rule of §7.3 step 4: SignatureFailures, and invalid metadata in a valid Hello. + private static Dictionary> MessageFailures => + new(SignatureFailures) + { + ["a Hello with invalid metadata"] = opening => + Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"]), + }; + // A raw 64-byte signature over the right bytes, by the key of device-b. private static byte[] OtherKeyProof => TestVectors.Instance.ChannelProof.Case("wrong_key").Proof; + private static KeyVector DeviceA => TestVectors.Instance.Key("device-a"); + + // The notAfter of the certificate of device-a, which opens every stream of these tests, + // as the vector file states it in Unix seconds, independently of any time zone. + private static DateTimeOffset DeviceANotAfter => + TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter; + // CONTRACT.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. [Theory] [MemberData(nameof(SignatureFailureNames))] @@ -143,6 +185,28 @@ public async Task EarlierFailureWins(string earlier, string later) Assert.Equal(earlierAlone.FailureDetail, result.FailureDetail); } + // CONTRACT.md §7.4 and §7.3 step 4: a stream closes at its certificate's notAfter and after the Hello timeout, + // so a Hello that comes later fails for that reason, whatever it holds. + // The certificate comes first: a reading after its notAfter is also after the timeout here. + // The timeout is signature_invalid like the message failures, and comes before all of them, metadata included. + [Theory] + [MemberData(nameof(ClockAndMessageFailures))] + public async Task ClockFailureWinsOverMessageFailure( + string clock, + string message, + AgentIdentityErrorCode expectedCode) + { + ChannelHandshakeResult clockAlone = + await VerifyAtAsync(ClockFailures[clock](), VectorChannel.ValidHelloMessage); + ChannelHandshakeResult messageAlone = await VerifyAsync(MessageFailures[message]); + + ChannelHandshakeResult result = await VerifyAtAsync(ClockFailures[clock](), MessageFailures[message]); + + Assert.NotEqual(messageAlone.FailureDetail, clockAlone.FailureDetail); + AssertFailed(result, expectedCode, StatusCode.Unauthenticated); + Assert.Equal(clockAlone.FailureDetail, result.FailureDetail); + } + // CONTRACT.md §7.3 step 4: the server verifies the proof with the key of the certificate that authenticated the // opening request, so the stream is authenticated for that certificate and its identity. // The vector file states the facts of the certificate, in Unix seconds, independently of any time zone. @@ -229,7 +293,8 @@ public async Task WelcomeAnswersTheHello() Assert.Equal(hello.Id, welcome.CorrelationId); } - // The clock returns the time of the opening, then the time of the handshake, then a later time. + // The clock reads the time of the opening twice, to judge the request and to start the Hello timeout, + // then the time of the handshake, then a later time. // The Welcome carries the time at which the server answers, from that one reading. [Fact] public async Task WelcomeCarriesTheServerTimeOfTheHandshake() @@ -237,7 +302,7 @@ public async Task WelcomeCarriesTheServerTimeOfTheHandshake() DateTimeOffset opened = VectorChannel.OpeningTime; DateTimeOffset answered = opened.AddMilliseconds(3_250); ChannelOpening opening = await VectorChannel.OpenAsync( - new SteppingTimeProvider(opened, answered, answered.AddDays(1).AddMilliseconds(500))); + new SteppingTimeProvider(opened, opened, answered, answered.AddDays(1).AddMilliseconds(500))); ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); @@ -262,6 +327,31 @@ public async Task HandshakeChecksTheChallengeAsSent() Assert.True(result.Succeeded, result.FailureDetail); } + // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + // The Welcome shows that the clock reading that judged the certificate is the one at notAfter. + [Fact] + public async Task HandshakeSucceedsAtTheCertificateNotAfter() + { + (ChannelOpening opening, AgentMessage hello) = await OpenBeforeNotAfterAsync(DeviceANotAfter); + + ChannelHandshakeResult result = opening.VerifyHello(hello); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(Timestamp.FromDateTimeOffset(DeviceANotAfter), result.Welcome.Welcome.ServerTime); + } + + // CONTRACT.md §7.4 and the shared intent, security invariant 8: a stream closes no later than the notAfter of the + // certificate that opened it, with UNAUTHENTICATED and error-code certificate_expired. + [Fact] + public async Task HandshakeOneTickAfterTheCertificateNotAfterIsCertificateExpired() + { + (ChannelOpening opening, AgentMessage hello) = await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(1)); + + ChannelHandshakeResult result = opening.VerifyHello(hello); + + AssertFailed(result, AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated); + } + // CONTRACT.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. [Fact] public async Task NoHelloInTimeIsSignatureInvalid() @@ -273,6 +363,22 @@ public async Task NoHelloInTimeIsSignatureInvalid() AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } + // CONTRACT.md §7.4: a stream still waiting for its Hello after its certificate's notAfter closes with + // certificate_expired rather than as a timeout. + [Theory] + [InlineData(0, AgentIdentityErrorCode.SignatureInvalid)] + [InlineData(1, AgentIdentityErrorCode.CertificateExpired)] + public async Task TimeoutIsCertificateExpiredOnlyAfterTheNotAfter( + long ticksAfterNotAfter, + AgentIdentityErrorCode expectedCode) + { + (ChannelOpening opening, _) = await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(ticksAfterNotAfter)); + + ChannelHandshakeResult result = opening.HelloTimedOut(); + + AssertFailed(result, expectedCode, StatusCode.Unauthenticated); + } + // CONTRACT.md §7.3 step 4: the server waits 10 s for the Hello. [Fact] public void HelloTimeoutIs10Seconds() @@ -280,6 +386,51 @@ public void HelloTimeoutIs10Seconds() Assert.Equal(TimeSpan.FromSeconds(10), ChannelHandshake.HelloTimeout); } + // CONTRACT.md §7.3 step 4: the Hello must arrive within 10 s of the opening, 10 s included. + // The Welcome shows that the clock reading that judged the timeout is the one at 10 s. + [Fact] + public async Task HelloExactlyAtTheTimeoutSucceeds() + { + DateTimeOffset opened = VectorChannel.OpeningTime; + DateTimeOffset answered = opened.AddSeconds(10); + ChannelOpening opening = await VectorChannel.OpenAsync( + new SteppingTimeProvider(opened, opened, answered, answered.AddTicks(1))); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime); + } + + // A Hello that the host passes on after the timeout fails, so a late host timer cannot let it through. + [Fact] + public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() + { + DateTimeOffset opened = VectorChannel.OpeningTime; + ChannelOpening opening = await VectorChannel.OpenAsync( + new SteppingTimeProvider(opened, opened, opened.AddSeconds(10).AddTicks(1))); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + + AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); + } + + // The agent can answer only once the opening is verified and its challenge sent, + // so the 10 s run from the end of verification, not from the earlier reading that judged the request. + // Here the registry and the nonce store take 5 s, and the Hello comes 10 s after them. + [Fact] + public async Task HelloTimeoutRunsFromTheEndOfTheOpening() + { + DateTimeOffset judged = VectorChannel.OpeningTime; + DateTimeOffset verified = judged.AddSeconds(5); + ChannelOpening opening = await VectorChannel.OpenAsync( + new SteppingTimeProvider(judged, verified, verified.AddSeconds(10))); + + ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + + Assert.True(result.Succeeded, result.FailureDetail); + } + // The first agent message decides the stream: a second one could retry a failed proof, // and a late Hello could authenticate a stream that already timed out. [Theory] @@ -343,6 +494,41 @@ private static async Task VerifyAsync(Func VerifyAtAsync( + DateTimeOffset handshakeTime, + Func firstMessage) + { + DateTimeOffset opened = VectorChannel.OpeningTime; + ChannelOpening opening = await VectorChannel.OpenAsync(new SteppingTimeProvider(opened, opened, handshakeTime)); + return opening.VerifyHello(firstMessage(opening)); + } + + // Opens a stream five seconds before the certificate of device-a expires, with a valid Hello signed for it, + // so that the handshake can reach notAfter within the Hello timeout. + // The clock reads the opening's time to judge the request and to start the timeout, then handshakeTime. + private static async Task<(ChannelOpening Opening, AgentMessage Hello)> OpenBeforeNotAfterAsync( + DateTimeOffset handshakeTime) + { + DateTimeOffset opened = DeviceANotAfter.AddSeconds(-5); + TestAuthority authority = new(opened, clock: new SteppingTimeProvider(opened, opened, handshakeTime)); + SignedHttpRequest request = authority.Valid(AgentOperation.Connect).ToHttpRequest(); + + ChannelOpeningResult result = await authority.Authenticator.AuthenticateChannelOpeningAsync( + request.Method, + request.SignatureInput, + request.Signature); + + Assert.True(result.Succeeded, result.FailureDetail); + ChannelOpening opening = result.Opening; + byte[] proof = TestChannelProof.Sign( + DeviceA, + opening.Challenge.Challenge.Challenge_.Span, + TestRequest.DefaultNonce); + + return (opening, VectorChannel.HelloMessage(opening, proof)); + } + private static string NewId() { return Guid.NewGuid().ToString("D"); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs index dace2966ce..b7e832736e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs @@ -95,6 +95,21 @@ public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() Assert.Equal(sentBytes, opening.ChallengeBytes.ToArray()); } + // CONTRACT.md §7.4: a stream closes no later than its certificate's notAfter, even while it waits for its Hello, + // so the opening gives the host that time. + // The vector file states it in Unix seconds, independently of any time zone. + [Fact] + public async Task OpeningGivesTheNotAfterOfItsCertificate() + { + TestAuthority authority = new(TestAuthority.DefaultNow); + + ChannelOpening opening = await OpenAsync(authority, TestRequest.DefaultNonce); + + Assert.Equal( + TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter, + opening.CertificateNotAfter); + } + private static async Task OpenAsync(TestAuthority authority, string nonce) { SignedHttpRequest request = authority.Valid(AgentOperation.Connect, nonce: nonce).ToHttpRequest(); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestChannelProof.cs new file mode 100644 index 0000000000..a70a9afcfa --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestChannelProof.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Channel proofs that a test signs as an agent would, built from CONTRACT.md §7.3 step 3 rather than by the library. +/// +internal static class TestChannelProof +{ + /// + /// Signs the domain, a zero byte, the challenge and the connect nonce with the key, as raw r‖s. + /// + /// + /// ECDSA signing is randomized, so each call returns another valid proof. + /// + public static byte[] Sign(KeyVector key, ReadOnlySpan challenge, string connectNonce) + { + byte[] input = + [ + .. "devolutions-agent-identity/v1/channel-proof"u8, + 0x00, + .. challenge, + .. Encoding.UTF8.GetBytes(connectNonce), + ]; + + using ECDsa privateKey = ECDsa.Create(); + privateKey.ImportPkcs8PrivateKey(key.PrivateKeyPkcs8, out _); + + return privateKey.SignData( + input, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs index 22599e3012..117f3e992e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs @@ -6,11 +6,13 @@ namespace Devolutions.AgentIdentity; public static class ChannelHandshake { /// - /// How long the host waits for the first agent message after it sends the challenge: 10 seconds. + /// How long the agent has to answer the challenge after the opening request is verified: 10 seconds. /// /// /// When no message arrives in that time, the host calls /// and closes the stream with its error. + /// also fails a message that it reads later than that, + /// so a late timer cannot let one through. /// public static TimeSpan HelloTimeout { get; } = TimeSpan.FromSeconds(10); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs index d33f103b1f..c306c79bfc 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs @@ -62,10 +62,14 @@ private ChannelHandshakeResult( public ServerMessage? Welcome { get; } /// - /// The error to close the stream with, when the handshake failed: - /// when the agent did not prove the key in time, - /// or when its proof verified but its metadata is invalid. + /// The error to close the stream with, when the handshake failed. /// + /// + /// It is when the certificate that opened the stream + /// expired during the handshake, and when the agent proved the + /// key in time but sent invalid metadata. + /// Every other failure is . + /// public AgentIdentityError? Error { get; } /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs index 37c602be7c..546639d471 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs @@ -21,9 +21,14 @@ namespace Devolutions.AgentIdentity; /// public sealed class ChannelOpening { + private const string ExpiredDetail = "The certificate that opened the stream expired during the handshake."; + private static readonly AgentIdentityError SignatureInvalid = AgentIdentityError.From(AgentIdentityErrorCode.SignatureInvalid); + private static readonly AgentIdentityError CertificateExpired = + AgentIdentityError.From(AgentIdentityErrorCode.CertificateExpired); + private readonly TimeProvider _timeProvider; // Set by the first call to VerifyHello or HelloTimedOut. @@ -34,6 +39,7 @@ internal ChannelOpening( string connectNonce, Guid challengeId, byte[] challenge, + DateTimeOffset openedAt, TimeProvider timeProvider) { Certificate = certificate; @@ -45,6 +51,7 @@ internal ChannelOpening( Id = ChallengeId, Challenge = new ChallengePayload { Challenge_ = ByteString.CopyFrom(challenge) }, }; + OpenedAt = openedAt; _timeProvider = timeProvider; } @@ -58,6 +65,16 @@ internal ChannelOpening( /// public string CertificateThumbprint => Certificate.Thumbprint; + /// + /// The notAfter of the certificate that signed the opening request. + /// + /// + /// A stream closes no later than this time, with , + /// so the host closes a stream still waiting for its Hello at this time with that error. + /// It is a time, not an identity, which stays hidden until the agent signs the challenge. + /// + public DateTimeOffset CertificateNotAfter => Certificate.NotAfter; + /// /// The certificate that signed the opening request, whose key must also sign the challenge. /// @@ -82,15 +99,28 @@ internal ChannelOpening( /// internal ReadOnlyMemory ChallengeBytes { get; } + /// + /// The instant the opening request was verified, + /// from which the agent has to answer the challenge. + /// + internal DateTimeOffset OpenedAt { get; } + /// /// Verifies the first message that the agent sent on the stream, as CONTRACT.md §7.3 step 4 specifies. /// /// - /// The message must be a Hello whose correlation_id is the id of , + /// The method reads the clock once, and judges the deadlines of the stream before the message, + /// since the stream closes at either one whatever the message holds. + /// After , the handshake fails with + /// . + /// More than after the opening request was verified, it fails with + /// , even when the host's own timer let the message through. + /// The message must then be a Hello whose correlation_id is the id of , /// and whose proof verifies with the key of the certificate that signed the opening request; /// otherwise the handshake fails with . /// Only then is the metadata checked, and metadata that CONTRACT.md §3 rejects fails the handshake with /// . + /// The Welcome of a successful handshake carries the time of that one reading. /// The first call to this method or to ends the handshake, whatever its outcome. /// /// The first message that the agent sent on the stream. @@ -101,6 +131,18 @@ public ChannelHandshakeResult VerifyHello(AgentMessage firstMessage) ArgumentNullException.ThrowIfNull(firstMessage); EndHandshake(); + DateTimeOffset now = _timeProvider.GetUtcNow(); + + if (HasExpired(now)) + { + return ChannelHandshakeResult.Failure(CertificateExpired, ExpiredDetail); + } + + if (now - OpenedAt > ChannelHandshake.HelloTimeout) + { + return ChannelHandshakeResult.Failure(SignatureInvalid, "The Hello arrived after the timeout."); + } + if (firstMessage.PayloadCase != AgentMessage.PayloadOneofCase.Hello) { return ChannelHandshakeResult.Failure(SignatureInvalid, "The first message on the stream is not a Hello."); @@ -137,7 +179,7 @@ public ChannelHandshakeResult VerifyHello(AgentMessage firstMessage) { Id = Guid.NewGuid().ToString("D"), CorrelationId = firstMessage.Id, - Welcome = new WelcomePayload { ServerTime = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()) }, + Welcome = new WelcomePayload { ServerTime = Timestamp.FromDateTimeOffset(now) }, }; return ChannelHandshakeResult.Success( @@ -148,9 +190,12 @@ public ChannelHandshakeResult VerifyHello(AgentMessage firstMessage) /// /// Ends the handshake of a stream on which no agent message arrived within - /// , with . + /// . /// /// + /// The handshake fails with , + /// or with when the clock reads a time after + /// . /// The host also calls it when the agent ends the stream without sending a message. /// /// The handshake has already ended. @@ -158,7 +203,9 @@ public ChannelHandshakeResult HelloTimedOut() { EndHandshake(); - return ChannelHandshakeResult.Failure(SignatureInvalid, "No Hello arrived before the timeout."); + return HasExpired(_timeProvider.GetUtcNow()) + ? ChannelHandshakeResult.Failure(CertificateExpired, ExpiredDetail) + : ChannelHandshakeResult.Failure(SignatureInvalid, "No Hello arrived before the timeout."); } private void EndHandshake() @@ -172,4 +219,10 @@ private void EndHandshake() throw new InvalidOperationException("The handshake of this channel opening has already ended."); } } + + // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter. + private bool HasExpired(DateTimeOffset now) + { + return now > Certificate.NotAfter; + } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs index 77f381f253..880518c6c3 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs @@ -128,6 +128,7 @@ public async ValueTask AuthenticateAsync( /// A verified opening does not authenticate the stream: /// the agent must still sign , /// which checks. + /// The agent has to do so, counted from the end of this verification. /// /// The request method, as received. /// The signature-input metadata entries in received order. @@ -166,8 +167,18 @@ public async ValueTask AuthenticateChannelOpeningAsync( byte[] challenge = new byte[ChallengeLength]; _randomNumberGenerator.GetBytes(challenge); + // The Hello timeout runs from the end of verification rather than from the reading that judged the request, + // so that time spent in the registry and the nonce store does not shorten the agent's time to answer. + DateTimeOffset openedAt = _timeProvider.GetUtcNow(); + return ChannelOpeningResult.Success( - new ChannelOpening(verification.Certificate, verification.Nonce, Guid.NewGuid(), challenge, _timeProvider)); + new ChannelOpening( + verification.Certificate, + verification.Nonce, + Guid.NewGuid(), + challenge, + openedAt, + _timeProvider)); } private async ValueTask VerifyAsync( From 91e234ea78c18cf4705c579e151f662abef823e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:50:09 +0900 Subject: [PATCH 25/93] feat(dotnet-agent-identity): re-check the registry in the handshake The identity of a stream stays hidden until its proof verifies, so the host cannot close a stream still in its handshake when its identity is revoked or deleted or its certificate retired, and would authenticate it anyway, although revocation must block authentication. ChannelOpening.VerifyHelloAsync replaces VerifyHello. It reads the clock, then the certificate registry once, and checks in the order of the signed-request rules: a certificate no longer registered is identity_unknown and a revoked identity identity_revoked, then come expiry, the Hello deadline, the message and the metadata as before. A certificate that a confirm retired during the handshake still authenticates and is reported as retired, so the host reconnects the stream as for any retired certificate rather than send a healthy agent the terminal identity_unknown. The handshake ends before the registry lookup, and a registry answer for another certificate or identity throws, as in request authentication. HelloTimedOut is unchanged. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AuthenticationTypeTests.cs | 2 +- .../ChannelHandshakeTests.cs | 342 ++++++++++++++---- .../ChannelHandshakeVectorTests.cs | 3 +- .../TestDoubles.cs | 22 +- .../VectorChannel.cs | 35 +- .../AuthenticatedIdentity.cs | 9 +- .../ChannelHandshake.cs | 2 +- .../ChannelHandshakeResult.cs | 19 +- .../ChannelOpening.cs | 95 ++++- .../RequestAuthenticator.cs | 5 +- 10 files changed, 427 insertions(+), 107 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs index f8b0293784..aac1962a75 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs @@ -58,7 +58,7 @@ public void OnlyAuthenticationProducesTheseTypes() "AuthenticationResult.get_Identity", "ChannelHandshakeResult.get_Identity", "ChannelOpening.HelloTimedOut", - "ChannelOpening.VerifyHello", + "ChannelOpening.VerifyHelloAsync", "ChannelOpeningResult.get_Opening", "RequestAuthenticator.AuthenticateAsync", "RequestAuthenticator.AuthenticateChannelOpeningAsync", diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs index e73a84510a..48acc8124c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs @@ -53,40 +53,47 @@ public class ChannelHandshakeTests ["invalid metadata"] = message => message.Hello.Metadata.Add("Hostname", "device-a"), }; - // The times at which VerifyHello runs on the vector stream, each breaking a rule that it checks on its clock - // reading before it reads the message. - // The certificate of device-a expires 70 days after the vector opening, so a reading after its notAfter is also - // after the Hello timeout. - private static readonly Dictionary> ClockFailures = new() + // Answers that the registry must never give for the thumbprint of the certificate that opened the stream. + private static readonly Dictionary> WrongRegistryAnswers = new() { - ["after the certificate's notAfter"] = () => DeviceANotAfter.AddTicks(1), - ["after the Hello timeout"] = () => VectorChannel.OpeningTime.AddSeconds(10).AddTicks(1), + ["another certificate"] = () => + new RegisteredCertificate(DeviceA.IdentityId, DeviceB.Certificate, CertificateStatus.Current, false), + ["the certificate for another identity"] = () => + new RegisteredCertificate(DeviceB.IdentityId, DeviceA.Certificate, CertificateStatus.Current, false), }; // Calls that end the handshake of an opening, one per outcome. - private static readonly Dictionary> HandshakeEnds = new() - { - ["a valid Hello"] = opening => opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)), - ["a Hello with the proof of another device"] = opening => - opening.VerifyHello(VectorChannel.HelloMessage(opening, OtherKeyProof)), - ["a Hello with invalid metadata"] = opening => - opening.VerifyHello(Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"])), - ["no Hello in time"] = opening => opening.HelloTimedOut(), - }; + private static readonly Dictionary>> HandshakeEnds = + new() + { + ["a valid Hello"] = opening => opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)), + ["a Hello with the proof of another device"] = opening => + opening.VerifyHelloAsync(VectorChannel.HelloMessage(opening, OtherKeyProof)), + ["a Hello with invalid metadata"] = opening => opening.VerifyHelloAsync( + Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"])), + ["no Hello in time"] = opening => ValueTask.FromResult(opening.HelloTimedOut()), + }; public static TheoryData SignatureFailureNames => new(SignatureFailures.Keys); - // Each clock failure with each message failure, and the error of the clock failure. - public static TheoryData ClockAndMessageFailures + public static TheoryData ScenarioNames => new(Scenarios.Keys); + + public static TheoryData WrongRegistryAnswerNames => new(WrongRegistryAnswers.Keys); + + // Each failure decided before the message, with each failure that the handshake checks after it. + public static TheoryData EarlyAndLaterFailures { get { - TheoryData cases = new(); + TheoryData cases = new(); + string[] early = [.. EarlyFailures.Keys]; - foreach (string message in MessageFailures.Keys) + for (int i = 0; i < early.Length; i++) { - cases.Add("after the certificate's notAfter", message, AgentIdentityErrorCode.CertificateExpired); - cases.Add("after the Hello timeout", message, AgentIdentityErrorCode.SignatureInvalid); + foreach (string later in early.Skip(i + 1).Concat(MessageFailures.Keys)) + { + cases.Add(early[i], later); + } } return cases; @@ -132,11 +139,46 @@ public static TheoryData HandshakeEndsThenAnotherCall Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"]), }; + // The failures that the handshake decides from the registry and the clock before it reads the message, + // in the order it checks them, with the code and the gRPC status that close the stream (CONTRACT.md §7.4). + // The certificate of device-a expires 70 days after the vector opening, so a reading after its notAfter is also + // after the Hello timeout. + private static OrderedDictionary + EarlyFailures => new() + { + ["the certificate is no longer registered"] = ( + new Scenario { CertificateRemoved = true }, + AgentIdentityErrorCode.IdentityUnknown, + StatusCode.Unauthenticated), + ["the identity is revoked"] = ( + new Scenario { IdentityRevoked = true }, + AgentIdentityErrorCode.IdentityRevoked, + StatusCode.PermissionDenied), + ["the certificate has expired"] = ( + new Scenario { HandshakeTime = DeviceANotAfter.AddTicks(1) }, + AgentIdentityErrorCode.CertificateExpired, + StatusCode.Unauthenticated), + ["the Hello is late"] = ( + new Scenario { HandshakeTime = VectorChannel.OpeningTime.AddSeconds(10).AddTicks(1) }, + AgentIdentityErrorCode.SignatureInvalid, + StatusCode.Unauthenticated), + }; + + // Every scenario by name: a valid handshake, then each early failure and each message failure. + private static Dictionary Scenarios => + new[] { KeyValuePair.Create("a valid Hello", new Scenario()) } + .Concat(EarlyFailures.Select(failure => KeyValuePair.Create(failure.Key, failure.Value.Scenario))) + .Concat(MessageFailures.Select( + failure => KeyValuePair.Create(failure.Key, new Scenario { FirstMessage = failure.Value }))) + .ToDictionary(); + // A raw 64-byte signature over the right bytes, by the key of device-b. private static byte[] OtherKeyProof => TestVectors.Instance.ChannelProof.Case("wrong_key").Proof; private static KeyVector DeviceA => TestVectors.Instance.Key("device-a"); + private static KeyVector DeviceB => TestVectors.Instance.Key("device-b"); + // The notAfter of the certificate of device-a, which opens every stream of these tests, // as the vector file states it in Unix seconds, independently of any time zone. private static DateTimeOffset DeviceANotAfter => @@ -161,7 +203,7 @@ public async Task HelloWithInvalidMetadataIsInvalidRequest() AgentMessage hello = VectorChannel.ValidHelloMessage(opening); LaterFailures["invalid metadata"](hello); - ChannelHandshakeResult result = opening.VerifyHello(hello); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); AssertFailed(result, AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument); } @@ -185,26 +227,154 @@ public async Task EarlierFailureWins(string earlier, string later) Assert.Equal(earlierAlone.FailureDetail, result.FailureDetail); } - // CONTRACT.md §7.4 and §7.3 step 4: a stream closes at its certificate's notAfter and after the Hello timeout, - // so a Hello that comes later fails for that reason, whatever it holds. - // The certificate comes first: a reading after its notAfter is also after the timeout here. - // The timeout is signature_invalid like the message failures, and comes before all of them, metadata included. + // CONTRACT.md §6 step 4 and §7.4: the registry and the clock decide before the message, in this order: + // a certificate no longer registered, a revoked identity, an expired certificate, then a late Hello. + // A stream that fails one of them is closed for that reason, whatever the message holds. + // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. [Theory] - [MemberData(nameof(ClockAndMessageFailures))] - public async Task ClockFailureWinsOverMessageFailure( - string clock, - string message, - AgentIdentityErrorCode expectedCode) + [MemberData(nameof(EarlyAndLaterFailures))] + public async Task EarlyFailureWinsOverLaterFailure(string early, string later) { - ChannelHandshakeResult clockAlone = - await VerifyAtAsync(ClockFailures[clock](), VectorChannel.ValidHelloMessage); - ChannelHandshakeResult messageAlone = await VerifyAsync(MessageFailures[message]); + (Scenario earlyScenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = EarlyFailures[early]; + ChannelHandshakeResult earlyAlone = await HandshakeAsync(earlyScenario); + ChannelHandshakeResult laterAlone = await HandshakeAsync(Scenarios[later]); - ChannelHandshakeResult result = await VerifyAtAsync(ClockFailures[clock](), MessageFailures[message]); + ChannelHandshakeResult result = await HandshakeAsync(earlyScenario.And(Scenarios[later])); - Assert.NotEqual(messageAlone.FailureDetail, clockAlone.FailureDetail); - AssertFailed(result, expectedCode, StatusCode.Unauthenticated); - Assert.Equal(clockAlone.FailureDetail, result.FailureDetail); + Assert.NotEqual(laterAlone.FailureDetail, earlyAlone.FailureDetail); + AssertFailed(result, expectedCode, expectedStatus); + Assert.Equal(earlyAlone.FailureDetail, result.FailureDetail); + } + + // The shared intent, security invariant 5: revocation blocks authentication, including of a stream whose identity + // the host cannot know yet. + // CONTRACT.md §7.4: PERMISSION_DENIED with the trailers `error-code: identity_revoked` and `server-time`. + [Fact] + public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() + { + DateTimeOffset serverTime = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + + ChannelHandshakeResult result = await HandshakeAsync(new Scenario { IdentityRevoked = true }); + + AgentIdentityError error = + AssertFailed(result, AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied); + Assert.Equal( + [("error-code", "identity_revoked"), ("server-time", "2026-09-24T12:00:00Z")], + error.ToRpcException(serverTime).Trailers.Select(entry => (entry.Key, entry.Value))); + } + + // CONTRACT.md §6 step 4: a certificate that is no longer registered, or whose identity was deleted, + // is identity_unknown. + [Fact] + public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() + { + ChannelHandshakeResult result = await HandshakeAsync(new Scenario { CertificateRemoved = true }); + + AssertFailed(result, AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated); + } + + // CONTRACT.md §7.4: when a confirm retires the certificate of a stream, the stream gets Reconnect and closes later, + // so a stream whose certificate was retired during the handshake authenticates and reports the retirement. + // Failing it would send a healthy agent the terminal identity_unknown. + [Fact] + public async Task CertificateRetiredDuringTheHandshakeAuthenticatesAsRetired() + { + ChannelHandshakeResult result = await HandshakeAsync(new Scenario { Status = CertificateStatus.Retired }); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(CertificateStatus.Retired, result.Identity.CertificateStatus); + } + + // CONTRACT.md §5.5: a confirm makes a pending certificate current, + // and the identity reports the status that the registry holds when the handshake ends. + [Fact] + public async Task ConfirmDuringTheHandshakeIsReported() + { + VectorAuthority authority = new(); + ChannelOpening opening = await VectorChannel.OpenAsync(authority, status: CertificateStatus.Pending); + VectorChannel.RegisterDevice(authority.Registry, CertificateStatus.Current, identityRevoked: false); + + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(CertificateStatus.Current, result.Identity.CertificateStatus); + } + + // A retired certificate passes the handshake, but the revocation of its identity still blocks it. + [Fact] + public async Task RevocationWinsOverARetiredCertificate() + { + ChannelHandshakeResult result = await HandshakeAsync( + new Scenario { Status = CertificateStatus.Retired, IdentityRevoked = true }); + + AssertFailed(result, AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied); + } + + // Whatever the outcome, the handshake reads the registry once, for the certificate that opened the stream. + [Theory] + [MemberData(nameof(ScenarioNames))] + public async Task HandshakeReadsTheRegistryOnce(string scenario) + { + VectorAuthority authority = new(); + + await HandshakeAsync(Scenarios[scenario], authority); + + // The lookup of the opening, then the one of the handshake. + Assert.Equal([DeviceA.Thumbprint, DeviceA.Thumbprint], authority.Registry.Lookups); + } + + // The registry answers a thumbprint with the certificate issued under it, for its one identity, + // so another answer is a host bug, which must fail as a server error rather than draw an outcome the agent acts on. + [Theory] + [MemberData(nameof(WrongRegistryAnswerNames))] + public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string answer) + { + VectorAuthority authority = new(); + ChannelOpening opening = await VectorChannel.OpenAsync(authority); + authority.Registry.Register(opening.CertificateThumbprint, WrongRegistryAnswers[answer]()); + + await Assert.ThrowsAsync( + () => opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)).AsTask()); + } + + // The handshake ends when VerifyHelloAsync is called, not when the registry answers, + // so a Hello timer that fires during the lookup cannot end the handshake a second time. + [Fact] + public async Task HandshakeEndsBeforeTheRegistryAnswers() + { + VectorAuthority authority = new(); + ChannelOpening opening = await VectorChannel.OpenAsync(authority); + TaskCompletionSource answer = new(TaskCreationOptions.RunContinuationsAsynchronously); + authority.Registry.Gate = answer.Task; + ValueTask verifying = + opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); + Assert.False(verifying.IsCompleted); + + Assert.Throws(() => opening.HelloTimedOut()); + + answer.SetResult(); + Assert.True((await verifying).Succeeded); + } + + // CONTRACT.md §6 reads the clock before the registry, and so does the handshake, + // so a Hello that arrives in time stays in time however long the registry takes to answer. + [Fact] + public async Task SlowRegistryDoesNotMakeAHelloLate() + { + VectorAuthority authority = new(); + FixedTimeProvider clock = new(VectorChannel.OpeningTime); + ChannelOpening opening = await VectorChannel.OpenAsync(authority, clock); + TaskCompletionSource answer = new(TaskCreationOptions.RunContinuationsAsynchronously); + authority.Registry.Gate = answer.Task; + clock.Now = VectorChannel.OpeningTime.AddSeconds(10); + ValueTask verifying = + opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); + + clock.Now = clock.Now.AddTicks(1); + answer.SetResult(); + ChannelHandshakeResult result = await verifying; + + Assert.True(result.Succeeded, result.FailureDetail); } // CONTRACT.md §7.3 step 4: the server verifies the proof with the key of the certificate that authenticated the @@ -217,7 +387,7 @@ public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() TestVectors.Instance.HttpSignature.RegisteredCertificate(TestVectors.Instance.ChannelProof.Device); ChannelOpening opening = await VectorChannel.OpenAsync(); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); AuthenticatedIdentity identity = result.Identity; @@ -238,7 +408,7 @@ public async Task IdentityHasTheStatusOfTheOpeningCertificate() { ChannelOpening opening = await VectorChannel.OpenAsync(status: CertificateStatus.Pending); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); Assert.Equal(CertificateStatus.Pending, result.Identity.CertificateStatus); @@ -250,7 +420,7 @@ public async Task ValidHelloYieldsItsMetadata() { ChannelOpening opening = await VectorChannel.OpenAsync(); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); Assert.Equal(VectorChannel.ValidMetadata, result.Metadata); @@ -266,7 +436,7 @@ public async Task HelloWithoutMetadataSucceeds() AgentMessage hello = VectorChannel.ValidHelloMessage(opening); hello.Hello.Metadata.Clear(); - ChannelHandshakeResult result = opening.VerifyHello(hello); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); Assert.True(result.Succeeded, result.FailureDetail); Assert.Empty(result.Metadata); @@ -282,7 +452,7 @@ public async Task WelcomeAnswersTheHello() AgentMessage hello = VectorChannel.ValidHelloMessage(opening); hello.Id = NewId().ToUpperInvariant(); - ChannelHandshakeResult result = opening.VerifyHello(hello); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); Assert.True(result.Succeeded, result.FailureDetail); ServerMessage welcome = result.Welcome; @@ -304,7 +474,7 @@ public async Task WelcomeCarriesTheServerTimeOfTheHandshake() ChannelOpening opening = await VectorChannel.OpenAsync( new SteppingTimeProvider(opened, opened, answered, answered.AddDays(1).AddMilliseconds(500))); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); Timestamp serverTime = result.Welcome.Welcome.ServerTime; @@ -322,7 +492,7 @@ public async Task HandshakeChecksTheChallengeAsSent() opening.Challenge.Id = NewId(); opening.Challenge.Challenge.Challenge_ = ByteString.CopyFrom(new byte[32]); - ChannelHandshakeResult result = opening.VerifyHello(hello); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); Assert.True(result.Succeeded, result.FailureDetail); } @@ -334,7 +504,7 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() { (ChannelOpening opening, AgentMessage hello) = await OpenBeforeNotAfterAsync(DeviceANotAfter); - ChannelHandshakeResult result = opening.VerifyHello(hello); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); Assert.True(result.Succeeded, result.FailureDetail); Assert.Equal(Timestamp.FromDateTimeOffset(DeviceANotAfter), result.Welcome.Welcome.ServerTime); @@ -347,7 +517,7 @@ public async Task HandshakeOneTickAfterTheCertificateNotAfterIsCertificateExpire { (ChannelOpening opening, AgentMessage hello) = await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(1)); - ChannelHandshakeResult result = opening.VerifyHello(hello); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); AssertFailed(result, AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated); } @@ -396,7 +566,7 @@ public async Task HelloExactlyAtTheTimeoutSucceeds() ChannelOpening opening = await VectorChannel.OpenAsync( new SteppingTimeProvider(opened, opened, answered, answered.AddTicks(1))); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime); @@ -410,7 +580,7 @@ public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() ChannelOpening opening = await VectorChannel.OpenAsync( new SteppingTimeProvider(opened, opened, opened.AddSeconds(10).AddTicks(1))); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } @@ -426,7 +596,7 @@ public async Task HelloTimeoutRunsFromTheEndOfTheOpening() ChannelOpening opening = await VectorChannel.OpenAsync( new SteppingTimeProvider(judged, verified, verified.AddSeconds(10))); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.ValidHelloMessage(opening)); + ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); } @@ -438,9 +608,9 @@ public async Task HelloTimeoutRunsFromTheEndOfTheOpening() public async Task HandshakeEndsAtTheFirstCall(string first, string second) { ChannelOpening opening = await VectorChannel.OpenAsync(); - HandshakeEnds[first](opening); + await HandshakeEnds[first](opening); - Assert.Throws(() => HandshakeEnds[second](opening)); + await Assert.ThrowsAsync(async () => await HandshakeEnds[second](opening)); } // CONTRACT.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure @@ -456,7 +626,7 @@ public async Task FailureClosesTheStreamWithTheContractStatusAndTrailers( { DateTimeOffset serverTime = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); ChannelOpening opening = await VectorChannel.OpenAsync(); - ChannelHandshakeResult result = HandshakeEnds[end](opening); + ChannelHandshakeResult result = await HandshakeEnds[end](opening); Assert.False(result.Succeeded); RpcException exception = result.Error.ToRpcException(serverTime); @@ -467,7 +637,8 @@ public async Task FailureClosesTheStreamWithTheContractStatusAndTrailers( exception.Trailers.Select(entry => (entry.Key, entry.Value))); } - private static void AssertFailed( + // Asserts that the handshake failed with the code and the gRPC status, and returns its error. + private static AgentIdentityError AssertFailed( ChannelHandshakeResult result, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) @@ -479,6 +650,7 @@ private static void AssertFailed( Assert.Null(result.Identity); Assert.Null(result.Metadata); Assert.Null(result.Welcome); + return result.Error; } private static AgentMessage Altered(AgentMessage message, Action alteration) @@ -488,20 +660,32 @@ private static AgentMessage Altered(AgentMessage message, Action a } // Opens the vector stream and verifies the first message built for it. - private static async Task VerifyAsync(Func firstMessage) + private static Task VerifyAsync(Func firstMessage) { - ChannelOpening opening = await VectorChannel.OpenAsync(); - return opening.VerifyHello(firstMessage(opening)); + return HandshakeAsync(new Scenario { FirstMessage = firstMessage }); } - // The same, with the clock reading handshakeTime for the handshake. - private static async Task VerifyAtAsync( - DateTimeOffset handshakeTime, - Func firstMessage) + // Opens the vector stream, changes the registry and the clock as the scenario says, then verifies its message. + private static async Task HandshakeAsync( + Scenario scenario, + VectorAuthority? authority = null) { + authority ??= new VectorAuthority(); DateTimeOffset opened = VectorChannel.OpeningTime; - ChannelOpening opening = await VectorChannel.OpenAsync(new SteppingTimeProvider(opened, opened, handshakeTime)); - return opening.VerifyHello(firstMessage(opening)); + ChannelOpening opening = await VectorChannel.OpenAsync( + authority, + new SteppingTimeProvider(opened, opened, scenario.HandshakeTime ?? opened)); + + if (scenario.CertificateRemoved) + { + authority.Registry.Unregister(opening.CertificateThumbprint); + } + else + { + VectorChannel.RegisterDevice(authority.Registry, scenario.Status, scenario.IdentityRevoked); + } + + return await opening.VerifyHelloAsync(scenario.FirstMessage(opening)); } // Opens a stream five seconds before the certificate of device-a expires, with a valid Hello signed for it, @@ -533,4 +717,34 @@ private static string NewId() { return Guid.NewGuid().ToString("D"); } + + // A handshake on the vector stream: what the registry holds and what the clock reads by then, + // and the first message, a valid Hello unless the scenario says otherwise. + private sealed record Scenario + { + public bool CertificateRemoved { get; init; } + + public bool IdentityRevoked { get; init; } + + public CertificateStatus Status { get; init; } = CertificateStatus.Current; + + // The time of the opening when null. + public DateTimeOffset? HandshakeTime { get; init; } + + public Func FirstMessage { get; init; } = VectorChannel.ValidHelloMessage; + + // This scenario with the changes of a later one to the registry and the clock, and with its first message. + // The only clock readings combined are after notAfter and after the timeout, and the first is after both. + public Scenario And(Scenario later) + { + return new Scenario + { + CertificateRemoved = CertificateRemoved || later.CertificateRemoved, + IdentityRevoked = IdentityRevoked || later.IdentityRevoked, + Status = Status, + HandshakeTime = HandshakeTime ?? later.HandshakeTime, + FirstMessage = later.FirstMessage, + }; + } + } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs index 42646c5d7c..fa80406f9c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs @@ -17,7 +17,8 @@ public async Task CaseMatchesItsExpectedValidity(string name) ChannelProofCaseVector vector = Vectors.Case(name); ChannelOpening opening = await VectorChannel.OpenAsync(); - ChannelHandshakeResult result = opening.VerifyHello(VectorChannel.HelloMessage(opening, vector.Proof)); + ChannelHandshakeResult result = + await opening.VerifyHelloAsync(VectorChannel.HelloMessage(opening, vector.Proof)); string outcome = result.Succeeded ? "ok" : result.Error.Code.ToWireValue(); Assert.Equal(vector.ExpectedValid ? "ok" : "signature_invalid", outcome); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs index 9983d4a14c..42aa5b48b5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs @@ -31,7 +31,7 @@ public override DateTimeOffset GetUtcNow() } /// -/// A registry over a dictionary, which records every lookup. +/// A registry over a dictionary, which records every lookup and can hold lookups until a test releases them. /// internal sealed class FakeCertificateRegistry : ICertificateRegistry { @@ -39,6 +39,11 @@ internal sealed class FakeCertificateRegistry : ICertificateRegistry public List Lookups { get; } = []; + /// + /// A task that every lookup waits for before it answers, when set. + /// + public Task? Gate { get; set; } + public void Register(string thumbprint, RegisteredCertificate certificate) { _certificates[thumbprint] = certificate; @@ -52,12 +57,23 @@ public void Register( Register(key.Thumbprint, new RegisteredCertificate(key.IdentityId, key.Certificate, status, identityRevoked)); } - public ValueTask FindByThumbprintAsync( + public void Unregister(string thumbprint) + { + _certificates.Remove(thumbprint); + } + + public async ValueTask FindByThumbprintAsync( string thumbprint, CancellationToken cancellationToken) { Lookups.Add(thumbprint); - return ValueTask.FromResult(_certificates.GetValueOrDefault(thumbprint)); + + if (Gate is { } gate) + { + await gate.WaitAsync(cancellationToken); + } + + return _certificates.GetValueOrDefault(thumbprint); } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs index ca169ec583..c8a7ea1973 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs @@ -38,21 +38,23 @@ internal static class VectorChannel /// /// The status to register the device's certificate with, instead of the status that the vector file gives it. /// - public static async Task OpenAsync(TimeProvider? clock = null, CertificateStatus? status = null) + public static Task OpenAsync(TimeProvider? clock = null, CertificateStatus? status = null) { - VectorAuthority authority = new(); + return OpenAsync(new VectorAuthority(), clock, status); + } + /// + /// Opens the stream through the public API, with the registry and the nonce store of , + /// which the test can then change before the handshake. + /// + public static async Task OpenAsync( + VectorAuthority authority, + TimeProvider? clock = null, + CertificateStatus? status = null) + { if (status is { } registeredStatus) { - RegisteredCertificateVector device = - TestVectors.Instance.HttpSignature.RegisteredCertificate(Vectors.Device); - authority.Registry.Register( - device.KeyId, - new RegisteredCertificate( - device.IdentityId, - device.Certificate, - registeredStatus, - device.IdentityRevoked)); + RegisterDevice(authority.Registry, registeredStatus, identityRevoked: false); } RequestAuthenticator authenticator = new( @@ -71,6 +73,17 @@ public static async Task OpenAsync(TimeProvider? clock = null, C return result.Opening; } + /// + /// Registers the device's certificate again, as the vector file registers it but with this status and revocation. + /// + public static void RegisterDevice(FakeCertificateRegistry registry, CertificateStatus status, bool identityRevoked) + { + RegisteredCertificateVector device = TestVectors.Instance.HttpSignature.RegisteredCertificate(Vectors.Device); + registry.Register( + device.KeyId, + new RegisteredCertificate(device.IdentityId, device.Certificate, status, identityRevoked)); + } + /// /// A Hello that answers the Challenge of with /// and . diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs index 5df6c15fe1..dba5f0289e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs @@ -36,9 +36,14 @@ internal AuthenticatedIdentity( public string CertificateThumbprint { get; } /// - /// The status of the certificate whose key signed the request: - /// or . + /// The status of the certificate whose key signed the request, as the registry held it when the request + /// authenticated. /// + /// + /// It is or , + /// or for an agent channel stream whose certificate a confirm retired + /// during the handshake. + /// public CertificateStatus CertificateStatus { get; } /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs index 117f3e992e..74c0523ae9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs @@ -11,7 +11,7 @@ public static class ChannelHandshake /// /// When no message arrives in that time, the host calls /// and closes the stream with its error. - /// also fails a message that it reads later than that, + /// also fails a message that it reads later than that, /// so a late timer cannot let one through. /// public static TimeSpan HelloTimeout { get; } = TimeSpan.FromSeconds(10); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs index c306c79bfc..358532e5e4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs @@ -33,8 +33,8 @@ private ChannelHandshakeResult( } /// - /// Whether the agent proved that it holds the key of the certificate that opened the stream, - /// and sent valid metadata. + /// Whether the stream authenticated: the certificate that opened it is still registered, unexpired and of an + /// unrevoked identity, and the agent proved in time that it holds its key and sent valid metadata. /// [MemberNotNullWhen(true, nameof(Identity), nameof(Metadata), nameof(Welcome))] [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] @@ -47,6 +47,11 @@ private ChannelHandshakeResult( /// /// The stream stays bound to the certificate that opened it, /// so the host closes the stream no later than . + /// When a confirm retired that certificate during the handshake, + /// is , + /// and the host treats the stream as every stream of a retired certificate (CONTRACT.md §7.4): + /// it sends Reconnect with reason certificate_rotated, + /// and closes the stream with OK 60 s later. /// public AuthenticatedIdentity? Identity { get; } @@ -65,15 +70,17 @@ private ChannelHandshakeResult( /// The error to close the stream with, when the handshake failed. /// /// - /// It is when the certificate that opened the stream - /// expired during the handshake, and when the agent proved the - /// key in time but sent invalid metadata. + /// It is when the certificate that opened the stream is no + /// longer registered or its identity was deleted, when the + /// identity was revoked, when the certificate expired + /// during the handshake, and when the agent proved the key in + /// time but sent invalid metadata. /// Every other failure is . /// public AgentIdentityError? Error { get; } /// - /// The rule that the first agent message broke, for host logs, when the handshake failed. + /// The rule that the handshake failed on, for host logs, when the handshake failed. /// /// /// It never repeats a value from the message, and quotes a metadata key only when the key itself is valid. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs index 546639d471..8c4a480bcf 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs @@ -16,7 +16,7 @@ namespace Devolutions.AgentIdentity; /// The host sends as the first message on the stream, /// and the stream is authenticated only once the agent signs that challenge. /// The identity stays hidden until then, so that nothing acts for it on the strength of the headers alone. -/// The host passes the first agent message to , +/// The host passes the first agent message to , /// or calls when none arrives within . /// public sealed class ChannelOpening @@ -29,9 +29,16 @@ public sealed class ChannelOpening private static readonly AgentIdentityError CertificateExpired = AgentIdentityError.From(AgentIdentityErrorCode.CertificateExpired); + private static readonly AgentIdentityError IdentityUnknown = + AgentIdentityError.From(AgentIdentityErrorCode.IdentityUnknown); + + private static readonly AgentIdentityError IdentityRevoked = + AgentIdentityError.From(AgentIdentityErrorCode.IdentityRevoked); + private readonly TimeProvider _timeProvider; + private readonly ICertificateRegistry _certificateRegistry; - // Set by the first call to VerifyHello or HelloTimedOut. + // Set by the first call to VerifyHelloAsync or HelloTimedOut. private int _handshakeEnded; internal ChannelOpening( @@ -40,7 +47,8 @@ internal ChannelOpening( Guid challengeId, byte[] challenge, DateTimeOffset openedAt, - TimeProvider timeProvider) + TimeProvider timeProvider, + ICertificateRegistry certificateRegistry) { Certificate = certificate; ConnectNonce = connectNonce; @@ -53,6 +61,7 @@ internal ChannelOpening( }; OpenedAt = openedAt; _timeProvider = timeProvider; + _certificateRegistry = certificateRegistry; } /// @@ -69,8 +78,9 @@ internal ChannelOpening( /// The notAfter of the certificate that signed the opening request. /// /// - /// A stream closes no later than this time, with , - /// so the host closes a stream still waiting for its Hello at this time with that error. + /// Once this time has passed, the host closes a stream still waiting for its Hello with + /// , + /// the error that returns only strictly after this time. /// It is a time, not an identity, which stays hidden until the agent signs the challenge. /// public DateTimeOffset CertificateNotAfter => Certificate.NotAfter; @@ -109,30 +119,66 @@ internal ChannelOpening( /// Verifies the first message that the agent sent on the stream, as CONTRACT.md §7.3 step 4 specifies. /// /// - /// The method reads the clock once, and judges the deadlines of the stream before the message, - /// since the stream closes at either one whatever the message holds. - /// After , the handshake fails with - /// . - /// More than after the opening request was verified, it fails with - /// , even when the host's own timer let the message through. + /// The method reads the clock once, then the certificate registry once, + /// because the host cannot close the stream for a change to its identity while that identity is still hidden. + /// In the order of CONTRACT.md §6 step 4, the handshake fails with + /// when the certificate that opened the stream is no longer + /// registered or its identity was deleted, with when the + /// identity is revoked, and with after + /// . + /// A certificate retired since the opening still passes, + /// and then reports . + /// More than after the opening request was verified, + /// the handshake fails with , + /// even when the host's own timer let the message through. /// The message must then be a Hello whose correlation_id is the id of , /// and whose proof verifies with the key of the certificate that signed the opening request; /// otherwise the handshake fails with . /// Only then is the metadata checked, and metadata that CONTRACT.md §3 rejects fails the handshake with /// . - /// The Welcome of a successful handshake carries the time of that one reading. - /// The first call to this method or to ends the handshake, whatever its outcome. + /// The Welcome of a successful handshake carries the time of the clock reading. + /// The first call to this method or to ends the handshake, whatever its outcome, + /// including an exception from the registry, which propagates. /// /// The first message that the agent sent on the stream. + /// A token that cancels the registry lookup. /// is null. - /// The handshake has already ended. - public ChannelHandshakeResult VerifyHello(AgentMessage firstMessage) + /// + /// The handshake has already ended, + /// or the registry returned another certificate, or the certificate for another identity. + /// + public async ValueTask VerifyHelloAsync( + AgentMessage firstMessage, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(firstMessage); EndHandshake(); DateTimeOffset now = _timeProvider.GetUtcNow(); + // The host cannot tie the stream to its identity before the proof verifies, + // so only this lookup sees a revocation, a deletion or a rotation since the opening. + RegisteredCertificate? registered = await _certificateRegistry + .FindByThumbprintAsync(Certificate.Thumbprint, cancellationToken) + .ConfigureAwait(false); + + // CONTRACT.md §6 step 4, except that a certificate retired since the opening still passes: + // its stream then closes as every stream of a retired certificate does (§7.4), + // whereas identity_unknown would end the identity for an agent that did nothing wrong. + if (registered is null) + { + return ChannelHandshakeResult.Failure( + IdentityUnknown, + "The certificate that opened the stream is no longer registered, or its identity was deleted."); + } + + CheckIsTheOpeningCertificate(registered); + + if (registered.IdentityRevoked) + { + return ChannelHandshakeResult.Failure(IdentityRevoked, "The identity was revoked after the stream opened."); + } + if (HasExpired(now)) { return ChannelHandshakeResult.Failure(CertificateExpired, ExpiredDetail); @@ -183,7 +229,7 @@ public ChannelHandshakeResult VerifyHello(AgentMessage firstMessage) }; return ChannelHandshakeResult.Success( - Certificate.ToIdentity(AgentOperation.Connect), + (Certificate with { Status = registered.Status }).ToIdentity(AgentOperation.Connect), metadata.Metadata, welcome); } @@ -225,4 +271,21 @@ private bool HasExpired(DateTimeOffset now) { return now > Certificate.NotAfter; } + + // The registry answers a thumbprint with the certificate that the authority issued under it, for its one identity, + // so another answer is a host bug, which fails as a server error rather than as a verdict on the agent. + private void CheckIsTheOpeningCertificate(RegisteredCertificate registered) + { + if (AgentIdentityProtocol.ComputeThumbprint(registered.CertificateDer.Span) != Certificate.Thumbprint) + { + throw new InvalidOperationException( + "The certificate registry returned a certificate whose thumbprint is not the one it was asked for."); + } + + if (registered.IdentityId != Certificate.IdentityId) + { + throw new InvalidOperationException( + "The certificate registry returned the certificate that opened the stream for another identity."); + } + } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs index 880518c6c3..68baa26171 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs @@ -127,7 +127,7 @@ public async ValueTask AuthenticateAsync( /// and its nonce is recorded only once every other step has passed. /// A verified opening does not authenticate the stream: /// the agent must still sign , - /// which checks. + /// which checks. /// The agent has to do so, counted from the end of this verification. /// /// The request method, as received. @@ -178,7 +178,8 @@ public async ValueTask AuthenticateChannelOpeningAsync( Guid.NewGuid(), challenge, openedAt, - _timeProvider)); + _timeProvider, + _certificateRegistry)); } private async ValueTask VerifyAsync( From d909f6d927641d7e474be12573a3702e04151b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 21:07:24 +0900 Subject: [PATCH 26/93] test(dotnet-agent-identity): read the new vector file members The shared vectors now give the private key of the valid CSR and a Content-Type field on some requests. The loader rejects unknown members, so that a change it does not model is noticed, and failed to load the file. Read both as optional members, typed like the fields beside them; the signature profile does not cover Content-Type. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity.Tests/TestVectors.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs index 88c4847929..0c51bb88ff 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs @@ -71,7 +71,12 @@ public sealed record KeyVector( public sealed record RootVector(byte[] Certificate, string Thumbprint, byte[] PublicKeySec1); -public sealed record CsrVector(byte[] Csr, string Expected, string? Description = null, byte[]? PublicKeySec1 = null); +public sealed record CsrVector( + byte[] Csr, + string Expected, + string? Description = null, + byte[]? PublicKeySec1 = null, + byte[]? PrivateKeyPkcs8 = null); public sealed record HttpSignatureVectors( IReadOnlyList Registered, @@ -123,12 +128,14 @@ public sealed record HttpSignatureCaseVector( } /// -/// The signature fields of a request, each a single field line; an absent field is null. +/// The fields of a request, each a single field line; an absent field is null. /// +/// A field that the signature profile does not cover. public sealed record HttpSignatureHeaders( [property: JsonPropertyName("signature-input")] string? SignatureInput = null, [property: JsonPropertyName("signature")] string? Signature = null, - [property: JsonPropertyName("content-digest")] string? ContentDigest = null); + [property: JsonPropertyName("content-digest")] string? ContentDigest = null, + [property: JsonPropertyName("content-type")] string? ContentType = null); /// Names of cases to run in order against one nonce store. /// The outcome of each step, as in . From 1966cd69dd7e32fbf19259be795ddacd2c60fb0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:25:44 +0900 Subject: [PATCH 27/93] refactor(dotnet-agent-identity): share the base URL rules The agent appends a path both to the product base URL of an enrollment token and to the agent channel URL of a config, and the contract states the same rules for both: an absolute https URL without a query or fragment. Move the token's URL check and normalization into an internal BaseUrl helper, unchanged, so that both URLs follow one implementation. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity/BaseUrl.cs | 49 +++++++++++++++++++ .../EnrollmentToken.cs | 34 +------------ 2 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/BaseUrl.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/BaseUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/BaseUrl.cs new file mode 100644 index 0000000000..a76be6b031 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/BaseUrl.cs @@ -0,0 +1,49 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Checks a base URL to which the agent appends a path, and writes it in one form. +/// +internal static class BaseUrl +{ + /// + /// Returns the URL in normalized absolute form without trailing slashes, + /// so that the path the agent appends follows exactly one slash. + /// + /// The URL text. + /// What the URL is, as error messages start, such as The product base URL. + /// The parameter that holds the URL. + /// + /// The URL is not an absolute https URL, or has user information, a query or a fragment. + /// + public static string Normalize(string url, string name, string paramName) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? parsed)) + { + throw new ArgumentException($"{name} is not an absolute URL.", paramName); + } + + if (parsed.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException($"{name} does not use the https scheme.", paramName); + } + + // UserInfo is empty for https://@host, so only the component with its delimiter shows the '@'. + if (parsed.GetComponents(UriComponents.UserInfo | UriComponents.KeepDelimiter, UriFormat.UriEscaped).Length != 0) + { + throw new ArgumentException($"{name} has user information.", paramName); + } + + // Query and Fragment include their delimiter, so an empty query or fragment still makes them non-empty. + if (parsed.Query.Length != 0) + { + throw new ArgumentException($"{name} has a query.", paramName); + } + + if (parsed.Fragment.Length != 0) + { + throw new ArgumentException($"{name} has a fragment.", paramName); + } + + return parsed.AbsoluteUri.TrimEnd('/'); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs index 5c652f1a17..953dbc0170 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs @@ -84,7 +84,7 @@ public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerato using (Utf8JsonWriter writer = new(json)) { writer.WriteStartObject(); - writer.WriteString("u", NormalizeProductBaseUrl(productBaseUrl)); + writer.WriteString("u", BaseUrl.Normalize(productBaseUrl, "The product base URL", nameof(productBaseUrl))); writer.WriteEndObject(); } @@ -126,36 +126,4 @@ internal static byte[] HashSecret(ReadOnlySpan secret) return SHA256.HashData(ascii); } - - private static string NormalizeProductBaseUrl(string productBaseUrl) - { - if (!Uri.TryCreate(productBaseUrl, UriKind.Absolute, out Uri? url)) - { - throw new ArgumentException("The product base URL is not an absolute URL.", nameof(productBaseUrl)); - } - - if (url.Scheme != Uri.UriSchemeHttps) - { - throw new ArgumentException("The product base URL does not use the https scheme.", nameof(productBaseUrl)); - } - - // UserInfo is empty for https://@host, so only the component with its delimiter shows the '@'. - if (url.GetComponents(UriComponents.UserInfo | UriComponents.KeepDelimiter, UriFormat.UriEscaped).Length != 0) - { - throw new ArgumentException("The product base URL has user information.", nameof(productBaseUrl)); - } - - // Query and Fragment include their delimiter, so an empty query or fragment still makes them non-empty. - if (url.Query.Length != 0) - { - throw new ArgumentException("The product base URL has a query.", nameof(productBaseUrl)); - } - - if (url.Fragment.Length != 0) - { - throw new ArgumentException("The product base URL has a fragment.", nameof(productBaseUrl)); - } - - return url.AbsoluteUri.TrimEnd('/'); - } } From 52c6ca3867d9d7f034f6e46137182acc5dc5193d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:35:19 +0900 Subject: [PATCH 28/93] feat(dotnet-agent-identity): add the identity config The enroll and check-in responses and the ConfigUpdate message all carry the identity's config, so every authority must write it the same way. IdentityConfig.Create takes the revision, an optional agent channel URL and optional product-specific fields, and fails with ArgumentException for anything the contract rejects. The URL must be an absolute https URL without user information, query or fragment, and is written without trailing slashes, since the agent appends the service path to it. A product field has a snake_case name that no contract member has, and a value other than null. ToUtf8Json writes version 1, the revision as an unsigned 64-bit integer, the URL only when present, and the product fields. The config is written once at creation, so that a value UTF-8 JSON cannot carry fails there rather than in a response. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConstructionTests.cs | 15 +- .../IdentityConfigTests.cs | 227 ++++++++++++++++++ .../IdentityConfig.cs | 200 +++++++++++++++ 3 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs index d870cca35d..706fa658e7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs @@ -3,18 +3,25 @@ namespace Devolutions.AgentIdentity.Tests; // An instance of each type below vouches that its value passed validation, -// so nothing but validation may create one: otherwise a host could pass unvalidated agent input in its place. +// so nothing but validation may create one: otherwise a host could pass an unchecked value in its place. public class ConstructionTests { - public static TheoryData ValidatedTypes => new( + private static readonly Type[] Validated = + [ typeof(AgentMetadata), typeof(CertificateSigningRequest), typeof(EnrollRequestBody), typeof(RenewRequestBody), - typeof(CheckInRequestBody)); + typeof(CheckInRequestBody), + ]; + + public static TheoryData ValidatedTypes => new(Validated); + + // IdentityConfig.Create checks the host's values and returns the config itself rather than a result. + public static TheoryData CheckedTypes => new([.. Validated, typeof(IdentityConfig)]); [Theory] - [MemberData(nameof(ValidatedTypes))] + [MemberData(nameof(CheckedTypes))] public void HasNoPublicOrProtectedConstructor(Type type) { IEnumerable reachable = type diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs new file mode 100644 index 0000000000..3046163ce1 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs @@ -0,0 +1,227 @@ +using System.Text.Json; + +namespace Devolutions.AgentIdentity.Tests; + +public class IdentityConfigTests +{ + // CONTRACT.md §5.2: `config` holds `version`, which is 1, `revision` and `agent_channel_url`. + // An agent reads both numbers as integers, so 1.0 or 3e0 would not do. + [Fact] + public void JsonHasTheContractMembers() + { + IdentityConfig config = IdentityConfig.Create(3, "https://host/dvls"); + + Dictionary members = Members(config.ToUtf8Json()); + + Assert.Equal(["agent_channel_url", "revision", "version"], members.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(JsonValueKind.Number, members["version"].ValueKind); + Assert.Equal("1", members["version"].GetRawText()); + Assert.Equal(JsonValueKind.Number, members["revision"].ValueKind); + Assert.Equal("3", members["revision"].GetRawText()); + Assert.Equal(JsonValueKind.String, members["agent_channel_url"].ValueKind); + Assert.Equal("https://host/dvls", members["agent_channel_url"].GetString()); + } + + // CONTRACT.md §5.2: `agent_channel_url` is absent when the server does not offer the channel, + // and §1 omits an absent field rather than writing null. + [Fact] + public void JsonOmitsAnAbsentAgentChannelUrl() + { + IdentityConfig config = IdentityConfig.Create(3); + + Dictionary members = Members(config.ToUtf8Json()); + + Assert.Equal(["revision", "version"], members.Keys.Order(StringComparer.Ordinal)); + Assert.Null(config.AgentChannelUrl); + } + + // CONTRACT.md §5.2: `revision` is an unsigned 64-bit integer, with no starting value the agent assumes. + // A double holds no more than 53 bits, so it would round the largest revisions. + [Theory] + [InlineData(0UL, "0")] + [InlineData(9007199254740993UL, "9007199254740993")] + [InlineData(18446744073709551615UL, "18446744073709551615")] + public void JsonWritesEveryRevisionExactly(ulong revision, string expected) + { + IdentityConfig config = IdentityConfig.Create(revision); + + Dictionary members = Members(config.ToUtf8Json()); + + Assert.Equal(expected, members["revision"].GetRawText()); + Assert.Equal(revision, config.Revision); + } + + // CONTRACT.md §5.2: `agent_channel_url` is an absolute `https` URL without a query or fragment. + // RFC 9110 §4.2.4: a sender does not generate user information in an `https` URI. + [Theory] + [InlineData("")] + [InlineData("/dvls")] + [InlineData("host/dvls")] + [InlineData("http://host/dvls")] + [InlineData("wss://host/dvls")] + [InlineData("https:///dvls")] + [InlineData("https://host/dvls?tenant=1")] + [InlineData("https://host/dvls?")] + [InlineData("https://host/dvls#top")] + [InlineData("https://host/dvls#")] + [InlineData("https://admin@host/dvls")] + [InlineData("https://@host/dvls")] + public void CreateRejectsAnotherAgentChannelUrl(string agentChannelUrl) + { + ArgumentException exception = + Assert.Throws(() => IdentityConfig.Create(1, agentChannelUrl)); + + Assert.Equal("agentChannelUrl", exception.ParamName); + } + + // CONTRACT.md §7.2: the agent prepends the URL path to `/devolutions.agent.channel.v1.AgentChannel/Connect`, + // so a URL ending in a slash would put two slashes in the gRPC path. + [Theory] + [InlineData("https://host/dvls", "https://host/dvls")] + [InlineData("https://host/dvls/", "https://host/dvls")] + [InlineData("https://host/", "https://host")] + [InlineData("https://host", "https://host")] + public void AgentChannelUrlEndsWithoutASlash(string agentChannelUrl, string expected) + { + IdentityConfig config = IdentityConfig.Create(1, agentChannelUrl); + + Assert.Equal(expected, Members(config.ToUtf8Json())["agent_channel_url"].GetString()); + Assert.Equal(expected, config.AgentChannelUrl); + } + + // CONTRACT.md §5.2: product-specific config fields, such as a PSU channel URL, may coexist with the others. + [Fact] + public void JsonHasTheProductFieldsBesideTheContractMembers() + { + const string ChannelUrl = "\"https://psu/channel\""; + const string Limits = """{"max_sessions": 4, "paths": ["a", "b"], "enabled": true, "ratio": 0.5}"""; + + IdentityConfig config = IdentityConfig.Create( + 3, + "https://host/dvls", + [new("psu_channel_url", Json(ChannelUrl)), new("policy_limits", Json(Limits))]); + + Dictionary members = Members(config.ToUtf8Json()); + Assert.Equal( + ["agent_channel_url", "policy_limits", "psu_channel_url", "revision", "version"], + members.Keys.Order(StringComparer.Ordinal)); + Assert.True(JsonElement.DeepEquals(Json(ChannelUrl), members["psu_channel_url"])); + Assert.True(JsonElement.DeepEquals(Json(Limits), members["policy_limits"])); + } + + // CONTRACT.md §1: JSON keys are snake_case, lowercase words of letters and digits joined by single underscores. + [Theory] + [InlineData("a")] + [InlineData("psu_channel_url")] + [InlineData("ipv4_address")] + [InlineData("tier_2")] + public void CreateAcceptsASnakeCaseProductFieldName(string name) + { + IdentityConfig config = IdentityConfig.Create(1, null, [new(name, Json("true"))]); + + Assert.Equal(JsonValueKind.True, Members(config.ToUtf8Json())[name].ValueKind); + } + + [Theory] + [InlineData("")] + [InlineData("Channel")] + [InlineData("psuChannel")] + [InlineData("psu-channel")] + [InlineData("psu channel")] + [InlineData("_psu")] + [InlineData("psu_")] + [InlineData("psu__channel")] + [InlineData("2fa")] + [InlineData("psu\n")] + [InlineData("caf\u00E9")] + public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) + { + AssertRejectsProductFields([new(name, Json("true"))]); + } + + // CONTRACT.md §5.2 defines these members, so a product field of one of these names would repeat it. + [Theory] + [InlineData("version")] + [InlineData("revision")] + [InlineData("agent_channel_url")] + public void CreateRejectsAProductFieldNamedAfterAContractMember(string name) + { + AssertRejectsProductFields([new(name, Json("2"))]); + } + + // RFC 8259 §4: with a repeated name, receivers disagree on the value, as CONTRACT.md §1 notes of request bodies. + [Fact] + public void CreateRejectsARepeatedProductFieldName() + { + AssertRejectsProductFields([new("psu_channel_url", Json("\"a\"")), new("psu_channel_url", Json("\"b\""))]); + } + + // CONTRACT.md §1: an absent field is omitted, never written as null. + [Fact] + public void CreateRejectsANullProductFieldValue() + { + AssertRejectsProductFields([new("psu_channel_url", Json("null"))]); + } + + // The default JsonElement holds no JSON value at all. + [Fact] + public void CreateRejectsAProductFieldWithoutAValue() + { + AssertRejectsProductFields([new("psu_channel_url", default)]); + } + + [Fact] + public void CreateRejectsAProductFieldWithoutAName() + { + AssertRejectsProductFields([new(null!, Json("true"))]); + } + + // RFC 8259 §8.1: JSON text exchanged between systems is UTF-8, which cannot encode an unpaired surrogate. + // Failing here keeps every later serialization of the config from failing. + [Fact] + public void CreateRejectsAProductFieldValueThatIsNotUnicodeText() + { + AssertRejectsProductFields([new("label", Json("""["ok", "\ud800"]"""))]); + } + + // A JsonElement reads from its JsonDocument, which the host may dispose once the config exists. + [Fact] + public void ConfigOutlivesTheDocumentOfItsProductFields() + { + const string Limits = """{"paths": ["a", "b"]}"""; + IdentityConfig config; + + using (JsonDocument document = JsonDocument.Parse(Limits)) + { + config = IdentityConfig.Create(1, null, [new("policy_limits", document.RootElement)]); + } + + Assert.True(JsonElement.DeepEquals(Json(Limits), Members(config.ToUtf8Json())["policy_limits"])); + } + + // Parses a JSON object with the BCL, never with the library, and fails on a repeated member name. + internal static Dictionary Members(byte[] utf8Json) + { + using JsonDocument document = JsonDocument.Parse(utf8Json); + Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind); + + return document.RootElement + .EnumerateObject() + .ToDictionary(member => member.Name, member => member.Value.Clone(), StringComparer.Ordinal); + } + + internal static JsonElement Json(string text) + { + using JsonDocument document = JsonDocument.Parse(text); + + return document.RootElement.Clone(); + } + + private static void AssertRejectsProductFields(KeyValuePair[] productFields) + { + ArgumentException exception = Assert.Throws( + () => IdentityConfig.Create(1, "https://host/dvls", productFields)); + + Assert.Equal("productFields", exception.ParamName); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs new file mode 100644 index 0000000000..66d33e620a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs @@ -0,0 +1,200 @@ +using System.Buffers; +using System.Text.Json; + +namespace Devolutions.AgentIdentity; + +/// +/// The config that the authority holds for an identity, which the agent receives in the enroll and +/// check-in responses and in ConfigUpdate messages. +/// +/// +/// Only creates instances, so an instance always serializes to a config that CONTRACT.md §5.2 +/// accepts. +/// The agent replaces its stored config only with a higher , +/// so the host creates the config with a higher revision whenever the identity's config changes. +/// +public sealed class IdentityConfig +{ + private const string VersionMember = "version"; + private const string RevisionMember = "revision"; + private const string AgentChannelUrlMember = "agent_channel_url"; + + // The schema of the config; the agent treats a config of another version as a transient failure. + private const int SchemaVersion = 1; + + private readonly byte[] _utf8Json; + + private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) + { + Revision = revision; + AgentChannelUrl = agentChannelUrl; + _utf8Json = utf8Json; + } + + /// + /// The revision of the config, which strictly increases for an identity whenever its config changes. + /// + public ulong Revision { get; } + + /// + /// The base URL of the agent channel, without trailing slashes, or null when the authority does not offer the + /// channel to the identity. + /// + public string? AgentChannelUrl { get; } + + /// + /// Creates a config with schema version 1. + /// + /// + /// The agent channel URL is written in normalized absolute form without trailing slashes, + /// since the agent appends the path of the channel service to it. + /// Each product-specific field has a snake_case name: + /// lowercase ASCII words of letters and digits joined by single underscores, the first starting with a letter. + /// No two fields have the same name, and none is named version, revision or + /// agent_channel_url. + /// Each value is any JSON value but null, + /// since an absent field is omitted rather than written as null. + /// The library looks inside a value only to check that UTF-8 JSON can carry it. + /// + /// The revision of the config. + /// + /// The absolute https base URL of the agent channel, such as https://host/dvls, + /// without user information, query or fragment; + /// null when the authority does not offer the channel to the identity, so that the agent opens none. + /// + /// The product-specific fields, if any. + /// + /// is not an absolute https URL, + /// or has user information, a query or a fragment; + /// or a product-specific field has no name, a name that is not snake_case or that the contract gives a member, + /// no value, the value null, or a value that UTF-8 JSON cannot carry, such as a lone surrogate. + /// + public static IdentityConfig Create( + ulong revision, + string? agentChannelUrl = null, + IEnumerable>? productFields = null) + { + string? normalizedUrl = agentChannelUrl is null + ? null + : BaseUrl.Normalize(agentChannelUrl, "The agent channel URL", nameof(agentChannelUrl)); + + return new IdentityConfig(revision, normalizedUrl, Write(revision, normalizedUrl, productFields ?? [])); + } + + /// + /// Writes the config as a UTF-8 JSON object with the members version, revision, + /// agent_channel_url when the authority offers the agent channel, and the product-specific fields. + /// + public byte[] ToUtf8Json() + { + return [.. _utf8Json]; + } + + // Writes the config once, when it is created, + // so that a value that UTF-8 JSON cannot carry fails then rather than in a response. + private static byte[] Write( + ulong revision, + string? agentChannelUrl, + IEnumerable> productFields) + { + ArrayBufferWriter buffer = new(); + + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WriteNumber(VersionMember, SchemaVersion); + writer.WriteNumber(RevisionMember, revision); + + if (agentChannelUrl is not null) + { + writer.WriteString(AgentChannelUrlMember, agentChannelUrl); + } + + WriteProductFields(writer, productFields); + writer.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + private static void WriteProductFields( + Utf8JsonWriter writer, + IEnumerable> productFields) + { + HashSet names = new(StringComparer.Ordinal); + int position = 0; + + foreach ((string name, JsonElement value) in productFields) + { + position++; + + if (name is null) + { + throw new ArgumentException($"Product field {position} has no name.", nameof(productFields)); + } + + // The name is quoted only once it is valid, so that a message never repeats arbitrary text. + if (!IsSnakeCase(name)) + { + throw new ArgumentException( + $"The name of product field {position} is not snake_case.", + nameof(productFields)); + } + + if (name is VersionMember or RevisionMember or AgentChannelUrlMember) + { + throw new ArgumentException( + $"The product field '{name}' would repeat a member of the config.", + nameof(productFields)); + } + + if (!names.Add(name)) + { + throw new ArgumentException($"The product fields repeat the name '{name}'.", nameof(productFields)); + } + + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + throw new ArgumentException( + $"The product field '{name}' has no value; an absent field is omitted, never null.", + nameof(productFields)); + } + + writer.WritePropertyName(name); + + try + { + value.WriteTo(writer); + } + catch (InvalidOperationException exception) + { + throw new ArgumentException( + $"The value of the product field '{name}' cannot be written as UTF-8 JSON.", + nameof(productFields), + exception); + } + } + } + + // Checks the name character by character: + // a .NET regular expression ending in $ would also accept a final line feed. + private static bool IsSnakeCase(string name) + { + if (name.Length == 0 || name[0] is not (>= 'a' and <= 'z') || name[^1] == '_') + { + return false; + } + + for (int i = 1; i < name.Length; i++) + { + char c = name[i]; + + if (!(c is (>= 'a' and <= 'z') or (>= '0' and <= '9') || (c == '_' && name[i - 1] != '_'))) + { + return false; + } + } + + return true; + } +} From 8f8e0b79a1353e6e4fb996c25bdcb6f27a17b738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:36:09 +0900 Subject: [PATCH 29/93] docs(dotnet-agent-identity): clarify two error outcomes IssuedKeyMatch.ForDeletedIdentity said that the key of a deleted identity gets identity_revoked whatever token enrolled it, but a deleted presented token gets token_invalid first, since it authenticates nothing but a replay. Say so. AgentIdentityError.MalformedBody did not say when a host uses it, though the body parsers return their own invalid_request. It is for a body that the host's framework cannot read or hand to the parsers, such as one that is not UTF-8 application/json. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity/AgentIdentityError.cs | 7 ++++++- .../Devolutions.AgentIdentity/IssuedKeyMatch.cs | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs index 5c08d2970d..a332a6c671 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs @@ -40,9 +40,14 @@ private AgentIdentityError(AgentIdentityErrorCode code, int httpStatusCode, stri public string Message { get; } /// - /// The error for a request body that is not valid JSON or does not have the expected shape: + /// The error for a request body that the host's framework cannot read or hand to the body parsers, + /// such as one that is not UTF-8 application/json, which CONTRACT.md §1 requires: /// 400 invalid_request. /// + /// + /// The body parsers, such as , report their own invalid_request + /// with a failure detail, so a body that they reject needs no other error. + /// public static AgentIdentityError MalformedBody { get; } = new(AgentIdentityErrorCode.InvalidRequest, 400, "The request body is malformed."); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs index 5990648438..83bcae23de 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs @@ -71,7 +71,9 @@ public static IssuedKeyMatch ForIdentity( /// /// /// After an identity is deleted, the authority keeps only the SPKI SHA-256 and the issuance time of its keys, - /// so the key gets identity_revoked whatever token enrolled the identity. + /// so refuses the key with identity_revoked whatever token enrolled + /// the identity. + /// A deleted presented token gets token_invalid instead, since it authenticates nothing but a replay. /// public static IssuedKeyMatch ForDeletedIdentity() { From 88ee3e69a8ae13a389ebd006ee5523a610f68738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:47:21 +0900 Subject: [PATCH 30/93] feat(dotnet-agent-identity): write the response bodies The trust-anchor, enroll, renew and check-in responses are agent-facing, so every authority must write them the same way. ResponseBodies writes each one as a UTF-8 JSON object with exactly the members of the contract. A certificate is its DER in canonical base64, and each published root also carries its thumbprint and its validity as RFC 3339 UTC timestamps. IDs are lowercase hyphenated UUIDs, and the nil UUID is refused, since it stands for no ID. A certificate chain must be leaf first: the issuer of each certificate must be the subject of the next, which RFC 5280 makes byte for byte equal. The config is written as IdentityConfig wrote it, and renewal_requested as a JSON boolean. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IdentityConfigTests.cs | 60 ++-- .../ResponseBodiesTests.cs | 276 ++++++++++++++++++ .../TestJson.cs | 72 +++++ .../IdentityConfig.cs | 5 + .../ResponseBodies.cs | 220 ++++++++++++++ 5 files changed, 595 insertions(+), 38 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestJson.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs index 3046163ce1..063673d789 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs @@ -11,9 +11,9 @@ public void JsonHasTheContractMembers() { IdentityConfig config = IdentityConfig.Create(3, "https://host/dvls"); - Dictionary members = Members(config.ToUtf8Json()); + Dictionary members = TestJson.Members(config.ToUtf8Json()); - Assert.Equal(["agent_channel_url", "revision", "version"], members.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(["agent_channel_url", "revision", "version"], TestJson.Names(members)); Assert.Equal(JsonValueKind.Number, members["version"].ValueKind); Assert.Equal("1", members["version"].GetRawText()); Assert.Equal(JsonValueKind.Number, members["revision"].ValueKind); @@ -29,9 +29,9 @@ public void JsonOmitsAnAbsentAgentChannelUrl() { IdentityConfig config = IdentityConfig.Create(3); - Dictionary members = Members(config.ToUtf8Json()); + Dictionary members = TestJson.Members(config.ToUtf8Json()); - Assert.Equal(["revision", "version"], members.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(["revision", "version"], TestJson.Names(members)); Assert.Null(config.AgentChannelUrl); } @@ -45,7 +45,7 @@ public void JsonWritesEveryRevisionExactly(ulong revision, string expected) { IdentityConfig config = IdentityConfig.Create(revision); - Dictionary members = Members(config.ToUtf8Json()); + Dictionary members = TestJson.Members(config.ToUtf8Json()); Assert.Equal(expected, members["revision"].GetRawText()); Assert.Equal(revision, config.Revision); @@ -85,7 +85,7 @@ public void AgentChannelUrlEndsWithoutASlash(string agentChannelUrl, string expe { IdentityConfig config = IdentityConfig.Create(1, agentChannelUrl); - Assert.Equal(expected, Members(config.ToUtf8Json())["agent_channel_url"].GetString()); + Assert.Equal(expected, TestJson.Members(config.ToUtf8Json())["agent_channel_url"].GetString()); Assert.Equal(expected, config.AgentChannelUrl); } @@ -99,14 +99,14 @@ public void JsonHasTheProductFieldsBesideTheContractMembers() IdentityConfig config = IdentityConfig.Create( 3, "https://host/dvls", - [new("psu_channel_url", Json(ChannelUrl)), new("policy_limits", Json(Limits))]); + [new("psu_channel_url", TestJson.Parse(ChannelUrl)), new("policy_limits", TestJson.Parse(Limits))]); - Dictionary members = Members(config.ToUtf8Json()); + Dictionary members = TestJson.Members(config.ToUtf8Json()); Assert.Equal( ["agent_channel_url", "policy_limits", "psu_channel_url", "revision", "version"], - members.Keys.Order(StringComparer.Ordinal)); - Assert.True(JsonElement.DeepEquals(Json(ChannelUrl), members["psu_channel_url"])); - Assert.True(JsonElement.DeepEquals(Json(Limits), members["policy_limits"])); + TestJson.Names(members)); + Assert.True(JsonElement.DeepEquals(TestJson.Parse(ChannelUrl), members["psu_channel_url"])); + Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), members["policy_limits"])); } // CONTRACT.md §1: JSON keys are snake_case, lowercase words of letters and digits joined by single underscores. @@ -117,9 +117,9 @@ public void JsonHasTheProductFieldsBesideTheContractMembers() [InlineData("tier_2")] public void CreateAcceptsASnakeCaseProductFieldName(string name) { - IdentityConfig config = IdentityConfig.Create(1, null, [new(name, Json("true"))]); + IdentityConfig config = IdentityConfig.Create(1, null, [new(name, TestJson.Parse("true"))]); - Assert.Equal(JsonValueKind.True, Members(config.ToUtf8Json())[name].ValueKind); + Assert.Equal(JsonValueKind.True, TestJson.Members(config.ToUtf8Json())[name].ValueKind); } [Theory] @@ -136,7 +136,7 @@ public void CreateAcceptsASnakeCaseProductFieldName(string name) [InlineData("caf\u00E9")] public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) { - AssertRejectsProductFields([new(name, Json("true"))]); + AssertRejectsProductFields([new(name, TestJson.Parse("true"))]); } // CONTRACT.md §5.2 defines these members, so a product field of one of these names would repeat it. @@ -146,21 +146,22 @@ public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) [InlineData("agent_channel_url")] public void CreateRejectsAProductFieldNamedAfterAContractMember(string name) { - AssertRejectsProductFields([new(name, Json("2"))]); + AssertRejectsProductFields([new(name, TestJson.Parse("2"))]); } // RFC 8259 §4: with a repeated name, receivers disagree on the value, as CONTRACT.md §1 notes of request bodies. [Fact] public void CreateRejectsARepeatedProductFieldName() { - AssertRejectsProductFields([new("psu_channel_url", Json("\"a\"")), new("psu_channel_url", Json("\"b\""))]); + AssertRejectsProductFields( + [new("psu_channel_url", TestJson.Parse("\"a\"")), new("psu_channel_url", TestJson.Parse("\"b\""))]); } // CONTRACT.md §1: an absent field is omitted, never written as null. [Fact] public void CreateRejectsANullProductFieldValue() { - AssertRejectsProductFields([new("psu_channel_url", Json("null"))]); + AssertRejectsProductFields([new("psu_channel_url", TestJson.Parse("null"))]); } // The default JsonElement holds no JSON value at all. @@ -173,7 +174,7 @@ public void CreateRejectsAProductFieldWithoutAValue() [Fact] public void CreateRejectsAProductFieldWithoutAName() { - AssertRejectsProductFields([new(null!, Json("true"))]); + AssertRejectsProductFields([new(null!, TestJson.Parse("true"))]); } // RFC 8259 §8.1: JSON text exchanged between systems is UTF-8, which cannot encode an unpaired surrogate. @@ -181,7 +182,7 @@ public void CreateRejectsAProductFieldWithoutAName() [Fact] public void CreateRejectsAProductFieldValueThatIsNotUnicodeText() { - AssertRejectsProductFields([new("label", Json("""["ok", "\ud800"]"""))]); + AssertRejectsProductFields([new("label", TestJson.Parse("""["ok", "\ud800"]"""))]); } // A JsonElement reads from its JsonDocument, which the host may dispose once the config exists. @@ -196,25 +197,8 @@ public void ConfigOutlivesTheDocumentOfItsProductFields() config = IdentityConfig.Create(1, null, [new("policy_limits", document.RootElement)]); } - Assert.True(JsonElement.DeepEquals(Json(Limits), Members(config.ToUtf8Json())["policy_limits"])); - } - - // Parses a JSON object with the BCL, never with the library, and fails on a repeated member name. - internal static Dictionary Members(byte[] utf8Json) - { - using JsonDocument document = JsonDocument.Parse(utf8Json); - Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind); - - return document.RootElement - .EnumerateObject() - .ToDictionary(member => member.Name, member => member.Value.Clone(), StringComparer.Ordinal); - } - - internal static JsonElement Json(string text) - { - using JsonDocument document = JsonDocument.Parse(text); - - return document.RootElement.Clone(); + JsonElement written = TestJson.Members(config.ToUtf8Json())["policy_limits"]; + Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), written)); } private static void AssertRejectsProductFields(KeyValuePair[] productFields) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs new file mode 100644 index 0000000000..7e47a3e6b6 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs @@ -0,0 +1,276 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The bodies of the successful responses of CONTRACT.md §5, each parsed with the BCL and checked against the contract. +/// +public class ResponseBodiesTests +{ + // CONTRACT.md §1: IDs are lowercase hyphenated UUIDs; these have hexadecimal letters, so their case shows. + // The identity is device-a of the vector file, whose certificate the vector root issued. + private const string AuthorityIdText = "7c9e6679-7425-40de-944b-e07fc1f90ae7"; + private const string IdentityIdText = "0b3c6a52-7a38-4f55-9a1c-2f1a8d3c9e01"; + + private static readonly DateTimeOffset Now = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + + private static readonly Guid AuthorityId = Guid.Parse(AuthorityIdText.ToUpperInvariant()); + private static readonly Guid IdentityId = Guid.Parse(IdentityIdText.ToUpperInvariant()); + + private static readonly X509Certificate2 VectorRoot = + X509CertificateLoader.LoadCertificate(TestVectors.Instance.Root.Certificate); + + private static readonly X509Certificate2 DeviceA = + X509CertificateLoader.LoadCertificate(TestVectors.Instance.Key("device-a").Certificate); + + private static readonly IdentityConfig Config = IdentityConfig.Create( + 7, + "https://host/dvls", + [new("psu_channel_url", TestJson.Parse("\"https://psu/channel\""))]); + + // The bodies that carry a certificate chain, written for a given chain. + private static readonly Dictionary, byte[]>> ChainBodies = new() + { + ["enroll"] = chain => ResponseBodies.Enroll(AuthorityId, IdentityId, chain, Config), + ["renew"] = ResponseBodies.Renew, + }; + + // The bodies that carry a config, written for a given config. + private static readonly Dictionary> ConfigBodies = new() + { + ["enroll"] = config => ResponseBodies.Enroll(AuthorityId, IdentityId, [DeviceA, VectorRoot], config), + ["check-in"] = config => ResponseBodies.CheckIn(config, renewalRequested: false), + }; + + public static TheoryData ChainBodyNames => new(ChainBodies.Keys); + + public static TheoryData ConfigBodyNames => new(ConfigBodies.Keys); + + // CONTRACT.md §5.1: each root has `certificate`, `thumbprint`, `not_before` and `not_after`, all strings. + [Fact] + public void TrustAnchorRootHasExactlyTheContractMembers() + { + byte[] body = ResponseBodies.TrustAnchor([VectorRoot]); + + Dictionary members = TestJson.Members(body); + Assert.Equal(["roots"], TestJson.Names(members)); + Dictionary root = TestJson.Members(Assert.Single(TestJson.Elements(members["roots"]))); + Assert.Equal(["certificate", "not_after", "not_before", "thumbprint"], TestJson.Names(root)); + Assert.All(root.Values, value => Assert.Equal(JsonValueKind.String, value.ValueKind)); + } + + // CONTRACT.md §1: a certificate is its DER in canonical base64, and its thumbprint is base64url(SHA-256(DER)), + // which the vector file gives for its root. + [Fact] + public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() + { + byte[] body = ResponseBodies.TrustAnchor([VectorRoot]); + + Dictionary root = TestJson.Members(TestJson.Elements(TestJson.Members(body)["roots"])[0]); + Assert.Equal(TestVectors.Instance.Root.Certificate, TestJson.CanonicalBase64(root["certificate"])); + Assert.Equal(TestVectors.Instance.Root.Thumbprint, root["thumbprint"].GetString()); + } + + // CONTRACT.md §1: timestamps are RFC 3339 UTC strings ending in `Z`. + // The validity is given here in offsets other than UTC, and the test machine's own time zone may be another. + [Fact] + public void TrustAnchorGivesTheValidityOfTheRootInUtc() + { + using X509Certificate2 root = SelfSignedRoot( + new DateTimeOffset(2026, 1, 2, 12, 4, 5, TimeSpan.FromHours(9)), + new DateTimeOffset(2036, 7, 8, 9, 10, 11, TimeSpan.FromHours(-4))); + + byte[] body = ResponseBodies.TrustAnchor([root]); + + Dictionary entry = TestJson.Members(TestJson.Elements(TestJson.Members(body)["roots"])[0]); + Assert.Equal("2026-01-02T03:04:05Z", entry["not_before"].GetString()); + Assert.Equal("2036-07-08T13:10:11Z", entry["not_after"].GetString()); + } + + // CONTRACT.md §5.1: the response lists one root normally and two during a rotation. + [Fact] + public void TrustAnchorListsEveryRootInTheOrderGiven() + { + using X509Certificate2 newRoot = SelfSignedRoot(Now.AddDays(-1), Now.AddYears(10)); + + byte[] body = ResponseBodies.TrustAnchor([VectorRoot, newRoot]); + + JsonElement[] roots = TestJson.Elements(TestJson.Members(body)["roots"]); + Assert.Equal( + [VectorRoot.RawData, newRoot.RawData], + roots.Select(root => TestJson.CanonicalBase64(TestJson.Members(root)["certificate"]))); + } + + // CONTRACT.md §5.1: the response always lists a root, and two during a rotation. + [Fact] + public void TrustAnchorRejectsNoRoots() + { + ArgumentException exception = Assert.Throws(() => ResponseBodies.TrustAnchor([])); + + Assert.Equal("roots", exception.ParamName); + } + + [Fact] + public void TrustAnchorRejectsANullRoot() + { + ArgumentException exception = + Assert.Throws(() => ResponseBodies.TrustAnchor([VectorRoot, null!])); + + Assert.Equal("roots", exception.ParamName); + } + + // CONTRACT.md §5.2: `authority_id`, `identity_id`, `certificate_chain` and `config`, every one of them required. + // §1: IDs are lowercase hyphenated UUIDs. + [Fact] + public void EnrollHasExactlyTheContractMembers() + { + byte[] body = ResponseBodies.Enroll(AuthorityId, IdentityId, [DeviceA, VectorRoot], Config); + + Dictionary members = TestJson.Members(body); + Assert.Equal(["authority_id", "certificate_chain", "config", "identity_id"], TestJson.Names(members)); + Assert.Equal(JsonValueKind.String, members["authority_id"].ValueKind); + Assert.Equal(AuthorityIdText, members["authority_id"].GetString()); + Assert.Equal(JsonValueKind.String, members["identity_id"].ValueKind); + Assert.Equal(IdentityIdText, members["identity_id"].GetString()); + Assert.Equal(JsonValueKind.Array, members["certificate_chain"].ValueKind); + Assert.Equal(JsonValueKind.Object, members["config"].ValueKind); + } + + // RFC 9562 §5.9: the nil UUID stands for the absence of a UUID, and CONTRACT.md §5.2 requires both IDs. + [Fact] + public void EnrollRejectsTheNilAuthorityId() + { + ArgumentException exception = Assert.Throws( + () => ResponseBodies.Enroll(Guid.Empty, IdentityId, [DeviceA, VectorRoot], Config)); + + Assert.Equal("authorityId", exception.ParamName); + } + + [Fact] + public void EnrollRejectsTheNilIdentityId() + { + ArgumentException exception = Assert.Throws( + () => ResponseBodies.Enroll(AuthorityId, Guid.Empty, [DeviceA, VectorRoot], Config)); + + Assert.Equal("identityId", exception.ParamName); + } + + // CONTRACT.md §5.3: `200`: `{ "certificate_chain": [ ... ] }`. + [Fact] + public void RenewHasExactlyTheContractMembers() + { + byte[] body = ResponseBodies.Renew([DeviceA, VectorRoot]); + + Dictionary members = TestJson.Members(body); + Assert.Equal(["certificate_chain"], TestJson.Names(members)); + Assert.Equal(JsonValueKind.Array, members["certificate_chain"].ValueKind); + } + + // CONTRACT.md §5.6: `config` and `renewal_requested`, the identity's request-renewal flag as a JSON boolean. + [Theory] + [InlineData(true, JsonValueKind.True)] + [InlineData(false, JsonValueKind.False)] + public void CheckInHasExactlyTheContractMembers(bool renewalRequested, JsonValueKind expected) + { + byte[] body = ResponseBodies.CheckIn(Config, renewalRequested); + + Dictionary members = TestJson.Members(body); + Assert.Equal(["config", "renewal_requested"], TestJson.Names(members)); + Assert.Equal(JsonValueKind.Object, members["config"].ValueKind); + Assert.Equal(expected, members["renewal_requested"].ValueKind); + } + + // CONTRACT.md §4: chains are leaf-first; §1: each certificate is its DER in canonical base64. + [Theory] + [MemberData(nameof(ChainBodyNames))] + public void ChainIsWrittenLeafFirst(string endpoint) + { + byte[] body = ChainBodies[endpoint]([DeviceA, VectorRoot]); + + JsonElement[] chain = TestJson.Elements(TestJson.Members(body)["certificate_chain"]); + Assert.Equal( + [TestVectors.Instance.Key("device-a").Certificate, TestVectors.Instance.Root.Certificate], + chain.Select(TestJson.CanonicalBase64)); + } + + // CONTRACT.md §4: a chain has any length, so the leaf alone is a chain too. + [Theory] + [MemberData(nameof(ChainBodyNames))] + public void ChainOfTheLeafAloneIsWritten(string endpoint) + { + byte[] body = ChainBodies[endpoint]([DeviceA]); + + JsonElement[] chain = TestJson.Elements(TestJson.Members(body)["certificate_chain"]); + Assert.Equal([TestVectors.Instance.Key("device-a").Certificate], chain.Select(TestJson.CanonicalBase64)); + } + + // RFC 5280 §4.1.2.6: a CA's subject is encoded exactly as the issuer of each certificate it issues, + // so in a leaf-first chain the issuer of each certificate is the subject of the next. + [Theory] + [MemberData(nameof(ChainBodyNames))] + public void ChainWithTheRootFirstIsRejected(string endpoint) + { + ArgumentException exception = + Assert.Throws(() => ChainBodies[endpoint]([VectorRoot, DeviceA])); + + Assert.Equal("certificateChain", exception.ParamName); + } + + // The other root has another subject than the vector root, which issued the leaf. + [Theory] + [MemberData(nameof(ChainBodyNames))] + public void ChainWithACertificateThatDidNotIssueThePreviousOneIsRejected(string endpoint) + { + using X509Certificate2 otherRoot = SelfSignedRoot(Now.AddDays(-1), Now.AddYears(10)); + + ArgumentException exception = + Assert.Throws(() => ChainBodies[endpoint]([DeviceA, otherRoot])); + + Assert.Equal("certificateChain", exception.ParamName); + } + + [Theory] + [MemberData(nameof(ChainBodyNames))] + public void EmptyChainIsRejected(string endpoint) + { + ArgumentException exception = Assert.Throws(() => ChainBodies[endpoint]([])); + + Assert.Equal("certificateChain", exception.ParamName); + } + + [Theory] + [MemberData(nameof(ChainBodyNames))] + public void ChainWithANullCertificateIsRejected(string endpoint) + { + ArgumentException exception = + Assert.Throws(() => ChainBodies[endpoint]([DeviceA, null!])); + + Assert.Equal("certificateChain", exception.ParamName); + } + + // CONTRACT.md §5.2 and §5.6: `config` is the identity's config, in the shape of §5.2. + [Theory] + [MemberData(nameof(ConfigBodyNames))] + public void BodyCarriesTheConfig(string endpoint) + { + byte[] body = ConfigBodies[endpoint](Config); + + Dictionary config = TestJson.Members(TestJson.Members(body)["config"]); + Assert.Equal(["agent_channel_url", "psu_channel_url", "revision", "version"], TestJson.Names(config)); + Assert.Equal("1", config["version"].GetRawText()); + Assert.Equal("7", config["revision"].GetRawText()); + Assert.Equal("https://host/dvls", config["agent_channel_url"].GetString()); + Assert.Equal("https://psu/channel", config["psu_channel_url"].GetString()); + } + + private static X509Certificate2 SelfSignedRoot(DateTimeOffset notBefore, DateTimeOffset notAfter) + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest request = new("CN=Test Agent Identity Root 2", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + + return request.CreateSelfSigned(notBefore, notAfter); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestJson.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestJson.cs new file mode 100644 index 0000000000..3a5d6ee2a7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestJson.cs @@ -0,0 +1,72 @@ +using System.Text.Json; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Reads JSON that the library wrote with the BCL alone, so that a library bug cannot make a body agree with itself. +/// +internal static class TestJson +{ + public const string LowercaseUuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + + /// + /// The members of a JSON text that holds one object, by name; a repeated member name fails. + /// + public static Dictionary Members(byte[] utf8Json) + { + using JsonDocument document = JsonDocument.Parse(utf8Json); + + return Members(document.RootElement.Clone()); + } + + /// + /// The members of an object, by name; a repeated member name fails. + /// + public static Dictionary Members(JsonElement element) + { + Assert.Equal(JsonValueKind.Object, element.ValueKind); + + return element + .EnumerateObject() + .ToDictionary(member => member.Name, member => member.Value, StringComparer.Ordinal); + } + + /// + /// The names of the members of an object, in ordinal order, for comparison with the members a contract lists. + /// + public static IEnumerable Names(Dictionary members) + { + return members.Keys.Order(StringComparer.Ordinal); + } + + /// + /// The elements of an array. + /// + public static JsonElement[] Elements(JsonElement element) + { + Assert.Equal(JsonValueKind.Array, element.ValueKind); + + return [.. element.EnumerateArray()]; + } + + /// + /// Decodes a string in canonical base64 (RFC 4648 §4): + /// the encoding that the BCL writes for the decoded bytes, with padding and without white space. + /// + public static byte[] CanonicalBase64(JsonElement element) + { + Assert.Equal(JsonValueKind.String, element.ValueKind); + string text = element.GetString()!; + byte[] bytes = Convert.FromBase64String(text); + Assert.Equal(Convert.ToBase64String(bytes), text); + + return bytes; + } + + public static JsonElement Parse(string text) + { + using JsonDocument document = JsonDocument.Parse(text); + + return document.RootElement.Clone(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs index 66d33e620a..3c57c3262c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs @@ -42,6 +42,11 @@ private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) /// public string? AgentChannelUrl { get; } + /// + /// The UTF-8 JSON object that copies. + /// + internal ReadOnlySpan Utf8Json => _utf8Json; + /// /// Creates a config with schema version 1. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs new file mode 100644 index 0000000000..60a08961ca --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs @@ -0,0 +1,220 @@ +using System.Buffers; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; + +namespace Devolutions.AgentIdentity; + +/// +/// Writes the bodies of the successful responses of the agent-facing endpoints, as UTF-8 JSON objects. +/// +/// +/// Each body has exactly the members that CONTRACT.md §5 gives the response, and no member is ever null. +/// A certificate is written as its DER in canonical base64, and a time as an RFC 3339 UTC timestamp. +/// +public static class ResponseBodies +{ + /// + /// Writes the body of a trust-anchor response: roots, the published root certificates. + /// + /// + /// The authority publishes one root normally, and the old and new roots during a rotation. + /// The roots are written in the order given, each with its certificate, its thumbprint, + /// and its not_before and not_after. + /// A chain that validates against these roots says nothing about whether its identity is revoked. + /// + /// The published root certificates. + /// is null. + /// is empty or holds null. + public static byte[] TrustAnchor(IEnumerable roots) + { + X509Certificate2[] published = Certificates(roots, "root", nameof(roots)); + + return Write(writer => + { + writer.WriteStartArray("roots"); + + foreach (X509Certificate2 root in published) + { + ReadOnlySpan der = root.RawDataMemory.Span; + + writer.WriteStartObject(); + writer.WriteBase64String("certificate", der); + writer.WriteString("thumbprint", AgentIdentityProtocol.ComputeThumbprint(der)); + writer.WriteString("not_before", Timestamp(root.NotBefore)); + writer.WriteString("not_after", Timestamp(root.NotAfter)); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + }); + } + + /// + /// Writes the body of an enroll response, for a new identity or a replay: + /// authority_id, identity_id, certificate_chain and config. + /// + /// + /// The IDs are written as lowercase hyphenated UUIDs. + /// + /// The authority's ID, which never changes for the authority. + /// The identity's ID. + /// + /// The chain of the identity's current certificate, leaf first: each certificate after the leaf issued the one + /// before it. + /// + /// The identity's config. + /// + /// or is null. + /// + /// + /// An ID is the nil UUID, which stands for no ID; + /// or is empty, holds null, or is not leaf first. + /// + public static byte[] Enroll( + Guid authorityId, + Guid identityId, + IEnumerable certificateChain, + IdentityConfig config) + { + CheckId(authorityId, "authority", nameof(authorityId)); + CheckId(identityId, "identity", nameof(identityId)); + X509Certificate2[] chain = LeafFirstChain(certificateChain); + ArgumentNullException.ThrowIfNull(config); + + return Write(writer => + { + writer.WriteString("authority_id", authorityId.ToString("D")); + writer.WriteString("identity_id", identityId.ToString("D")); + WriteChain(writer, chain); + WriteConfig(writer, config); + }); + } + + /// + /// Writes the body of a renew response: certificate_chain. + /// + /// + /// The chain of the identity's pending certificate, leaf first: each certificate after the leaf issued the one + /// before it. + /// + /// is null. + /// + /// is empty, holds null, or is not leaf first. + /// + public static byte[] Renew(IEnumerable certificateChain) + { + X509Certificate2[] chain = LeafFirstChain(certificateChain); + + return Write(writer => WriteChain(writer, chain)); + } + + /// + /// Writes the body of a check-in response: config and renewal_requested. + /// + /// The identity's config. + /// + /// The identity's request-renewal flag, which the authority sets when it wants the identity to renew. + /// + /// is null. + public static byte[] CheckIn(IdentityConfig config, bool renewalRequested) + { + ArgumentNullException.ThrowIfNull(config); + + return Write(writer => + { + WriteConfig(writer, config); + writer.WriteBoolean("renewal_requested", renewalRequested); + }); + } + + private static byte[] Write(Action writeMembers) + { + ArrayBufferWriter buffer = new(); + + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writeMembers(writer); + writer.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + // Copies the certificates, so that the checks and the body see the same ones. + private static X509Certificate2[] Certificates( + IEnumerable certificates, + string description, + string paramName) + { + ArgumentNullException.ThrowIfNull(certificates, paramName); + X509Certificate2[] copy = [.. certificates]; + + if (copy.Length == 0) + { + throw new ArgumentException($"There is no {description} certificate.", paramName); + } + + if (copy.Any(certificate => certificate is null)) + { + throw new ArgumentException($"A {description} certificate is null.", paramName); + } + + return copy; + } + + private static X509Certificate2[] LeafFirstChain(IEnumerable certificateChain) + { + X509Certificate2[] chain = Certificates(certificateChain, "chain", nameof(certificateChain)); + + // RFC 5280 §4.1.2.6: a CA encodes its subject exactly as the issuer of each certificate it issues, + // so a chain in another order fails this comparison. + for (int i = 1; i < chain.Length; i++) + { + if (!chain[i - 1].IssuerName.RawData.AsSpan().SequenceEqual(chain[i].SubjectName.RawData)) + { + throw new ArgumentException( + $"The subject of chain certificate {i + 1} is not the issuer of certificate {i}, " + + "so the chain is not leaf first.", + nameof(certificateChain)); + } + } + + return chain; + } + + // RFC 9562 §5.9: the nil UUID stands for the absence of a UUID, and both IDs are required. + private static void CheckId(Guid id, string description, string paramName) + { + if (id == Guid.Empty) + { + throw new ArgumentException($"The {description} ID is the nil UUID.", paramName); + } + } + + private static void WriteChain(Utf8JsonWriter writer, X509Certificate2[] chain) + { + writer.WriteStartArray("certificate_chain"); + + foreach (X509Certificate2 certificate in chain) + { + writer.WriteBase64StringValue(certificate.RawDataMemory.Span); + } + + writer.WriteEndArray(); + } + + private static void WriteConfig(Utf8JsonWriter writer, IdentityConfig config) + { + writer.WritePropertyName("config"); + + // The config wrote these bytes itself when it was created. + writer.WriteRawValue(config.Utf8Json, skipInputValidation: true); + } + + // X509Certificate2 gives its validity in local time. + private static string Timestamp(DateTime localTime) + { + return AgentIdentityProtocol.FormatTimestamp(new DateTimeOffset(localTime.ToUniversalTime())); + } +} From 76f8a7dae1a475085b6fb1913f3ff619335b3922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:49:44 +0900 Subject: [PATCH 31/93] feat(dotnet-agent-identity): build the channel push messages ConfigUpdate, RenewRequested and Reconnect carry agent-facing values on the agent channel, so every authority must build them the same way. ChannelMessages builds each one as a ServerMessage with a fresh lowercase hyphenated UUID id and no correlation_id. ConfigUpdate carries the JSON object that IdentityConfig writes in config_json. RenewRequested carries the reason admin or rotation, and Reconnect the reason certificate_rotated, each from an enum whose values start at 1, so an undefined reason throws. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ChannelMessagesTests.cs | 106 ++++++++++++++++++ .../ChannelMessages.cs | 90 +++++++++++++++ .../ReconnectReason.cs | 17 +++ .../RenewalRequestReason.cs | 22 ++++ 4 files changed, 235 insertions(+) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelMessagesTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelMessages.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/ReconnectReason.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRequestReason.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelMessagesTests.cs new file mode 100644 index 0000000000..707b77b838 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelMessagesTests.cs @@ -0,0 +1,106 @@ +using System.Text; +using System.Text.Json; + +using Devolutions.Agent.Channel.V1; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The messages that the authority pushes on an authenticated agent channel stream, CONTRACT.md §7.3 and §7.4. +/// +public class ChannelMessagesTests +{ + private static readonly IdentityConfig Config = IdentityConfig.Create(5, "https://host/dvls"); + + private static readonly Dictionary> Messages = new() + { + ["ConfigUpdate"] = () => ChannelMessages.ConfigUpdate(Config), + ["RenewRequested"] = () => ChannelMessages.RenewRequested(RenewalRequestReason.Admin), + ["Reconnect"] = () => ChannelMessages.Reconnect(ReconnectReason.CertificateRotated), + }; + + public static TheoryData MessageNames => new(Messages.Keys); + + // CONTRACT.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. + // A push answers no agent message, so it has no correlation_id. + [Theory] + [MemberData(nameof(MessageNames))] + public void MessageHasALowercaseUuidIdAndNoCorrelationId(string message) + { + ServerMessage built = Messages[message](); + + Assert.Matches(TestJson.LowercaseUuidPattern, built.Id); + Assert.False(built.HasCorrelationId); + } + + // CONTRACT.md §7.3 steps 5 and 7: the agent acknowledges a message by its id, so no two messages share one. + [Theory] + [MemberData(nameof(MessageNames))] + public void EachMessageHasItsOwnId(string message) + { + Assert.NotEqual(Messages[message]().Id, Messages[message]().Id); + } + + // CONTRACT.md §7.3 step 7 and channel.proto: ConfigUpdate carries the config, in the shape of §5.2, + // as a UTF-8 JSON object. + [Fact] + public void ConfigUpdateCarriesTheConfigJson() + { + ServerMessage message = ChannelMessages.ConfigUpdate(Config); + + Assert.Equal(ServerMessage.PayloadOneofCase.ConfigUpdate, message.PayloadCase); + Dictionary config = + TestJson.Members(Encoding.UTF8.GetBytes(message.ConfigUpdate.ConfigJson)); + Assert.Equal(["agent_channel_url", "revision", "version"], TestJson.Names(config)); + Assert.Equal("1", config["version"].GetRawText()); + Assert.Equal("5", config["revision"].GetRawText()); + Assert.Equal("https://host/dvls", config["agent_channel_url"].GetString()); + } + + // channel.proto: the reason of RenewRequested is "admin" or "rotation"; + // CONTRACT.md §8.3 sends "rotation" at the start of a root rotation. + [Theory] + [InlineData(RenewalRequestReason.Admin, "admin")] + [InlineData(RenewalRequestReason.Rotation, "rotation")] + public void RenewRequestedCarriesItsReason(RenewalRequestReason reason, string expected) + { + ServerMessage message = ChannelMessages.RenewRequested(reason); + + Assert.Equal(ServerMessage.PayloadOneofCase.RenewRequested, message.PayloadCase); + Assert.Equal(expected, message.RenewRequested.Reason); + } + + // CONTRACT.md §7.4: the streams of a retired certificate get Reconnect{reason:"certificate_rotated"}. + [Fact] + public void ReconnectCarriesItsReason() + { + ServerMessage message = ChannelMessages.Reconnect(ReconnectReason.CertificateRotated); + + Assert.Equal(ServerMessage.PayloadOneofCase.Reconnect, message.PayloadCase); + Assert.Equal("certificate_rotated", message.Reconnect.Reason); + } + + // Reasons start at 1, so a reason that the host never set is not a valid one, + // and a value after the last reason is not one either. + [Theory] + [InlineData(0)] + [InlineData(3)] + public void RenewRequestedRejectsAnUndefinedReason(int reason) + { + ArgumentOutOfRangeException exception = Assert.Throws( + () => ChannelMessages.RenewRequested((RenewalRequestReason)reason)); + + Assert.Equal("reason", exception.ParamName); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public void ReconnectRejectsAnUndefinedReason(int reason) + { + ArgumentOutOfRangeException exception = Assert.Throws( + () => ChannelMessages.Reconnect((ReconnectReason)reason)); + + Assert.Equal("reason", exception.ParamName); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelMessages.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelMessages.cs new file mode 100644 index 0000000000..e69b7ab908 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelMessages.cs @@ -0,0 +1,90 @@ +using System.Text; + +using Devolutions.Agent.Channel.V1; + +using ConfigUpdatePayload = Devolutions.Agent.Channel.V1.ConfigUpdate; +using ReconnectPayload = Devolutions.Agent.Channel.V1.Reconnect; +using RenewRequestedPayload = Devolutions.Agent.Channel.V1.RenewRequested; + +namespace Devolutions.AgentIdentity; + +/// +/// Builds the messages that the authority pushes to the agent on an agent channel stream. +/// +/// +/// The host sends them only on an authenticated stream, after its Welcome, +/// since nothing is pushed before the agent signs the stream's challenge. +/// Each message has a fresh id, a lowercase hyphenated UUID, +/// which the agent's Ack carries as its correlation_id. +/// A push is an optimization, since Hello and Welcome reconcile the state at every connection. +/// +public static class ChannelMessages +{ + /// + /// Builds a ConfigUpdate, whose config_json is the identity's config as a JSON object. + /// + /// + /// The host sends it after the Welcome when the identity's config has a higher revision than the one in + /// applied_state_versions["config"] of the Hello, + /// and on each live stream of the identity when its config changes. + /// + /// The identity's config. + /// is null. + public static ServerMessage ConfigUpdate(IdentityConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + return new ServerMessage + { + Id = NewId(), + ConfigUpdate = new ConfigUpdatePayload { ConfigJson = Encoding.UTF8.GetString(config.Utf8Json) }, + }; + } + + /// + /// Builds a RenewRequested, which asks the agent to renew its certificate. + /// + /// + /// While the identity's request-renewal flag is set, the host sends it after the Welcome of every stream + /// that the identity opens, until the identity confirms a certificate issued after the flag was set. + /// + /// Why the authority asks for the renewal. + /// is not a defined reason. + public static ServerMessage RenewRequested(RenewalRequestReason reason) + { + string wireReason = reason switch + { + RenewalRequestReason.Admin => "admin", + RenewalRequestReason.Rotation => "rotation", + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "Not a defined renewal request reason."), + }; + + return new ServerMessage { Id = NewId(), RenewRequested = new RenewRequestedPayload { Reason = wireReason } }; + } + + /// + /// Builds a Reconnect, which asks the agent to open a new stream and then close this one. + /// + /// + /// When a confirmation retires the certificate that opened a stream, + /// the host sends it on that stream with , + /// and closes the stream with status OK 60 s later if it is still open. + /// + /// Why the authority asks for a new stream. + /// is not a defined reason. + public static ServerMessage Reconnect(ReconnectReason reason) + { + string wireReason = reason switch + { + ReconnectReason.CertificateRotated => "certificate_rotated", + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "Not a defined reconnect reason."), + }; + + return new ServerMessage { Id = NewId(), Reconnect = new ReconnectPayload { Reason = wireReason } }; + } + + private static string NewId() + { + return Guid.NewGuid().ToString("D"); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ReconnectReason.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ReconnectReason.cs new file mode 100644 index 0000000000..6a038f25ee --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ReconnectReason.cs @@ -0,0 +1,17 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Why the authority asks the agent to replace an agent channel stream, as a Reconnect message says. +/// +public enum ReconnectReason +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a reason would change what an existing host sends. + // When adding a reason, give it the next unused value; + // values start at 1 so that an uninitialized reason is not a valid one. + + /// + /// A confirmation retired the certificate that opened the stream: certificate_rotated. + /// + CertificateRotated = 1, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRequestReason.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRequestReason.cs new file mode 100644 index 0000000000..169e5cca90 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRequestReason.cs @@ -0,0 +1,22 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Why the authority asks an identity to renew its certificate, as a RenewRequested message says. +/// +public enum RenewalRequestReason +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a reason would change what an existing host sends. + // When adding a reason, give it the next unused value; + // values start at 1 so that an uninitialized reason is not a valid one. + + /// + /// An administrator asked for the renewal: admin. + /// + Admin = 1, + + /// + /// A root rotation started while the identity holds a certificate from the old root: rotation. + /// + Rotation = 2, +} From 9ac06efd4046690bc071ecb9e41f5dda55e66363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:53:24 +0900 Subject: [PATCH 32/93] feat(dotnet-agent-identity): issue renewals for the proved identity A renewal certifies a new key for an existing identity, but the only issuance method took a Guid, so a host could issue a renewal for an identity that a request merely named. IdentityCertificateIssuer.Issue now has an overload that takes the AuthenticatedIdentity of a renew request, which only the signature check creates, and throws ArgumentException for an identity authenticated for another operation. The Guid overload is documented as the one for the identity that the authority mints at enrollment. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IdentityCertificateIssuerTests.cs | 47 +++++++++++++++++ .../TestIdentity.cs | 41 +++++++++++++++ .../IdentityCertificateIssuer.cs | 52 ++++++++++++++++++- 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs index e252140259..3e89a76e53 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs @@ -321,6 +321,53 @@ public void ThrowsWhenTheIssuerHasNoSubjectKeyIdentifier() Assert.Throws(() => Issue(root: root)); } + // INTENT.md: code that acts for an identity takes the identity that the signature check produced. + // The renew request is signed by device-a, whose identity ID the vector file gives. + [Fact] + public async Task RenewalCertifiesTheKeyOfTheCsrForTheIdentityThatSignedTheRenewRequest() + { + Assert.Equal(Guid.Parse(IdentityIdText), TestVectors.Instance.Key("device-a").IdentityId); + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + + using X509Certificate2 leaf = IssueForRenewal(renewing); + + Assert.Equal($"CN={IdentityIdText}", leaf.Subject); + Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo()); + } + + // An identity that proved its key for another operation never had a renew request accepted for it. + [Theory] + [InlineData(AgentOperation.Confirm)] + [InlineData(AgentOperation.CheckIn)] + [InlineData(AgentOperation.Connect)] + public async Task RenewalThrowsForAnIdentityAuthenticatedForAnotherOperation(AgentOperation operation) + { + AuthenticatedIdentity identity = await TestIdentity.AuthenticateAsync(operation); + + ArgumentException exception = Assert.Throws(() => IssueForRenewal(identity)); + + Assert.Equal("renewing", exception.ParamName); + } + + [Fact] + public void RenewalThrowsForANullIdentity() + { + ArgumentNullException exception = Assert.Throws(() => IssueForRenewal(null!)); + + Assert.Equal("renewing", exception.ParamName); + } + + private X509Certificate2 IssueForRenewal(AuthenticatedIdentity renewing) + { + return IdentityCertificateIssuer.Issue( + renewing, + _csr, + new TestRootSigner(_root, _rootKey), + Lifetime, + new FixedTimeProvider(Now), + _random); + } + private X509Certificate2 Issue( TimeSpan? lifetime = null, TimeProvider? clock = null, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs new file mode 100644 index 0000000000..8c7a05b934 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs @@ -0,0 +1,41 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Identities that the library authenticated, as a host receives them. +/// +internal static class TestIdentity +{ + /// + /// Authenticates a request of device-a for an operation through the public API: + /// connect through the channel handshake, the others as signed requests. + /// + public static async Task AuthenticateAsync(AgentOperation operation) + { + TestAuthority authority = new(TestAuthority.DefaultNow); + SignedHttpRequest request = authority.Valid(operation).ToHttpRequest(); + + if (operation == AgentOperation.Connect) + { + ChannelOpeningResult opening = await authority.Authenticator.AuthenticateChannelOpeningAsync( + request.Method, + request.SignatureInput, + request.Signature); + Assert.True(opening.Succeeded, opening.FailureDetail); + + byte[] proof = TestChannelProof.Sign( + TestVectors.Instance.Key("device-a"), + opening.Opening.Challenge.Challenge.Challenge_.Span, + TestRequest.DefaultNonce); + ChannelHandshakeResult handshake = + opening.Opening.VerifyHello(VectorChannel.HelloMessage(opening.Opening, proof)); + Assert.True(handshake.Succeeded, handshake.FailureDetail); + + return handshake.Identity; + } + + AuthenticationResult result = await authority.Authenticator.AuthenticateAsync(operation, request); + Assert.True(result.Succeeded, result.FailureDetail); + + return result.Identity; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs index 2c54cfe339..242fb078bf 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs @@ -20,7 +20,8 @@ public static class IdentityCertificateIssuer public static TimeSpan DefaultLifetime { get; } = TimeSpan.FromDays(90); /// - /// Issues the identity certificate for the key of a validated CSR. + /// Issues the certificate of a new identity, whose ID the authority mints at enrollment, + /// for the key of a validated CSR. /// /// /// @@ -36,8 +37,12 @@ public static class IdentityCertificateIssuer /// Its serial number is 16 octets from , positive and never zero, /// and signs it with SHA-256. /// + /// + /// A renewal certifies a key for an existing identity, + /// so it uses the overload that takes the identity that the renew request proved. + /// /// - /// The identity that the certificate belongs to. + /// The ID that the authority minted for the new identity. /// The validated CSR whose key the certificate is for. /// The root that signs the certificate. /// The certificate lifetime, usually . @@ -117,6 +122,49 @@ public static X509Certificate2 Issue( NewSerialNumber(random)); } + /// + /// Issues the pending certificate of a renewal, for the identity that signed the renew request. + /// + /// + /// The certificate is the one that the other overload issues for . + /// Taking the identity that the signature check produced, rather than an ID, + /// keeps a renewal from certifying a key for an identity that a request only named. + /// + /// + /// The identity that a renew request proved, as + /// returns it for . + /// + /// The validated CSR whose key the certificate is for. + /// The root that signs the certificate. + /// The certificate lifetime, usually . + /// The clock, read once. + /// The source of the serial number. + /// An argument is null. + /// + /// was authenticated for another operation than renew; + /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second. + /// + /// + /// is shorter than one second. + /// + public static X509Certificate2 Issue( + AuthenticatedIdentity renewing, + CertificateSigningRequest csr, + IRootSigner issuer, + TimeSpan lifetime, + TimeProvider timeProvider, + RandomNumberGenerator random) + { + ArgumentNullException.ThrowIfNull(renewing); + + if (renewing.Operation != AgentOperation.Renew) + { + throw new ArgumentException("The identity was not authenticated by a renew request.", nameof(renewing)); + } + + return Issue(renewing.IdentityId, csr, issuer, lifetime, timeProvider, random); + } + private static DateTimeOffset TruncateToSecond(DateTimeOffset value) { return new DateTimeOffset(value.UtcTicks - (value.UtcTicks % TimeSpan.TicksPerSecond), TimeSpan.Zero); From e1e33541087bd883f8a69c2cf1cfe3088d99d048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:57:32 +0900 Subject: [PATCH 33/93] feat(dotnet-agent-identity): decide renewals by the CSR key The contract gives a renew request one outcome per CSR key, and an authority that answers differently could hand out another identity's pending chain or reuse a public key, so every authority must apply the rules the same way. RenewalRules.Decide takes the AuthenticatedIdentity of the renew request and the IssuedKeyMatch of the CSR key, or null for a key never issued. The pending key of that identity returns its pending chain, so a device recovers a lost response. Any other issued key gets invalid_request: the identity's current or retired key, any key of another identity, revoked or not, and any key of a deleted identity. A key never issued gets a new pending certificate, which retires the previous one. An identity authenticated for another operation throws ArgumentException. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RenewalRulesTests.cs | 107 ++++++++++++++++++ .../IssuedKeyMatch.cs | 11 +- .../RenewalDecision.cs | 45 ++++++++ .../RenewalOutcome.cs | 29 +++++ .../Devolutions.AgentIdentity/RenewalRules.cs | 72 ++++++++++++ 5 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RenewalRulesTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalDecision.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalOutcome.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRules.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RenewalRulesTests.cs new file mode 100644 index 0000000000..a855a1af4d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RenewalRulesTests.cs @@ -0,0 +1,107 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The CSR key rules of a renew request, CONTRACT.md §5.3. +/// +/// +/// Every request is a renew request that device-a signed; device-b of the vector file is another identity. +/// +public class RenewalRulesTests +{ + private static readonly Guid OtherIdentityId = TestVectors.Instance.Key("device-b").IdentityId; + + // CONTRACT.md §5.3: the key of any other certificate that the server has issued, including this identity's + // current or retired certificates and every certificate of another identity, gets invalid_request. + // A deleted identity's certificates are certificates that the server has issued. + private static readonly Dictionary> OtherIssuedKeys = new() + { + ["the current key of the identity"] = identityId => + IssuedKeyMatch.ForIdentity(identityId, false, false, CertificateStatus.Current), + ["a retired key of the identity"] = identityId => + IssuedKeyMatch.ForIdentity(identityId, false, false, CertificateStatus.Retired), + ["the pending key of another identity"] = _ => + IssuedKeyMatch.ForIdentity(OtherIdentityId, false, false, CertificateStatus.Pending), + ["the current key of another identity"] = _ => + IssuedKeyMatch.ForIdentity(OtherIdentityId, false, false, CertificateStatus.Current), + ["a retired key of another identity"] = _ => + IssuedKeyMatch.ForIdentity(OtherIdentityId, false, false, CertificateStatus.Retired), + ["the pending key of another identity, which is revoked"] = _ => + IssuedKeyMatch.ForIdentity(OtherIdentityId, false, true, CertificateStatus.Pending), + ["a key of a deleted identity"] = _ => IssuedKeyMatch.ForDeletedIdentity(), + }; + + public static TheoryData OtherIssuedKeyNames => new(OtherIssuedKeys.Keys); + + // CONTRACT.md §5.3: if the CSR key is the key of the identity's pending certificate, + // the server returns 200 with that pending chain, a retry after a lost response. + // A renew request presents no enrollment token, so whatever the host says about one does not count. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReturnsThePendingCertificateForThePendingKeyOfTheIdentity(bool enrolledWithPresentedToken) + { + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + IssuedKeyMatch keyMatch = IssuedKeyMatch.ForIdentity( + renewing.IdentityId, + enrolledWithPresentedToken, + identityRevoked: false, + CertificateStatus.Pending); + + RenewalDecision decision = RenewalRules.Decide(renewing, keyMatch); + + Assert.Equal(RenewalOutcome.ReturnPendingCertificate, decision.Outcome); + Assert.Null(decision.Error); + Assert.Null(decision.FailureDetail); + } + + [Theory] + [MemberData(nameof(OtherIssuedKeyNames))] + public async Task RejectsAnyOtherIssuedKeyWithInvalidRequest(string key) + { + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + + RenewalDecision decision = RenewalRules.Decide(renewing, OtherIssuedKeys[key](renewing.IdentityId)); + + Assert.Equal(RenewalOutcome.Reject, decision.Outcome); + Assert.Equal(AgentIdentityErrorCode.InvalidRequest, decision.Error?.Code); + Assert.Equal(400, decision.Error?.HttpStatusCode); + Assert.False(string.IsNullOrEmpty(decision.FailureDetail)); + } + + // CONTRACT.md §5.3: if the CSR key was never issued, the server issues a new pending certificate + // and retires the identity's previous pending certificate, if any. + [Fact] + public async Task IssuesAPendingCertificateForAKeyNeverIssued() + { + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + + RenewalDecision decision = RenewalRules.Decide(renewing, null); + + Assert.Equal(RenewalOutcome.IssuePendingCertificate, decision.Outcome); + Assert.Null(decision.Error); + Assert.Null(decision.FailureDetail); + } + + // INTENT.md: code that acts for an identity takes the identity that the signature check produced, + // and only a renew request proves the identity that a renewal acts for. + [Theory] + [InlineData(AgentOperation.Confirm)] + [InlineData(AgentOperation.CheckIn)] + [InlineData(AgentOperation.Connect)] + public async Task ThrowsForAnIdentityAuthenticatedForAnotherOperation(AgentOperation operation) + { + AuthenticatedIdentity identity = await TestIdentity.AuthenticateAsync(operation); + + ArgumentException exception = Assert.Throws(() => RenewalRules.Decide(identity, null)); + + Assert.Equal("renewing", exception.ParamName); + } + + [Fact] + public void ThrowsForANullIdentity() + { + ArgumentNullException exception = Assert.Throws(() => RenewalRules.Decide(null!, null)); + + Assert.Equal("renewing", exception.ParamName); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs index 83bcae23de..a32c410fc4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs @@ -1,7 +1,7 @@ namespace Devolutions.AgentIdentity; /// -/// The issued certificate whose public key is the key of an enroll request's CSR. +/// The issued certificate whose public key is the key of an enroll or renew request's CSR. /// /// /// The host finds it by the SPKI SHA-256 of the CSR key among every certificate the authority has issued: @@ -45,9 +45,14 @@ private IssuedKeyMatch( /// /// Creates the match of a certificate of an identity that the authority holds. /// + /// + /// reads only the identity and the status, + /// since a renewal refuses any key of another identity, revoked or not. + /// /// The identity that the certificate was issued to. /// - /// Whether the identity was enrolled with the token that the request presents. + /// Whether the identity was enrolled with the token that the request presents; + /// false for a renew request, which presents none. /// /// Whether the identity is revoked. /// The status of the certificate. @@ -74,6 +79,8 @@ public static IssuedKeyMatch ForIdentity( /// so refuses the key with identity_revoked whatever token enrolled /// the identity. /// A deleted presented token gets token_invalid instead, since it authenticates nothing but a replay. + /// refuses the key with invalid_request, as it does any key of another + /// identity. /// public static IssuedKeyMatch ForDeletedIdentity() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalDecision.cs new file mode 100644 index 0000000000..5c2cea6753 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalDecision.cs @@ -0,0 +1,45 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with a renew request, as decides it. +/// +public sealed class RenewalDecision +{ + private RenewalDecision(RenewalOutcome outcome, AgentIdentityError? error, string? failureDetail) + { + Outcome = outcome; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// What the authority does with the request. + /// + public RenewalOutcome Outcome { get; } + + /// + /// The error that refuses the request, 400 invalid_request, + /// when is ; otherwise null. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that refuses the request, for the host's logs, + /// when is ; otherwise null. + /// + public string? FailureDetail { get; } + + internal static RenewalDecision IssuePendingCertificate { get; } = + new(RenewalOutcome.IssuePendingCertificate, null, null); + + internal static RenewalDecision ReturnPendingCertificate { get; } = + new(RenewalOutcome.ReturnPendingCertificate, null, null); + + internal static RenewalDecision Reject(string failureDetail) + { + return new RenewalDecision( + RenewalOutcome.Reject, + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest), + failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalOutcome.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalOutcome.cs new file mode 100644 index 0000000000..246b9f8e9d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalOutcome.cs @@ -0,0 +1,29 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with a renew request. +/// +public enum RenewalOutcome +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering an outcome would change what an existing host does. + // When adding an outcome, give it the next unused value; + // values start at 1 so that an uninitialized outcome is not a valid one. + + /// + /// Issue a pending certificate for the CSR key, with the overload that + /// takes the renewing identity, and retire the identity's previous pending certificate, if any. + /// + IssuePendingCertificate = 1, + + /// + /// Return the chain of the identity's pending certificate, which holds the CSR key, + /// so that a device recovers a lost renew response. + /// + ReturnPendingCertificate = 2, + + /// + /// Refuse the request with . + /// + Reject = 3, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRules.cs new file mode 100644 index 0000000000..be35911baa --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRules.cs @@ -0,0 +1,72 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Applies the renewal rules that every authority shares to a renew request. +/// +public static class RenewalRules +{ + /// + /// Decides whether a renew request gets a new pending certificate, gets the identity's pending certificate again, + /// or is refused, from the issued certificate that holds the key of its CSR. + /// + /// + /// + /// The host calls this once the request authenticated and its body, CSR and metadata are valid. + /// The key of the identity's pending certificate gets that certificate's chain again, + /// so that a device recovers a lost renew response. + /// Any other issued key gets invalid_request: + /// the identity's current or retired key, any key of another identity, revoked or not, + /// and any key of a deleted identity. + /// A key never issued gets a new pending certificate, which retires the identity's previous pending certificate, + /// if any. + /// Renewal never promotes a certificate; only confirm does. + /// + /// + /// Every accepted request, including one that gets the pending certificate again, replaces the identity's metadata. + /// The decision holds only for the state it was made from. + /// The host issues the certificate atomically, so that of two concurrent renewals with one CSR key, + /// exactly one issues the certificate and the other gets that pending certificate. + /// + /// + /// + /// The identity that the renew request proved, as returns it + /// for . + /// + /// The issued certificate whose public key is the CSR key, or null when none has it. + /// is null. + /// + /// was authenticated for another operation than renew. + /// + public static RenewalDecision Decide(AuthenticatedIdentity renewing, IssuedKeyMatch? keyMatch) + { + ArgumentNullException.ThrowIfNull(renewing); + + if (renewing.Operation != AgentOperation.Renew) + { + throw new ArgumentException("The identity was not authenticated by a renew request.", nameof(renewing)); + } + + if (keyMatch is null) + { + return RenewalDecision.IssuePendingCertificate; + } + + // A public key belongs to one certificate lineage, so only the pending key of the identity itself is a retry. + if (keyMatch.IdentityDeleted) + { + return RenewalDecision.Reject("The CSR key belongs to a deleted identity."); + } + + if (keyMatch.IdentityId != renewing.IdentityId) + { + return RenewalDecision.Reject("The CSR key belongs to another identity."); + } + + if (keyMatch.Status != CertificateStatus.Pending) + { + return RenewalDecision.Reject("The CSR key belongs to a current or retired certificate of the identity."); + } + + return RenewalDecision.ReturnPendingCertificate; + } +} From 54743ee57dadf2b95caca77c562e0060f5992b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 21:01:43 +0900 Subject: [PATCH 34/93] refactor(dotnet-agent-identity): simplify the body writer checks The certificate list check of ResponseBodies took a description only to vary two messages, which the parameter name already tells apart. The body tests repeated the reading of the roots and of the chain. Drop the description and give the tests one helper for each array. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ResponseBodiesTests.cs | 20 ++++++++++++++----- .../ResponseBodies.cs | 13 +++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs index 7e47a3e6b6..2c3d59e6bc 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs @@ -68,7 +68,7 @@ public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() { byte[] body = ResponseBodies.TrustAnchor([VectorRoot]); - Dictionary root = TestJson.Members(TestJson.Elements(TestJson.Members(body)["roots"])[0]); + Dictionary root = TestJson.Members(Roots(body)[0]); Assert.Equal(TestVectors.Instance.Root.Certificate, TestJson.CanonicalBase64(root["certificate"])); Assert.Equal(TestVectors.Instance.Root.Thumbprint, root["thumbprint"].GetString()); } @@ -84,7 +84,7 @@ public void TrustAnchorGivesTheValidityOfTheRootInUtc() byte[] body = ResponseBodies.TrustAnchor([root]); - Dictionary entry = TestJson.Members(TestJson.Elements(TestJson.Members(body)["roots"])[0]); + Dictionary entry = TestJson.Members(Roots(body)[0]); Assert.Equal("2026-01-02T03:04:05Z", entry["not_before"].GetString()); Assert.Equal("2036-07-08T13:10:11Z", entry["not_after"].GetString()); } @@ -97,7 +97,7 @@ public void TrustAnchorListsEveryRootInTheOrderGiven() byte[] body = ResponseBodies.TrustAnchor([VectorRoot, newRoot]); - JsonElement[] roots = TestJson.Elements(TestJson.Members(body)["roots"]); + JsonElement[] roots = Roots(body); Assert.Equal( [VectorRoot.RawData, newRoot.RawData], roots.Select(root => TestJson.CanonicalBase64(TestJson.Members(root)["certificate"]))); @@ -189,7 +189,7 @@ public void ChainIsWrittenLeafFirst(string endpoint) { byte[] body = ChainBodies[endpoint]([DeviceA, VectorRoot]); - JsonElement[] chain = TestJson.Elements(TestJson.Members(body)["certificate_chain"]); + JsonElement[] chain = Chain(body); Assert.Equal( [TestVectors.Instance.Key("device-a").Certificate, TestVectors.Instance.Root.Certificate], chain.Select(TestJson.CanonicalBase64)); @@ -202,7 +202,7 @@ public void ChainOfTheLeafAloneIsWritten(string endpoint) { byte[] body = ChainBodies[endpoint]([DeviceA]); - JsonElement[] chain = TestJson.Elements(TestJson.Members(body)["certificate_chain"]); + JsonElement[] chain = Chain(body); Assert.Equal([TestVectors.Instance.Key("device-a").Certificate], chain.Select(TestJson.CanonicalBase64)); } @@ -265,6 +265,16 @@ public void BodyCarriesTheConfig(string endpoint) Assert.Equal("https://psu/channel", config["psu_channel_url"].GetString()); } + private static JsonElement[] Roots(byte[] body) + { + return TestJson.Elements(TestJson.Members(body)["roots"]); + } + + private static JsonElement[] Chain(byte[] body) + { + return TestJson.Elements(TestJson.Members(body)["certificate_chain"]); + } + private static X509Certificate2 SelfSignedRoot(DateTimeOffset notBefore, DateTimeOffset notAfter) { using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs index 60a08961ca..941a9ae6c7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs @@ -27,7 +27,7 @@ public static class ResponseBodies /// is empty or holds null. public static byte[] TrustAnchor(IEnumerable roots) { - X509Certificate2[] published = Certificates(roots, "root", nameof(roots)); + X509Certificate2[] published = Certificates(roots, nameof(roots)); return Write(writer => { @@ -142,22 +142,19 @@ private static byte[] Write(Action writeMembers) } // Copies the certificates, so that the checks and the body see the same ones. - private static X509Certificate2[] Certificates( - IEnumerable certificates, - string description, - string paramName) + private static X509Certificate2[] Certificates(IEnumerable certificates, string paramName) { ArgumentNullException.ThrowIfNull(certificates, paramName); X509Certificate2[] copy = [.. certificates]; if (copy.Length == 0) { - throw new ArgumentException($"There is no {description} certificate.", paramName); + throw new ArgumentException("There is no certificate.", paramName); } if (copy.Any(certificate => certificate is null)) { - throw new ArgumentException($"A {description} certificate is null.", paramName); + throw new ArgumentException("A certificate is null.", paramName); } return copy; @@ -165,7 +162,7 @@ private static X509Certificate2[] Certificates( private static X509Certificate2[] LeafFirstChain(IEnumerable certificateChain) { - X509Certificate2[] chain = Certificates(certificateChain, "chain", nameof(certificateChain)); + X509Certificate2[] chain = Certificates(certificateChain, nameof(certificateChain)); // RFC 5280 §4.1.2.6: a CA encodes its subject exactly as the issuer of each certificate it issues, // so a chain in another order fails this comparison. From 2ab593662cf11ff57041376206b74788dbeca375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 21:07:36 +0900 Subject: [PATCH 35/93] test(dotnet-agent-identity): pin the copies of configs and chains A config is immutable, and a response body must write the certificates that it checked, but no test caught a ToUtf8Json that returned the config's own buffer or a writer that read the caller's certificates a second time. Check that changing the returned JSON leaves the config unchanged, and that the trust-anchor, enroll and renew writers read their certificates once. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IdentityConfigTests.cs | 13 +++++++ .../ResponseBodiesTests.cs | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs index 063673d789..6f88d06fd9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs @@ -201,6 +201,19 @@ public void ConfigOutlivesTheDocumentOfItsProductFields() Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), written)); } + // A config is immutable, and the response bodies and ConfigUpdate messages write its JSON as it was created. + [Fact] + public void ChangingTheReturnedJsonLeavesTheConfigUnchanged() + { + IdentityConfig config = IdentityConfig.Create(3, "https://host/dvls"); + byte[] first = config.ToUtf8Json(); + byte[] original = [.. first]; + + first.AsSpan().Fill((byte)' '); + + Assert.Equal(original, config.ToUtf8Json()); + } + private static void AssertRejectsProductFields(KeyValuePair[] productFields) { ArgumentException exception = Assert.Throws( diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs index 2c3d59e6bc..5708395861 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs @@ -250,6 +250,41 @@ public void ChainWithANullCertificateIsRejected(string endpoint) Assert.Equal("certificateChain", exception.ParamName); } + // A writer that read the chain twice could check one chain and write another. + [Theory] + [MemberData(nameof(ChainBodyNames))] + public void ChainIsReadOnce(string endpoint) + { + int reads = 0; + + IEnumerable ReadChain() + { + reads++; + yield return DeviceA; + yield return VectorRoot; + } + + ChainBodies[endpoint](ReadChain()); + + Assert.Equal(1, reads); + } + + [Fact] + public void TrustAnchorReadsTheRootsOnce() + { + int reads = 0; + + IEnumerable ReadRoots() + { + reads++; + yield return VectorRoot; + } + + ResponseBodies.TrustAnchor(ReadRoots()); + + Assert.Equal(1, reads); + } + // CONTRACT.md §5.2 and §5.6: `config` is the identity's config, in the shape of §5.2. [Theory] [MemberData(nameof(ConfigBodyNames))] From 9895c308d7391f6a6faac2b706566cdd530ea44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 21:22:47 +0900 Subject: [PATCH 36/93] test(dotnet-agent-identity): verify test handshakes asynchronously The handshake now looks the opening certificate up again, so ChannelOpening.VerifyHello became VerifyHelloAsync, and the helper that authenticates a test identity through a channel no longer compiled. Await VerifyHelloAsync in that helper. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity.Tests/TestIdentity.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs index 8c7a05b934..8bfa400382 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs @@ -27,7 +27,7 @@ public static async Task AuthenticateAsync(AgentOperation opening.Opening.Challenge.Challenge.Challenge_.Span, TestRequest.DefaultNonce); ChannelHandshakeResult handshake = - opening.Opening.VerifyHello(VectorChannel.HelloMessage(opening.Opening, proof)); + await opening.Opening.VerifyHelloAsync(VectorChannel.HelloMessage(opening.Opening, proof)); Assert.True(handshake.Succeeded, handshake.FailureDetail); return handshake.Identity; From 518d37eb7fd70863d9d3c8ee8e5dace93151123a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 21:31:25 +0900 Subject: [PATCH 37/93] fix(dotnet-agent-identity): apply the JSON rules inside config values IdentityConfig checked only the names of the product-specific fields, so a value such as {"maxSessions": 4} reached the enroll and check-in responses and ConfigUpdate, against the snake_case keys of the contract's protocol conventions, and a value could repeat a member name, which two parsers may read differently. Create now walks each value, through nested objects and arrays, before writing it. Every member must have a snake_case name that no other member of its object has, names compared after unescaping, and a value other than null, since an absent field is omitted. An array element may still be null. The walk keeps its own stack, so a deep value cannot exhaust the thread's stack. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IdentityConfigTests.cs | 55 ++++++++++++++- .../IdentityConfig.cs | 69 +++++++++++++++++-- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs index 6f88d06fd9..3f1495dbe4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs @@ -179,10 +179,61 @@ public void CreateRejectsAProductFieldWithoutAName() // RFC 8259 §8.1: JSON text exchanged between systems is UTF-8, which cannot encode an unpaired surrogate. // Failing here keeps every later serialization of the config from failing. + [Theory] + [InlineData("""["ok", "\ud800"]""")] + [InlineData("""{"\ud800": 1}""")] + public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value) + { + AssertRejectsProductFields([new("label", TestJson.Parse(value))]); + } + + // CONTRACT.md §1: JSON keys are snake_case, so the members inside a product field's value are too. + [Theory] + [InlineData("""{"maxSessions": 4}""")] + [InlineData("""{"limits": {"max_sessions": 4, "Max_paths": 2}}""")] + public void CreateRejectsAMemberNameThatIsNotSnakeCaseInANestedObject(string value) + { + AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); + } + + [Theory] + [InlineData("""[{"Path": "a"}]""")] + [InlineData("""{"paths": [["a"], [{"pathName": "b"}]]}""")] + public void CreateRejectsAMemberNameThatIsNotSnakeCaseInAnObjectInAnArray(string value) + { + AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); + } + + // RFC 8259 §4: with a repeated name, receivers disagree on the value, as CONTRACT.md §1 notes of request bodies. + // A name and its escaped spelling are the same name. + [Theory] + [InlineData("""{"max_sessions": 4, "max_sessions": 5}""")] + [InlineData("""{"limits": [{"max_sessions": 4, "max_sessions": 5}]}""")] + [InlineData("""{"max_sessions": 4, "max\u005Fsessions": 5}""")] + public void CreateRejectsAMemberNameRepeatedInOneObjectOfAValue(string value) + { + AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); + } + + // CONTRACT.md §1: an absent field is omitted, never written as null, at any depth. + [Theory] + [InlineData("""{"proxy": null}""")] + [InlineData("""[{"proxy": null}]""")] + public void CreateRejectsANullMemberInAValue(string value) + { + AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); + } + + // A name repeats only within one object, and an array element is no field, so it may be null. [Fact] - public void CreateRejectsAProductFieldValueThatIsNotUnicodeText() + public void CreateAcceptsAValueWhoseMembersFollowTheConventions() { - AssertRejectsProductFields([new("label", TestJson.Parse("""["ok", "\ud800"]"""))]); + const string Limits = """{"a": {"b": 1}, "c": {"b": [1, {"b": 2}]}, "d": [{"b": 1}, {"b": 1}, null]}"""; + + IdentityConfig config = IdentityConfig.Create(1, null, [new("policy_limits", TestJson.Parse(Limits))]); + + JsonElement written = TestJson.Members(config.ToUtf8Json())["policy_limits"]; + Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), written)); } // A JsonElement reads from its JsonDocument, which the host may dispose once the config exists. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs index 3c57c3262c..cc969c9732 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs @@ -59,7 +59,9 @@ private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) /// agent_channel_url. /// Each value is any JSON value but null, /// since an absent field is omitted rather than written as null. - /// The library looks inside a value only to check that UTF-8 JSON can carry it. + /// The members of every object inside a value follow the same rules: + /// each has a snake_case name, which no other member of its object has, and a value other than null. + /// An array element is no member, so it may be null. /// /// The revision of the config. /// @@ -72,7 +74,9 @@ private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) /// is not an absolute https URL, /// or has user information, a query or a fragment; /// or a product-specific field has no name, a name that is not snake_case or that the contract gives a member, - /// no value, the value null, or a value that UTF-8 JSON cannot carry, such as a lone surrogate. + /// no value, the value null, or a value that UTF-8 JSON cannot carry, such as a lone surrogate; + /// or a member inside a value has a name that is not snake_case or that its object repeats, or the value + /// null. /// public static IdentityConfig Create( ulong revision, @@ -165,10 +169,17 @@ private static void WriteProductFields( nameof(productFields)); } - writer.WritePropertyName(name); - try { + // CONTRACT.md §1 governs every member of an agent-facing body, so it governs those inside the value. + if (CheckMembers(value) is { } problem) + { + throw new ArgumentException( + $"The value of the product field '{name}' {problem}.", + nameof(productFields)); + } + + writer.WritePropertyName(name); value.WriteTo(writer); } catch (InvalidOperationException exception) @@ -181,6 +192,56 @@ private static void WriteProductFields( } } + // Returns how a member inside the value breaks the conventions of CONTRACT.md §1, or null. + // The walk keeps its own stack, so that a deeply nested value cannot exhaust the thread's stack. + // Reading a name decodes its escapes, and throws InvalidOperationException for a lone surrogate. + private static string? CheckMembers(JsonElement value) + { + Stack pending = new(); + pending.Push(value); + + while (pending.TryPop(out JsonElement element)) + { + if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + pending.Push(item); + } + } + else if (element.ValueKind == JsonValueKind.Object) + { + HashSet names = new(StringComparer.Ordinal); + + foreach (JsonProperty member in element.EnumerateObject()) + { + string memberName = member.Name; + + // The name is quoted only once it is valid, so that a message never repeats arbitrary text. + if (!IsSnakeCase(memberName)) + { + return "has a member whose name is not snake_case"; + } + + // RFC 8259 §4: receivers disagree on the value of a repeated name. + if (!names.Add(memberName)) + { + return $"repeats the member name '{memberName}' in one object"; + } + + if (member.Value.ValueKind == JsonValueKind.Null) + { + return $"gives the member '{memberName}' the value null; an absent field is omitted"; + } + + pending.Push(member.Value); + } + } + } + + return null; + } + // Checks the name character by character: // a .NET regular expression ending in $ would also accept a final line feed. private static bool IsSnakeCase(string name) From c01363b52f80236b9082b94379c20f8e86940455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:25:48 +0900 Subject: [PATCH 38/93] docs(dotnet-agent-identity): add the package README DVLS and PSU embed the library to serve the agent-facing protocol, and using it correctly takes facts that no single type documents: what stays with the host, the guarantee each dependency must keep, and the order of the size check, authentication and body parsing on which the contract's error precedence depends. The README covers these, the HTTP and gRPC error responses, why only the signature check and the channel handshake create an AuthenticatedIdentity, and how to build, test and pack the package. It links the contract sections instead of restating them. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/README.md | 141 +++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 dotnet/AgentIdentity/README.md diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md new file mode 100644 index 0000000000..a377b99d1d --- /dev/null +++ b/dotnet/AgentIdentity/README.md @@ -0,0 +1,141 @@ +# Devolutions.AgentIdentity + +`Devolutions.AgentIdentity` implements what every Agent Identity authority must do the same way under [CONTRACT.md][contract], so that its hosts, such as DVLS and PSU, cannot drift apart on the protocol that Devolutions Agent speaks. +The contract is `docs/agent-identity/CONTRACT.md` in the [devolutions-gateway repository][repo], which also holds this library. + +## What the library covers + +- Enrollment tokens: `EnrollmentToken.Create` creates a token, `FriendlyNameFormat` parses and evaluates its friendly-name format, `EnrollmentAuthorization.ReadBearerToken` reads it from an `enroll` request, and `EnrollmentRules.Decide` decides the enrollment. + The host stores the token's `SecretHash`, never its `Value`, which it shows once and never logs. +- Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.md [§6]). +- Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`). +- Certificates: `IdentityCertificateIssuer.Issue` issues identity certificates. +- Agent channel: `ChannelOpening` verifies the handshake that authenticates a stream. +- Errors: `AgentIdentityError` gives each error its code, HTTP status and message, and writes its JSON body and gRPC exception. +- Encodings: `AgentIdentityProtocol` computes certificate thumbprints and formats timestamps for response bodies. + +The host keeps everything else: + +- Storage: identities, certificates and their statuses, the SPKI SHA-256 of every issued key, token hashes and tombstones, accepted nonces, metadata and config. +- Hosting: routes, TLS, success response bodies, the gRPC service, its timers and its push messages. +- Management: tokens, identities and root rotation, and the permissions that guard them. +- The root CA and its private key at rest; the library reaches the key only through `IRootSigner`. + +## Host dependencies + +The host provides these dependencies, with the guarantees the library relies on: + +- `ICertificateRegistry` finds, by thumbprint, a certificate that the authority issued, in any status including `Retired`, or returns null when there is none or its identity was deleted. + Index certificates by `AgentIdentityProtocol.ComputeThumbprint`, not by the SHA-1 `X509Certificate2.Thumbprint`. + Each `RegisteredCertificate` holds the DER exactly as issued, and the current `Status` and `IdentityRevoked`, so that a revocation takes effect from the next request. +- `INonceStore.TryRecordAsync` records a `keyid` and `nonce` pair, or returns false when the pair is already recorded, in one atomic operation, and keeps the pair until `retainUntil`. +- `IRootSigner` holds the root that issues new certificates and a `SignatureGenerator` over its private key. + Its `Certificate` must have a Subject Key Identifier extension, which becomes the Authority Key Identifier of each issued certificate; without one, `IdentityCertificateIssuer.Issue` throws `ArgumentException`. + Once a root rotation starts, it holds the new root (CONTRACT.md [§8.3]). +- `TimeProvider` is the authority's clock, usually `TimeProvider.System`, which decides signature freshness, certificate validity and handshake deadlines. + Take the `now` of `EnrollmentRules.Decide` and the `serverTime` of every error from the same clock, since the agent corrects its own clock from `server_time`. +- `RandomNumberGenerator` must be cryptographically secure, such as `RandomNumberGenerator.Create()`, since it supplies channel challenges, token secrets and certificate serial numbers. + +`RequestAuthenticator` keeps no state between requests, so one instance can serve them all when its dependencies accept concurrent calls. +Exceptions from the registry and the nonce store propagate to the caller. + +## Call order + +Each HTTP request goes through the body size check, then authentication, then body parsing, so that the error precedence of CONTRACT.md [§5.4] holds: a body over 64 KiB fails first, and an authentication error wins over an invalid body, CSR or metadata. +When a step fails, answer with the `Error` of its result, as [Errors][errors] describes. + +### `enroll` + +1. Answer a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge`. +2. Pass the `Authorization` field lines, as received, to `EnrollmentAuthorization.ReadBearerToken`. + Find the stored token by the result's `SecretHash`, deleted tokens included, and answer `AgentIdentityError.From(AgentIdentityErrorCode.TokenInvalid)` when there is none. +3. Parse the body with `EnrollRequestBody.Parse`. +4. Look up `Csr.SubjectPublicKeyInfoSha256` among the keys of every certificate the authority has issued, deleted identities included. + Describe the match with `IssuedKeyMatch.ForIdentity` or `IssuedKeyMatch.ForDeletedIdentity`, or use null when there is none. +5. Pass the token's `EnrollmentTokenState`, the match and the current time to `EnrollmentRules.Decide`, and act on its `Outcome`: + - `Reject`: answer with `Error`. + - `ReturnExistingIdentity`: answer with identity `ExistingIdentityId` and its current certificate chain, and change nothing. + - `CreateIdentity`: mint an identity ID, issue its certificate with `IdentityCertificateIssuer.Issue`, name it with `FriendlyNameFormat.Evaluate`, and store it with its metadata and the key's `SubjectPublicKeyInfoSha256` (CONTRACT.md [§5.2]). + Create the identity and count the token use atomically, as CONTRACT.md [§2.1] requires of concurrent enrollments. + +### `renew`, `confirm` and `check-in` + +1. Build a `SignedHttpRequest` from the method, the `Signature-Input`, `Signature` and `Content-Digest` field lines, and the body bytes, all as received. +2. Pass it to `RequestAuthenticator.AuthenticateAsync` with `AgentOperation.Renew`, `AgentOperation.Confirm` or `AgentOperation.CheckIn`. + It answers a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge` before it checks the signature. +3. Parse a `renew` body with `RenewRequestBody.Parse` and a `check-in` body with `CheckInRequestBody.Parse`; a `confirm` body is empty. +4. Act for the result's `Identity` only, as CONTRACT.md [§5.3], [§5.5] and [§5.6] specify. + `renew` issues a pending certificate with `IdentityCertificateIssuer.Issue` only for a key that was never issued, and `confirm` promotes the certificate only when `Identity.CertificateStatus` is `CertificateStatus.Pending`. + +### Agent channel + +1. Pass the request method and the values of the `signature-input` and `signature` request headers, in received order, to `RequestAuthenticator.AuthenticateChannelOpeningAsync`. +2. Send the result's `Opening.Challenge` as the first message on the stream. +3. Pass the first agent message to `Opening.VerifyHello`. + Call `Opening.HelloTimedOut` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. + The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. +4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`, from CONTRACT.md [§7.3] step 5 on. +5. Close the stream no later than `Identity.CertificateNotAfter`, with `AgentIdentityErrorCode.CertificateExpired`. + +A channel opening has no body; `VerifyHello` validates the metadata of the `Hello` after its proof, in the order of CONTRACT.md [§7.3] step 4. +`VerifyHello` does not read the registry again, so register the stream as live and then check that the identity is still not revoked; a revocation during the handshake then still closes the stream. + +## Errors + +Over HTTP, every non-2xx response under `{u}/api/agent-identity/v1/` must carry an error body, including those of the web framework (CONTRACT.md [§5.4]). +Answer with status `HttpStatusCode`, header `Content-Type: application/json` and body `ToJsonBody(serverTime)`. +For a failure that the library does not report, use `AgentIdentityError.From` with its code, or one of these errors: + +| Failure | Error | +| ---------------------------------------- | ------------------------- | +| Path that no endpoint serves | `UnknownRoute`, 404 | +| Method that the endpoint does not accept | `MethodNotAllowed`, 405 | +| Body that the web framework cannot read | `MalformedBody`, 400 | +| Body larger than 64 KiB | `BodyTooLarge`, 413 | +| Unexpected exception | `UnexpectedFailure`, 500 | +| Overload or a missing dependency | `ServiceUnavailable`, 503 | + +The library writes no headers, so the host adds `Retry-After` to `ServiceUnavailable` when it sheds load. + +Over gRPC, end the call by throwing `ToRpcException(serverTime)`, which carries the status of `ToGrpcStatusCode` and the trailers `error-code` and `server-time` (CONTRACT.md [§7.4]). +Close a live stream the same way when its identity is revoked (`IdentityRevoked`) or its certificate expires (`CertificateExpired`). + +Log `FailureDetail` beside the error: it names the rule that the request broke and never holds a token or a secret. + +## Authenticated identities + +Only the signature check, `RequestAuthenticator.AuthenticateAsync`, and the channel handshake, `ChannelOpening.VerifyHello`, create an `AuthenticatedIdentity`, which has no public constructor. +Code that acts for an identity should take one, so that it acts for the identity a signature proved, never for an identity ID that the agent or a caller sent. +A verified channel opening yields only a `ChannelOpening`, whose identity stays hidden until the agent signs the stream's challenge, since signed opening headers are not tied to one stream and can be captured by a TLS-terminating proxy, TLS inspection or a header log (security invariant 7 of [INTENT.agent-identity.md][invariants]). +For the same reason, `AuthenticateAsync` throws `ArgumentOutOfRangeException` for `AgentOperation.Connect`. + +## Build, test and pack + +From the repository root, with the .NET 10 SDK: + +```powershell +pwsh ./dotnet/AgentIdentity/build.ps1 +dotnet test ./dotnet/AgentIdentity/AgentIdentity.sln +``` + +`build.ps1` builds the library in Release and packs it, with its symbol package, into `dotnet/AgentIdentity/Devolutions.AgentIdentity/bin/Release/`. +The tests also check the library against the shared vectors in `docs/agent-identity/test-vectors.json`. + +The package depends on `Devolutions.Agent.Channel`, the gRPC types generated from the channel schema (CONTRACT.md [§7.1]): the handshake takes its `AgentMessage` and returns its `ServerMessage`. +Implement the `AgentChannel` service with that package; a feed that serves this package must serve that one too. + +[contract]: ../../docs/agent-identity/CONTRACT.md +[repo]: https://github.com/Devolutions/devolutions-gateway +[§2.1]: ../../docs/agent-identity/CONTRACT.md#21-authority-behavior +[§5.2]: ../../docs/agent-identity/CONTRACT.md#52-post-enroll +[§5.3]: ../../docs/agent-identity/CONTRACT.md#53-post-renew +[§5.4]: ../../docs/agent-identity/CONTRACT.md#agent-identity-errors +[§5.5]: ../../docs/agent-identity/CONTRACT.md#55-post-confirm +[§5.6]: ../../docs/agent-identity/CONTRACT.md#56-post-check-in-http-fallback +[§6]: ../../docs/agent-identity/CONTRACT.md#agent-identity-signatures +[§7.1]: ../../docs/agent-identity/CONTRACT.md#71-service +[§7.3]: ../../docs/agent-identity/CONTRACT.md#73-sequence +[§7.4]: ../../docs/agent-identity/CONTRACT.md#74-termination +[§8.3]: ../../docs/agent-identity/CONTRACT.md#83-root-rotation +[invariants]: ../../docs/agent-identity/INTENT.agent-identity.md#security-invariants +[errors]: #errors From b7612f8f128dd771df02dbc662695bd07c2f2a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 20:25:56 +0900 Subject: [PATCH 39/93] build(dotnet-agent-identity): pack the README NuGet shows a package's README on its gallery page and in IDEs, and pack warns about a package without one. PackageReadmeFile names the README, and a packed None item places it at the root of the package. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj index d1f6da7f52..9b498dd5fb 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj @@ -16,6 +16,7 @@ Devolutions.AgentIdentity 2026.10.20 MIT OR Apache-2.0 + README.md https://github.com/Devolutions/devolutions-gateway.git git true @@ -26,6 +27,10 @@ + + + + From 362fb7d2b6dc3bd9040a0bd19fa19210e442cb92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 21:48:59 +0900 Subject: [PATCH 40/93] docs(dotnet-agent-identity): cover responses, renewals and pushes The library now writes the response bodies, decides renewals, issues them for the proved identity, builds the channel push messages and re-reads the registry during the channel handshake, so a host that follows the README must call these rather than reimplement them. Each endpoint now has its own steps: the trust-anchor, enroll, renew and check-in responses come from ResponseBodies, a renewal goes through RenewalRules.Decide and the Issue overload that takes the renewing AuthenticatedIdentity, confirm reconnects the streams of the retired certificate, and the channel uses VerifyHelloAsync, treats a certificate retired during the handshake like any retired one, and pushes RenewRequested and ConfigUpdate. The advice to re-check revocation after the handshake is gone, since VerifyHelloAsync does it. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/README.md | 72 +++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index a377b99d1d..911db9d8af 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -5,19 +5,21 @@ The contract is `docs/agent-identity/CONTRACT.md` in the [devolutions-gateway re ## What the library covers -- Enrollment tokens: `EnrollmentToken.Create` creates a token, `FriendlyNameFormat` parses and evaluates its friendly-name format, `EnrollmentAuthorization.ReadBearerToken` reads it from an `enroll` request, and `EnrollmentRules.Decide` decides the enrollment. +- Enrollment tokens: `EnrollmentToken.Create` creates a token, `FriendlyNameFormat` parses and evaluates its friendly-name format, and `EnrollmentAuthorization.ReadBearerToken` reads it from an `enroll` request. The host stores the token's `SecretHash`, never its `Value`, which it shows once and never logs. - Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.md [§6]). - Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`). -- Certificates: `IdentityCertificateIssuer.Issue` issues identity certificates. -- Agent channel: `ChannelOpening` verifies the handshake that authenticates a stream. +- Decisions: `EnrollmentRules.Decide` decides an enrollment and `RenewalRules.Decide` a renewal, given the issued certificate that holds the CSR key. +- Certificates: `IdentityCertificateIssuer.Issue` issues the certificate of a new identity, or of the identity that a renew request proved. +- Responses: `IdentityConfig` holds an identity's config, and `ResponseBodies` writes the bodies of the `trust-anchor`, `enroll`, `renew` and `check-in` responses. +- Agent channel: `ChannelOpening` verifies the handshake that authenticates a stream, and `ChannelMessages` builds the `ConfigUpdate`, `RenewRequested` and `Reconnect` messages that the authority pushes. - Errors: `AgentIdentityError` gives each error its code, HTTP status and message, and writes its JSON body and gRPC exception. -- Encodings: `AgentIdentityProtocol` computes certificate thumbprints and formats timestamps for response bodies. +- Encodings: `AgentIdentityProtocol` computes certificate thumbprints and formats timestamps. The host keeps everything else: -- Storage: identities, certificates and their statuses, the SPKI SHA-256 of every issued key, token hashes and tombstones, accepted nonces, metadata and config. -- Hosting: routes, TLS, success response bodies, the gRPC service, its timers and its push messages. +- Storage: identities, certificates and their statuses, the SPKI SHA-256 of every issued key, token hashes and tombstones, accepted nonces, metadata, configs and request-renewal flags. +- Hosting: routes, TLS, the gRPC service and its timers, and when to send each push message. - Management: tokens, identities and root rotation, and the permissions that guard them. - The root CA and its private key at rest; the library reaches the key only through `IRootSigner`. @@ -44,6 +46,10 @@ Exceptions from the registry and the nonce store propagate to the caller. Each HTTP request goes through the body size check, then authentication, then body parsing, so that the error precedence of CONTRACT.md [§5.4] holds: a body over 64 KiB fails first, and an authentication error wins over an invalid body, CSR or metadata. When a step fails, answer with the `Error` of its result, as [Errors][errors] describes. +### `trust-anchor` + +Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normally, the old and the new during a rotation (CONTRACT.md [§5.1]). + ### `enroll` 1. Answer a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge`. @@ -54,31 +60,58 @@ When a step fails, answer with the `Error` of its result, as [Errors][errors] de Describe the match with `IssuedKeyMatch.ForIdentity` or `IssuedKeyMatch.ForDeletedIdentity`, or use null when there is none. 5. Pass the token's `EnrollmentTokenState`, the match and the current time to `EnrollmentRules.Decide`, and act on its `Outcome`: - `Reject`: answer with `Error`. - - `ReturnExistingIdentity`: answer with identity `ExistingIdentityId` and its current certificate chain, and change nothing. - - `CreateIdentity`: mint an identity ID, issue its certificate with `IdentityCertificateIssuer.Issue`, name it with `FriendlyNameFormat.Evaluate`, and store it with its metadata and the key's `SubjectPublicKeyInfoSha256` (CONTRACT.md [§5.2]). + - `ReturnExistingIdentity`: take identity `ExistingIdentityId`, and change nothing. + - `CreateIdentity`: mint an identity ID, issue its certificate with `IdentityCertificateIssuer.Issue`, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata and the key's `SubjectPublicKeyInfoSha256`. Create the identity and count the token use atomically, as CONTRACT.md [§2.1] requires of concurrent enrollments. +6. Answer with `ResponseBodies.Enroll`, given the authority ID and the identity's ID, current certificate chain, leaf first, and config (CONTRACT.md [§5.2]). + +### Signed requests -### `renew`, `confirm` and `check-in` +`renew`, `confirm` and `check-in` start alike: 1. Build a `SignedHttpRequest` from the method, the `Signature-Input`, `Signature` and `Content-Digest` field lines, and the body bytes, all as received. 2. Pass it to `RequestAuthenticator.AuthenticateAsync` with `AgentOperation.Renew`, `AgentOperation.Confirm` or `AgentOperation.CheckIn`. It answers a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge` before it checks the signature. 3. Parse a `renew` body with `RenewRequestBody.Parse` and a `check-in` body with `CheckInRequestBody.Parse`; a `confirm` body is empty. -4. Act for the result's `Identity` only, as CONTRACT.md [§5.3], [§5.5] and [§5.6] specify. - `renew` issues a pending certificate with `IdentityCertificateIssuer.Issue` only for a key that was never issued, and `confirm` promotes the certificate only when `Identity.CertificateStatus` is `CertificateStatus.Pending`. + +Each then acts for the result's `Identity` only. + +### `renew` + +1. Look up the CSR key as for `enroll`, passing false for `enrolledWithPresentedToken`. +2. Pass `Identity` and the match to `RenewalRules.Decide`, and act on its `Outcome`: + - `Reject`: answer with `Error`. + - `ReturnPendingCertificate`: keep the identity's pending certificate, which holds the key. + - `IssuePendingCertificate`: issue a certificate with the `IdentityCertificateIssuer.Issue` overload that takes the `AuthenticatedIdentity`, and store it as the identity's pending certificate, retiring the previous one. + Store it atomically, as CONTRACT.md [§2.1] requires of concurrent renewals. +3. Replace the identity's metadata, and answer with `ResponseBodies.Renew`, given the pending certificate's chain (CONTRACT.md [§5.3]). + +### `confirm` + +When `Identity.CertificateStatus` is `CertificateStatus.Pending`, make the certificate current, retire the previous current certificate, and clear the request-renewal flag if it was set before the certificate was issued (CONTRACT.md [§8.2]). +Then send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` on each stream that the retired certificate opened, and close those streams with `OK` 60 s later (CONTRACT.md [§7.4]). +Answer `204`, also when the certificate was already current (CONTRACT.md [§5.5]). + +### `check-in` + +Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given the identity's config and its request-renewal flag (CONTRACT.md [§5.6], [§8.2]). ### Agent channel 1. Pass the request method and the values of the `signature-input` and `signature` request headers, in received order, to `RequestAuthenticator.AuthenticateChannelOpeningAsync`. 2. Send the result's `Opening.Challenge` as the first message on the stream. -3. Pass the first agent message to `Opening.VerifyHello`. +3. Pass the first agent message to `Opening.VerifyHelloAsync`. Call `Opening.HelloTimedOut` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. -4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`, from CONTRACT.md [§7.3] step 5 on. -5. Close the stream no later than `Identity.CertificateNotAfter`, with `AgentIdentityErrorCode.CertificateExpired`. +4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`. +5. When `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` and close the stream with `OK` 60 s later, as for `confirm`. + Otherwise, send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.md [§7.3] steps 5 and 7). +6. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.md [§7.4]). + +A channel opening has no body: `VerifyHelloAsync` reads the registry again and checks the certificate's expiry and the deadline before the message and its proof, and the metadata of the `Hello` last, in the order of CONTRACT.md [§7.4] and [§7.3] step 4. -A channel opening has no body; `VerifyHello` validates the metadata of the `Hello` after its proof, in the order of CONTRACT.md [§7.3] step 4. -`VerifyHello` does not read the registry again, so register the stream as live and then check that the identity is still not revoked; a revocation during the handshake then still closes the stream. +When an identity's config changes, create its `IdentityConfig` again with a higher `Revision`, and send `ChannelMessages.ConfigUpdate` on each authenticated stream of the identity. +`ChannelMessages.RenewRequested` takes `RenewalRequestReason.Rotation` for the notifications of a root rotation (CONTRACT.md [§8.3]), and `RenewalRequestReason.Admin` for an administrator's request. ## Errors @@ -98,14 +131,15 @@ For a failure that the library does not report, use `AgentIdentityError.From` wi The library writes no headers, so the host adds `Retry-After` to `ServiceUnavailable` when it sheds load. Over gRPC, end the call by throwing `ToRpcException(serverTime)`, which carries the status of `ToGrpcStatusCode` and the trailers `error-code` and `server-time` (CONTRACT.md [§7.4]). -Close a live stream the same way when its identity is revoked (`IdentityRevoked`) or its certificate expires (`CertificateExpired`). +For a closure that the host decides, such as a revocation, build the error with `AgentIdentityError.From`. Log `FailureDetail` beside the error: it names the rule that the request broke and never holds a token or a secret. ## Authenticated identities -Only the signature check, `RequestAuthenticator.AuthenticateAsync`, and the channel handshake, `ChannelOpening.VerifyHello`, create an `AuthenticatedIdentity`, which has no public constructor. +Only the signature check, `RequestAuthenticator.AuthenticateAsync`, and the channel handshake, `ChannelOpening.VerifyHelloAsync`, create an `AuthenticatedIdentity`, which has no public constructor. Code that acts for an identity should take one, so that it acts for the identity a signature proved, never for an identity ID that the agent or a caller sent. +The library does so itself: `RenewalRules.Decide` and the renewal overload of `IdentityCertificateIssuer.Issue` take the renewing `AuthenticatedIdentity`, and throw `ArgumentException` for one authenticated for another operation. A verified channel opening yields only a `ChannelOpening`, whose identity stays hidden until the agent signs the stream's challenge, since signed opening headers are not tied to one stream and can be captured by a TLS-terminating proxy, TLS inspection or a header log (security invariant 7 of [INTENT.agent-identity.md][invariants]). For the same reason, `AuthenticateAsync` throws `ArgumentOutOfRangeException` for `AgentOperation.Connect`. @@ -127,6 +161,7 @@ Implement the `AgentChannel` service with that package; a feed that serves this [contract]: ../../docs/agent-identity/CONTRACT.md [repo]: https://github.com/Devolutions/devolutions-gateway [§2.1]: ../../docs/agent-identity/CONTRACT.md#21-authority-behavior +[§5.1]: ../../docs/agent-identity/CONTRACT.md#51-get-trust-anchor [§5.2]: ../../docs/agent-identity/CONTRACT.md#52-post-enroll [§5.3]: ../../docs/agent-identity/CONTRACT.md#53-post-renew [§5.4]: ../../docs/agent-identity/CONTRACT.md#agent-identity-errors @@ -136,6 +171,7 @@ Implement the `AgentChannel` service with that package; a feed that serves this [§7.1]: ../../docs/agent-identity/CONTRACT.md#71-service [§7.3]: ../../docs/agent-identity/CONTRACT.md#73-sequence [§7.4]: ../../docs/agent-identity/CONTRACT.md#74-termination +[§8.2]: ../../docs/agent-identity/CONTRACT.md#82-server-certificate-state [§8.3]: ../../docs/agent-identity/CONTRACT.md#83-root-rotation [invariants]: ../../docs/agent-identity/INTENT.agent-identity.md#security-invariants [errors]: #errors From 3ed4614b76ddb41dfbdb6587a5a0c76c4ec9223e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 22:04:20 +0900 Subject: [PATCH 41/93] docs(dotnet-agent-identity): reconcile every authenticated stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACT.md §7.3 steps 5 and 7 send RenewRequested and ConfigUpdate after every Welcome they apply to. §7.4 adds Reconnect and the closure 60 s later for a stream whose certificate was retired during the handshake, but exempts that stream from neither message, and the README skipped both for it. The channel steps now reconcile every authenticated stream, and handle a retired certificate in a separate step that also applies. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 911db9d8af..aff81d323f 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -104,9 +104,9 @@ Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given Call `Opening.HelloTimedOut` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. 4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`. -5. When `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` and close the stream with `OK` 60 s later, as for `confirm`. - Otherwise, send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.md [§7.3] steps 5 and 7). -6. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.md [§7.4]). +5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.md [§7.3] steps 5 and 7). +6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.md [§7.4]). +7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.md [§7.4]). A channel opening has no body: `VerifyHelloAsync` reads the registry again and checks the certificate's expiry and the deadline before the message and its proof, and the metadata of the `Hello` last, in the order of CONTRACT.md [§7.4] and [§7.3] step 4. From 9550ada41328883d441a57ef993b9984e3864c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 22:05:16 +0900 Subject: [PATCH 42/93] docs(dotnet-agent-identity): link the contract by absolute URL NuGet's README renderer drops repository-relative links, and the contract and intent files are not in the package, so every contract and intent link in the packed README led nowhere. The link definitions now point at those files on the master branch on GitHub, with the same section anchors. The package is published from master, so the paths resolve there. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index aff81d323f..b8c2ebb94c 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -158,20 +158,20 @@ The tests also check the library against the shared vectors in `docs/agent-ident The package depends on `Devolutions.Agent.Channel`, the gRPC types generated from the channel schema (CONTRACT.md [§7.1]): the handshake takes its `AgentMessage` and returns its `ServerMessage`. Implement the `AgentChannel` service with that package; a feed that serves this package must serve that one too. -[contract]: ../../docs/agent-identity/CONTRACT.md +[contract]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md [repo]: https://github.com/Devolutions/devolutions-gateway -[§2.1]: ../../docs/agent-identity/CONTRACT.md#21-authority-behavior -[§5.1]: ../../docs/agent-identity/CONTRACT.md#51-get-trust-anchor -[§5.2]: ../../docs/agent-identity/CONTRACT.md#52-post-enroll -[§5.3]: ../../docs/agent-identity/CONTRACT.md#53-post-renew -[§5.4]: ../../docs/agent-identity/CONTRACT.md#agent-identity-errors -[§5.5]: ../../docs/agent-identity/CONTRACT.md#55-post-confirm -[§5.6]: ../../docs/agent-identity/CONTRACT.md#56-post-check-in-http-fallback -[§6]: ../../docs/agent-identity/CONTRACT.md#agent-identity-signatures -[§7.1]: ../../docs/agent-identity/CONTRACT.md#71-service -[§7.3]: ../../docs/agent-identity/CONTRACT.md#73-sequence -[§7.4]: ../../docs/agent-identity/CONTRACT.md#74-termination -[§8.2]: ../../docs/agent-identity/CONTRACT.md#82-server-certificate-state -[§8.3]: ../../docs/agent-identity/CONTRACT.md#83-root-rotation -[invariants]: ../../docs/agent-identity/INTENT.agent-identity.md#security-invariants +[§2.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#21-authority-behavior +[§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#51-get-trust-anchor +[§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#52-post-enroll +[§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#53-post-renew +[§5.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#agent-identity-errors +[§5.5]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#55-post-confirm +[§5.6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#56-post-check-in-http-fallback +[§6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#agent-identity-signatures +[§7.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#71-service +[§7.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#73-sequence +[§7.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#74-termination +[§8.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#82-server-certificate-state +[§8.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#83-root-rotation +[invariants]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/INTENT.agent-identity.md#security-invariants [errors]: #errors From aff64b8dfe7c532f250d5927fc98c64ebec49fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 22:20:49 +0900 Subject: [PATCH 43/93] feat(dotnet-agent-identity): apply stream closures to the Hello timeout A revocation, a deletion and the certificate's notAfter close a stream still awaiting its Hello ahead of every failure of the handshake, the timeout included, so a stream whose identity was revoked while it waited must close with identity_revoked rather than signature_invalid. ChannelOpening.HelloTimedOutAsync replaces HelloTimedOut. Like VerifyHelloAsync, it ends the handshake at once, reads the clock and then the certificate registry once, and returns identity_unknown for a certificate no longer registered, identity_revoked for a revoked identity and certificate_expired after notAfter, before it falls back to signature_invalid. A registry answer for another certificate or identity throws. Both methods share the clock and registry reading and these closures. The docs now say that the certificate of a stream may be retired during the handshake by a renew as well as a confirm, and leave its treatment to the contract. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AuthenticationTypeTests.cs | 2 +- .../ChannelHandshakeTests.cs | 141 +++++++++++++++--- .../AuthenticatedIdentity.cs | 3 +- .../ChannelHandshake.cs | 2 +- .../ChannelHandshakeResult.cs | 6 +- .../ChannelOpening.cs | 127 +++++++++++----- 6 files changed, 217 insertions(+), 64 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs index aac1962a75..e2b2dcda5b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs @@ -57,7 +57,7 @@ public void OnlyAuthenticationProducesTheseTypes() [ "AuthenticationResult.get_Identity", "ChannelHandshakeResult.get_Identity", - "ChannelOpening.HelloTimedOut", + "ChannelOpening.HelloTimedOutAsync", "ChannelOpening.VerifyHelloAsync", "ChannelOpeningResult.get_Opening", "RequestAuthenticator.AuthenticateAsync", diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs index 48acc8124c..cfeb3eab2e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs @@ -71,14 +71,31 @@ public class ChannelHandshakeTests opening.VerifyHelloAsync(VectorChannel.HelloMessage(opening, OtherKeyProof)), ["a Hello with invalid metadata"] = opening => opening.VerifyHelloAsync( Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"])), - ["no Hello in time"] = opening => ValueTask.FromResult(opening.HelloTimedOut()), + ["no Hello in time"] = opening => opening.HelloTimedOutAsync(), }; public static TheoryData SignatureFailureNames => new(SignatureFailures.Keys); public static TheoryData ScenarioNames => new(Scenarios.Keys); - public static TheoryData WrongRegistryAnswerNames => new(WrongRegistryAnswers.Keys); + public static TheoryData ClosureNames => new(Closures.Keys); + + // Each wrong answer of the registry, to each of the two calls that read it. + public static TheoryData WrongRegistryAnswersAndCalls + { + get + { + TheoryData cases = new(); + + foreach (string answer in WrongRegistryAnswers.Keys) + { + cases.Add(answer, "a valid Hello"); + cases.Add(answer, "no Hello in time"); + } + + return cases; + } + } // Each failure decided before the message, with each failure that the handshake checks after it. public static TheoryData EarlyAndLaterFailures @@ -100,6 +117,26 @@ public static TheoryData EarlyAndLaterFailures } } + // Each closure, with each closure checked after it and with the timeout itself. + public static TheoryData ClosuresAndLaterTimeoutFailures + { + get + { + TheoryData cases = new(); + string[] closures = [.. Closures.Keys]; + + for (int i = 0; i < closures.Length; i++) + { + foreach (string later in closures.Skip(i + 1).Append("no Hello in time")) + { + cases.Add(closures[i], later); + } + } + + return cases; + } + } + // Every way to end a handshake, followed by each of the two calls a host makes. public static TheoryData HandshakeEndsThenAnotherCall { @@ -139,12 +176,12 @@ public static TheoryData HandshakeEndsThenAnotherCall Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"]), }; - // The failures that the handshake decides from the registry and the clock before it reads the message, - // in the order it checks them, with the code and the gRPC status that close the stream (CONTRACT.md §7.4). + // CONTRACT.md §7.4: the closures that apply to a stream even while it awaits its Hello, in the order the + // handshake checks them, with the code and the gRPC status that close the stream. // The certificate of device-a expires 70 days after the vector opening, so a reading after its notAfter is also // after the Hello timeout. private static OrderedDictionary - EarlyFailures => new() + Closures => new() { ["the certificate is no longer registered"] = ( new Scenario { CertificateRemoved = true }, @@ -158,18 +195,33 @@ public static TheoryData HandshakeEndsThenAnotherCall new Scenario { HandshakeTime = DeviceANotAfter.AddTicks(1) }, AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated), + }; + + // The failures that the handshake decides from the registry and the clock before it reads the message: + // the closures, then a late Hello. + private static OrderedDictionary + EarlyFailures => new(Closures) + { ["the Hello is late"] = ( new Scenario { HandshakeTime = VectorChannel.OpeningTime.AddSeconds(10).AddTicks(1) }, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated), }; - // Every scenario by name: a valid handshake, then each early failure and each message failure. + // No first message arrives, and the host ends the handshake for the timeout. + private static Scenario NoHello => new() { FirstMessage = null }; + + // Every scenario by name: a valid handshake, each early failure, each message failure, + // and the timeout, alone and after each closure. private static Dictionary Scenarios => new[] { KeyValuePair.Create("a valid Hello", new Scenario()) } .Concat(EarlyFailures.Select(failure => KeyValuePair.Create(failure.Key, failure.Value.Scenario))) .Concat(MessageFailures.Select( failure => KeyValuePair.Create(failure.Key, new Scenario { FirstMessage = failure.Value }))) + .Append(KeyValuePair.Create("no Hello in time", NoHello)) + .Concat(Closures.Select(closure => KeyValuePair.Create( + $"no Hello in time, and {closure.Key}", + closure.Value.Scenario.And(NoHello)))) .ToDictionary(); // A raw 64-byte signature over the right bytes, by the key of device-b. @@ -273,8 +325,8 @@ public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() AssertFailed(result, AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated); } - // CONTRACT.md §7.4: when a confirm retires the certificate of a stream, the stream gets Reconnect and closes later, - // so a stream whose certificate was retired during the handshake authenticates and reports the retirement. + // CONTRACT.md §7.4: a stream whose certificate is retired while it awaits its Hello still completes the handshake, + // then gets the treatment of every stream of a retired certificate, so the result reports the retirement. // Failing it would send a healthy agent the terminal identity_unknown. [Fact] public async Task CertificateRetiredDuringTheHandshakeAuthenticatesAsRetired() @@ -326,15 +378,14 @@ public async Task HandshakeReadsTheRegistryOnce(string scenario) // The registry answers a thumbprint with the certificate issued under it, for its one identity, // so another answer is a host bug, which must fail as a server error rather than draw an outcome the agent acts on. [Theory] - [MemberData(nameof(WrongRegistryAnswerNames))] - public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string answer) + [MemberData(nameof(WrongRegistryAnswersAndCalls))] + public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string answer, string call) { VectorAuthority authority = new(); ChannelOpening opening = await VectorChannel.OpenAsync(authority); authority.Registry.Register(opening.CertificateThumbprint, WrongRegistryAnswers[answer]()); - await Assert.ThrowsAsync( - () => opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)).AsTask()); + await Assert.ThrowsAsync(async () => await HandshakeEnds[call](opening)); } // The handshake ends when VerifyHelloAsync is called, not when the registry answers, @@ -350,12 +401,33 @@ public async Task HandshakeEndsBeforeTheRegistryAnswers() opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.False(verifying.IsCompleted); - Assert.Throws(() => opening.HelloTimedOut()); - + ValueTask timingOut = opening.HelloTimedOutAsync(); answer.SetResult(); + + await Assert.ThrowsAsync(() => timingOut.AsTask()); Assert.True((await verifying).Succeeded); } + // The timeout ends the handshake when HelloTimedOutAsync is called too, + // so a Hello that arrives during its lookup cannot authenticate a stream that is being closed. + [Fact] + public async Task TimeoutEndsTheHandshakeBeforeTheRegistryAnswers() + { + VectorAuthority authority = new(); + ChannelOpening opening = await VectorChannel.OpenAsync(authority); + TaskCompletionSource answer = new(TaskCreationOptions.RunContinuationsAsynchronously); + authority.Registry.Gate = answer.Task; + ValueTask timingOut = opening.HelloTimedOutAsync(); + Assert.False(timingOut.IsCompleted); + + ValueTask verifying = + opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); + answer.SetResult(); + + await Assert.ThrowsAsync(() => verifying.AsTask()); + AssertFailed(await timingOut, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); + } + // CONTRACT.md §6 reads the clock before the registry, and so does the handshake, // so a Hello that arrives in time stays in time however long the registry takes to answer. [Fact] @@ -528,7 +600,7 @@ public async Task NoHelloInTimeIsSignatureInvalid() { ChannelOpening opening = await VectorChannel.OpenAsync(); - ChannelHandshakeResult result = opening.HelloTimedOut(); + ChannelHandshakeResult result = await opening.HelloTimedOutAsync(); AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } @@ -544,11 +616,41 @@ public async Task TimeoutIsCertificateExpiredOnlyAfterTheNotAfter( { (ChannelOpening opening, _) = await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(ticksAfterNotAfter)); - ChannelHandshakeResult result = opening.HelloTimedOut(); + ChannelHandshakeResult result = await opening.HelloTimedOutAsync(); AssertFailed(result, expectedCode, StatusCode.Unauthenticated); } + // CONTRACT.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the failures of §7.3 + // step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. + [Theory] + [MemberData(nameof(ClosureNames))] + public async Task TimeoutOfAClosedStreamFailsWithTheClosure(string closure) + { + (Scenario scenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Closures[closure]; + + ChannelHandshakeResult result = await HandshakeAsync(scenario.And(NoHello)); + + AssertFailed(result, expectedCode, expectedStatus); + } + + // CONTRACT.md §7.4: the closures keep their order while the stream awaits its Hello, + // so an identity revoked and then deleted during that wait gets identity_unknown. + [Theory] + [MemberData(nameof(ClosuresAndLaterTimeoutFailures))] + public async Task ClosureWinsOverLaterFailureWithoutHello(string early, string later) + { + (Scenario earlyScenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Closures[early]; + ChannelHandshakeResult earlyAlone = await HandshakeAsync(earlyScenario.And(NoHello)); + ChannelHandshakeResult laterAlone = await HandshakeAsync(Scenarios[later].And(NoHello)); + + ChannelHandshakeResult result = await HandshakeAsync(earlyScenario.And(Scenarios[later]).And(NoHello)); + + Assert.NotEqual(laterAlone.FailureDetail, earlyAlone.FailureDetail); + AssertFailed(result, expectedCode, expectedStatus); + Assert.Equal(earlyAlone.FailureDetail, result.FailureDetail); + } + // CONTRACT.md §7.3 step 4: the server waits 10 s for the Hello. [Fact] public void HelloTimeoutIs10Seconds() @@ -685,7 +787,9 @@ private static async Task HandshakeAsync( VectorChannel.RegisterDevice(authority.Registry, scenario.Status, scenario.IdentityRevoked); } - return await opening.VerifyHelloAsync(scenario.FirstMessage(opening)); + return scenario.FirstMessage is { } firstMessage + ? await opening.VerifyHelloAsync(firstMessage(opening)) + : await opening.HelloTimedOutAsync(); } // Opens a stream five seconds before the certificate of device-a expires, with a valid Hello signed for it, @@ -731,7 +835,8 @@ private sealed record Scenario // The time of the opening when null. public DateTimeOffset? HandshakeTime { get; init; } - public Func FirstMessage { get; init; } = VectorChannel.ValidHelloMessage; + // When null, no message arrives and the host ends the handshake for the timeout. + public Func? FirstMessage { get; init; } = VectorChannel.ValidHelloMessage; // This scenario with the changes of a later one to the registry and the clock, and with its first message. // The only clock readings combined are after notAfter and after the timeout, and the first is after both. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs index dba5f0289e..0a232b5029 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs @@ -41,8 +41,9 @@ internal AuthenticatedIdentity( /// /// /// It is or , - /// or for an agent channel stream whose certificate a confirm retired + /// or for an agent channel stream whose certificate was retired /// during the handshake. + /// CONTRACT.md §7.4 says how the host treats a stream of a retired certificate. /// public CertificateStatus CertificateStatus { get; } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs index 74c0523ae9..9c21e59f45 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs @@ -9,7 +9,7 @@ public static class ChannelHandshake /// How long the agent has to answer the challenge after the opening request is verified: 10 seconds. /// /// - /// When no message arrives in that time, the host calls + /// When no message arrives in that time, the host calls /// and closes the stream with its error. /// also fails a message that it reads later than that, /// so a late timer cannot let one through. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs index 358532e5e4..4bc1b63a97 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs @@ -47,11 +47,9 @@ private ChannelHandshakeResult( /// /// The stream stays bound to the certificate that opened it, /// so the host closes the stream no later than . - /// When a confirm retired that certificate during the handshake, + /// When that certificate was retired during the handshake, /// is , - /// and the host treats the stream as every stream of a retired certificate (CONTRACT.md §7.4): - /// it sends Reconnect with reason certificate_rotated, - /// and closes the stream with OK 60 s later. + /// and CONTRACT.md §7.4 says how the host treats a stream of a retired certificate. /// public AuthenticatedIdentity? Identity { get; } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs index 8c4a480bcf..2389a73ad5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + using Devolutions.Agent.Channel.V1; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; @@ -17,7 +19,7 @@ namespace Devolutions.AgentIdentity; /// and the stream is authenticated only once the agent signs that challenge. /// The identity stays hidden until then, so that nothing acts for it on the strength of the headers alone. /// The host passes the first agent message to , -/// or calls when none arrives within . +/// or calls when none arrives within . /// public sealed class ChannelOpening { @@ -38,7 +40,7 @@ public sealed class ChannelOpening private readonly TimeProvider _timeProvider; private readonly ICertificateRegistry _certificateRegistry; - // Set by the first call to VerifyHelloAsync or HelloTimedOut. + // Set by the first call to VerifyHelloAsync or HelloTimedOutAsync. private int _handshakeEnded; internal ChannelOpening( @@ -78,9 +80,9 @@ internal ChannelOpening( /// The notAfter of the certificate that signed the opening request. /// /// - /// Once this time has passed, the host closes a stream still waiting for its Hello with - /// , - /// the error that returns only strictly after this time. + /// Once this time has passed, the host closes a stream still waiting for its Hello with the error of + /// : , which that method + /// returns only strictly after this time, unless the identity is also revoked or deleted. /// It is a time, not an identity, which stays hidden until the agent signs the challenge. /// public DateTimeOffset CertificateNotAfter => Certificate.NotAfter; @@ -127,7 +129,8 @@ internal ChannelOpening( /// identity is revoked, and with after /// . /// A certificate retired since the opening still passes, - /// and then reports . + /// and then reports ; + /// CONTRACT.md §7.4 says how the host treats a stream of a retired certificate. /// More than after the opening request was verified, /// the handshake fails with , /// even when the host's own timer let the message through. @@ -137,7 +140,7 @@ internal ChannelOpening( /// Only then is the metadata checked, and metadata that CONTRACT.md §3 rejects fails the handshake with /// . /// The Welcome of a successful handshake carries the time of the clock reading. - /// The first call to this method or to ends the handshake, whatever its outcome, + /// The first call to this method or to ends the handshake, whatever its outcome, /// including an exception from the registry, which propagates. /// /// The first message that the agent sent on the stream. @@ -154,34 +157,12 @@ public async ValueTask VerifyHelloAsync( ArgumentNullException.ThrowIfNull(firstMessage); EndHandshake(); - DateTimeOffset now = _timeProvider.GetUtcNow(); - - // The host cannot tie the stream to its identity before the proof verifies, - // so only this lookup sees a revocation, a deletion or a rotation since the opening. - RegisteredCertificate? registered = await _certificateRegistry - .FindByThumbprintAsync(Certificate.Thumbprint, cancellationToken) - .ConfigureAwait(false); + (DateTimeOffset now, RegisteredCertificate? registered) = + await ReadClockAndRegistryAsync(cancellationToken).ConfigureAwait(false); - // CONTRACT.md §6 step 4, except that a certificate retired since the opening still passes: - // its stream then closes as every stream of a retired certificate does (§7.4), - // whereas identity_unknown would end the identity for an agent that did nothing wrong. - if (registered is null) + if (IsClosed(registered, now, out ChannelHandshakeResult? closure)) { - return ChannelHandshakeResult.Failure( - IdentityUnknown, - "The certificate that opened the stream is no longer registered, or its identity was deleted."); - } - - CheckIsTheOpeningCertificate(registered); - - if (registered.IdentityRevoked) - { - return ChannelHandshakeResult.Failure(IdentityRevoked, "The identity was revoked after the stream opened."); - } - - if (HasExpired(now)) - { - return ChannelHandshakeResult.Failure(CertificateExpired, ExpiredDetail); + return closure; } if (now - OpenedAt > ChannelHandshake.HelloTimeout) @@ -239,18 +220,33 @@ public async ValueTask VerifyHelloAsync( /// . /// /// - /// The handshake fails with , - /// or with when the clock reads a time after + /// A stream still awaiting its Hello closes for its identity and its certificate before it closes for the + /// timeout, so the method reads the clock once, then the certificate registry once, as + /// does. + /// In the order of CONTRACT.md §6 step 4, the handshake fails with + /// when the certificate that opened the stream is no longer + /// registered or its identity was deleted, with when the + /// identity is revoked, and with after /// . + /// Otherwise it fails with . /// The host also calls it when the agent ends the stream without sending a message. + /// The first call to this method or to ends the handshake, whatever its outcome, + /// including an exception from the registry, which propagates. /// - /// The handshake has already ended. - public ChannelHandshakeResult HelloTimedOut() + /// A token that cancels the registry lookup. + /// + /// The handshake has already ended, + /// or the registry returned another certificate, or the certificate for another identity. + /// + public async ValueTask HelloTimedOutAsync(CancellationToken cancellationToken = default) { EndHandshake(); - return HasExpired(_timeProvider.GetUtcNow()) - ? ChannelHandshakeResult.Failure(CertificateExpired, ExpiredDetail) + (DateTimeOffset now, RegisteredCertificate? registered) = + await ReadClockAndRegistryAsync(cancellationToken).ConfigureAwait(false); + + return IsClosed(registered, now, out ChannelHandshakeResult? closure) + ? closure : ChannelHandshakeResult.Failure(SignatureInvalid, "No Hello arrived before the timeout."); } @@ -272,6 +268,59 @@ private bool HasExpired(DateTimeOffset now) return now > Certificate.NotAfter; } + // Reads the clock, then the registry, once each. + // The clock comes first, as in CONTRACT.md §6, so that a slow registry cannot make a Hello late. + // The host cannot tie the stream to its identity before the proof verifies, + // so only this lookup sees a revocation, a deletion or a rotation since the opening. + private async ValueTask<(DateTimeOffset Now, RegisteredCertificate? Registered)> ReadClockAndRegistryAsync( + CancellationToken cancellationToken) + { + DateTimeOffset now = _timeProvider.GetUtcNow(); + RegisteredCertificate? registered = await _certificateRegistry + .FindByThumbprintAsync(Certificate.Thumbprint, cancellationToken) + .ConfigureAwait(false); + + return (now, registered); + } + + // CONTRACT.md §7.4: a deletion, a revocation and the certificate's notAfter close a stream even while it awaits + // its Hello, ahead of every failure of §7.3 step 4, in the order of §6 step 4. + // A certificate retired since the opening does not close the stream: + // §7.4 lets that stream complete the handshake, whereas identity_unknown would end the identity for an agent + // that did nothing wrong. + private bool IsClosed( + [NotNullWhen(false)] RegisteredCertificate? registered, + DateTimeOffset now, + [NotNullWhen(true)] out ChannelHandshakeResult? closure) + { + if (registered is null) + { + closure = ChannelHandshakeResult.Failure( + IdentityUnknown, + "The certificate that opened the stream is no longer registered, or its identity was deleted."); + return true; + } + + CheckIsTheOpeningCertificate(registered); + + if (registered.IdentityRevoked) + { + closure = ChannelHandshakeResult.Failure( + IdentityRevoked, + "The identity was revoked after the stream opened."); + return true; + } + + if (HasExpired(now)) + { + closure = ChannelHandshakeResult.Failure(CertificateExpired, ExpiredDetail); + return true; + } + + closure = null; + return false; + } + // The registry answers a thumbprint with the certificate that the authority issued under it, for its one identity, // so another answer is a host bug, which fails as a server error rather than as a verdict on the agent. private void CheckIsTheOpeningCertificate(RegisteredCertificate registered) From 0627e5242fd69f0524ced2948114debc1a7e9c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 22:30:13 +0900 Subject: [PATCH 44/93] docs(dotnet-agent-identity): document the async Hello timeout The Hello timeout is ChannelOpening.HelloTimedOutAsync, which re-reads the certificate registry like VerifyHelloAsync, so a deletion, a revocation or the certificate's expiry closes a stream still awaiting its Hello with that error rather than as a timeout. The channel steps call HelloTimedOutAsync, and the README says that both handshake methods read the registry again before any handshake failure. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index b8c2ebb94c..ae3c4feb25 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -101,14 +101,15 @@ Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given 1. Pass the request method and the values of the `signature-input` and `signature` request headers, in received order, to `RequestAuthenticator.AuthenticateChannelOpeningAsync`. 2. Send the result's `Opening.Challenge` as the first message on the stream. 3. Pass the first agent message to `Opening.VerifyHelloAsync`. - Call `Opening.HelloTimedOut` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. + Call `Opening.HelloTimedOutAsync` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. 4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`. 5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.md [§7.3] steps 5 and 7). 6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.md [§7.4]). 7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.md [§7.4]). -A channel opening has no body: `VerifyHelloAsync` reads the registry again and checks the certificate's expiry and the deadline before the message and its proof, and the metadata of the `Hello` last, in the order of CONTRACT.md [§7.4] and [§7.3] step 4. +`VerifyHelloAsync` and `HelloTimedOutAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.md [§7.4]). +A channel opening has no body: `VerifyHelloAsync` then checks the deadline, the message and its proof, and the metadata of the `Hello` last (CONTRACT.md [§7.3] step 4). When an identity's config changes, create its `IdentityConfig` again with a higher `Revision`, and send `ChannelMessages.ConfigUpdate` on each authenticated stream of the identity. `ChannelMessages.RenewRequested` takes `RenewalRequestReason.Rotation` for the notifications of a root rotation (CONTRACT.md [§8.3]), and `RenewalRequestReason.Admin` for an administrator's request. From 503d4a0d53842172292ec2715360bc9d08af0c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 22:58:27 +0900 Subject: [PATCH 45/93] refactor(dotnet-agent-identity): group the sources by contract area A project root that holds every source file buries its .csproj among dozens of files and scatters each contract area through an alphabetical list. Each project root now shows its .csproj beside one folder per contract area: Errors, Protocol, Enrollment, Bodies, Certificates, Authentication and Channel, plus Support for the shared test helpers. The files move unchanged and keep their namespace, so the public API stays in the single Devolutions.AgentIdentity namespace and a host still needs one using directive. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../{ => Authentication}/AuthenticationTypeTests.cs | 0 .../{ => Authentication}/RegisteredCertificateTests.cs | 0 .../{ => Authentication}/RequestAuthenticatorProfileTests.cs | 0 .../{ => Authentication}/RequestAuthenticatorTests.cs | 0 .../{ => Authentication}/RequestAuthenticatorVectorTests.cs | 0 .../{ => Bodies}/AgentMetadataTests.cs | 0 .../{ => Bodies}/IdentityConfigTests.cs | 0 .../{ => Bodies}/RequestBodyTests.cs | 0 .../{ => Bodies}/ResponseBodiesTests.cs | 0 .../{ => Certificates}/CertificateSigningRequestTests.cs | 0 .../{ => Certificates}/IdentityCertificateIssuerTests.cs | 0 .../{ => Certificates}/RenewalRulesTests.cs | 0 .../{ => Channel}/ChannelHandshakeTests.cs | 0 .../{ => Channel}/ChannelHandshakeVectorTests.cs | 0 .../{ => Channel}/ChannelMessagesTests.cs | 0 .../{ => Channel}/ChannelOpeningTests.cs | 0 .../{ => Enrollment}/EnrollmentAuthorizationTests.cs | 0 .../{ => Enrollment}/EnrollmentRulesTests.cs | 0 .../{ => Enrollment}/EnrollmentTokenAssert.cs | 0 .../{ => Enrollment}/EnrollmentTokenTests.cs | 0 .../{ => Enrollment}/FriendlyNameFormatTests.cs | 0 .../{ => Errors}/AgentIdentityErrorCodeTests.cs | 0 .../{ => Errors}/AgentIdentityErrorTests.cs | 0 .../{ => Protocol}/AgentIdentityProtocolTests.cs | 0 .../{ => Protocol}/StrictBase64Tests.cs | 0 .../{ => Support}/TestAuthority.cs | 0 .../{ => Support}/TestChannelProof.cs | 0 .../Devolutions.AgentIdentity.Tests/{ => Support}/TestCsr.cs | 0 .../Devolutions.AgentIdentity.Tests/{ => Support}/TestDoubles.cs | 0 .../Devolutions.AgentIdentity.Tests/{ => Support}/TestIdentity.cs | 0 .../Devolutions.AgentIdentity.Tests/{ => Support}/TestJson.cs | 0 .../Devolutions.AgentIdentity.Tests/{ => Support}/TestRequest.cs | 0 .../Devolutions.AgentIdentity.Tests/{ => Support}/TestVectors.cs | 0 .../{ => Support}/VectorAuthority.cs | 0 .../{ => Support}/VectorChannel.cs | 0 .../Devolutions.AgentIdentity.Tests/{ => Support}/VectorData.cs | 0 .../{ => Authentication}/AgentOperation.cs | 0 .../{ => Authentication}/AuthenticatedIdentity.cs | 0 .../{ => Authentication}/AuthenticationResult.cs | 0 .../{ => Authentication}/EcdsaP256Signature.cs | 0 .../Devolutions.AgentIdentity/{ => Authentication}/FieldLines.cs | 0 .../{ => Authentication}/ICertificateRegistry.cs | 0 .../Devolutions.AgentIdentity/{ => Authentication}/INonceStore.cs | 0 .../{ => Authentication}/RegisteredCertificate.cs | 0 .../{ => Authentication}/RequestAuthenticator.cs | 0 .../{ => Authentication}/SignatureFields.cs | 0 .../{ => Authentication}/SignedHttpRequest.cs | 0 .../{ => Authentication}/SignedOperation.cs | 0 .../{ => Authentication}/StructuredField.cs | 0 .../{ => Authentication}/VerifiedCertificate.cs | 0 .../Devolutions.AgentIdentity/{ => Bodies}/AgentMetadata.cs | 0 .../Devolutions.AgentIdentity/{ => Bodies}/CheckInRequestBody.cs | 0 .../Devolutions.AgentIdentity/{ => Bodies}/EnrollRequestBody.cs | 0 .../Devolutions.AgentIdentity/{ => Bodies}/IdentityConfig.cs | 0 .../{ => Bodies}/MetadataValidationResult.cs | 0 .../Devolutions.AgentIdentity/{ => Bodies}/RenewRequestBody.cs | 0 .../{ => Bodies}/RequestBodyParseResult.cs | 0 .../Devolutions.AgentIdentity/{ => Bodies}/RequestBodyReader.cs | 0 .../Devolutions.AgentIdentity/{ => Bodies}/ResponseBodies.cs | 0 .../{ => Certificates}/CertificateSigningRequest.cs | 0 .../{ => Certificates}/CertificateStatus.cs | 0 .../{ => Certificates}/CsrValidationResult.cs | 0 .../Devolutions.AgentIdentity/{ => Certificates}/IRootSigner.cs | 0 .../{ => Certificates}/IdentityCertificateIssuer.cs | 0 .../{ => Certificates}/RenewalDecision.cs | 0 .../{ => Certificates}/RenewalOutcome.cs | 0 .../Devolutions.AgentIdentity/{ => Certificates}/RenewalRules.cs | 0 .../Devolutions.AgentIdentity/{ => Channel}/ChannelHandshake.cs | 0 .../{ => Channel}/ChannelHandshakeResult.cs | 0 .../Devolutions.AgentIdentity/{ => Channel}/ChannelMessages.cs | 0 .../Devolutions.AgentIdentity/{ => Channel}/ChannelOpening.cs | 0 .../{ => Channel}/ChannelOpeningResult.cs | 0 .../Devolutions.AgentIdentity/{ => Channel}/ChannelProof.cs | 0 .../Devolutions.AgentIdentity/{ => Channel}/ReconnectReason.cs | 0 .../{ => Channel}/RenewalRequestReason.cs | 0 .../{ => Enrollment}/EnrollmentAuthorization.cs | 0 .../{ => Enrollment}/EnrollmentAuthorizationResult.cs | 0 .../{ => Enrollment}/EnrollmentDecision.cs | 0 .../{ => Enrollment}/EnrollmentOutcome.cs | 0 .../Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentRules.cs | 0 .../Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentToken.cs | 0 .../{ => Enrollment}/EnrollmentTokenState.cs | 0 .../{ => Enrollment}/FriendlyNameFormat.cs | 0 .../Devolutions.AgentIdentity/{ => Enrollment}/IssuedKeyMatch.cs | 0 .../Devolutions.AgentIdentity/{ => Errors}/AgentIdentityError.cs | 0 .../{ => Errors}/AgentIdentityErrorCode.cs | 0 .../{ => Errors}/AgentIdentityErrorCodeExtensions.cs | 0 .../{ => Protocol}/AgentIdentityProtocol.cs | 0 .../Devolutions.AgentIdentity/{ => Protocol}/BaseUrl.cs | 0 .../Devolutions.AgentIdentity/{ => Protocol}/StrictBase64.cs | 0 90 files changed, 0 insertions(+), 0 deletions(-) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Authentication}/AuthenticationTypeTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Authentication}/RegisteredCertificateTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Authentication}/RequestAuthenticatorProfileTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Authentication}/RequestAuthenticatorTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Authentication}/RequestAuthenticatorVectorTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Bodies}/AgentMetadataTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Bodies}/IdentityConfigTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Bodies}/RequestBodyTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Bodies}/ResponseBodiesTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Certificates}/CertificateSigningRequestTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Certificates}/IdentityCertificateIssuerTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Certificates}/RenewalRulesTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Channel}/ChannelHandshakeTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Channel}/ChannelHandshakeVectorTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Channel}/ChannelMessagesTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Channel}/ChannelOpeningTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Enrollment}/EnrollmentAuthorizationTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Enrollment}/EnrollmentRulesTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Enrollment}/EnrollmentTokenAssert.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Enrollment}/EnrollmentTokenTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Enrollment}/FriendlyNameFormatTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Errors}/AgentIdentityErrorCodeTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Errors}/AgentIdentityErrorTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Protocol}/AgentIdentityProtocolTests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Protocol}/StrictBase64Tests.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestAuthority.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestChannelProof.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestCsr.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestDoubles.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestIdentity.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestJson.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestRequest.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/TestVectors.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/VectorAuthority.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/VectorChannel.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/{ => Support}/VectorData.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/AgentOperation.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/AuthenticatedIdentity.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/AuthenticationResult.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/EcdsaP256Signature.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/FieldLines.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/ICertificateRegistry.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/INonceStore.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/RegisteredCertificate.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/RequestAuthenticator.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/SignatureFields.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/SignedHttpRequest.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/SignedOperation.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/StructuredField.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Authentication}/VerifiedCertificate.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/AgentMetadata.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/CheckInRequestBody.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/EnrollRequestBody.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/IdentityConfig.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/MetadataValidationResult.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/RenewRequestBody.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/RequestBodyParseResult.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/RequestBodyReader.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Bodies}/ResponseBodies.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/CertificateSigningRequest.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/CertificateStatus.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/CsrValidationResult.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/IRootSigner.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/IdentityCertificateIssuer.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/RenewalDecision.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/RenewalOutcome.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Certificates}/RenewalRules.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/ChannelHandshake.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/ChannelHandshakeResult.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/ChannelMessages.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/ChannelOpening.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/ChannelOpeningResult.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/ChannelProof.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/ReconnectReason.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Channel}/RenewalRequestReason.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentAuthorization.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentAuthorizationResult.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentDecision.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentOutcome.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentRules.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentToken.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/EnrollmentTokenState.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/FriendlyNameFormat.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Enrollment}/IssuedKeyMatch.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Errors}/AgentIdentityError.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Errors}/AgentIdentityErrorCode.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Errors}/AgentIdentityErrorCodeExtensions.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Protocol}/AgentIdentityProtocol.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Protocol}/BaseUrl.cs (100%) rename dotnet/AgentIdentity/Devolutions.AgentIdentity/{ => Protocol}/StrictBase64.cs (100%) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AuthenticationTypeTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RegisteredCertificateTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorProfileTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorProfileTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestAuthenticatorVectorTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentMetadataTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityConfigTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RequestBodyTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResponseBodiesTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/CertificateSigningRequestTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/IdentityCertificateIssuerTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/RenewalRulesTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelHandshakeVectorTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelMessagesTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ChannelOpeningTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentAuthorizationTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentRulesTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenAssert.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenAssert.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenAssert.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenAssert.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/EnrollmentTokenTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/FriendlyNameFormatTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorCodeTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityErrorTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/AgentIdentityProtocolTests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/StrictBase64Tests.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestAuthority.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestChannelProof.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestCsr.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestCsr.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestCsr.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestDoubles.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestDoubles.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestDoubles.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestIdentity.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestJson.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestJson.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestJson.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestJson.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestRequest.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/TestVectors.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorAuthority.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorChannel.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorData.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/VectorData.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentOperation.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AgentOperation.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentOperation.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AgentOperation.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticatedIdentity.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AuthenticationResult.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EcdsaP256Signature.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/EcdsaP256Signature.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EcdsaP256Signature.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/EcdsaP256Signature.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FieldLines.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/FieldLines.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/FieldLines.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/FieldLines.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ICertificateRegistry.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/ICertificateRegistry.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ICertificateRegistry.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/ICertificateRegistry.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/INonceStore.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/INonceStore.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/INonceStore.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/INonceStore.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RegisteredCertificate.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RegisteredCertificate.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RegisteredCertificate.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RegisteredCertificate.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestAuthenticator.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignatureFields.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/SignatureFields.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedHttpRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedHttpRequest.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedHttpRequest.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedHttpRequest.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedOperation.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/SignedOperation.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/StructuredField.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/StructuredField.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/StructuredField.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/StructuredField.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/VerifiedCertificate.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/VerifiedCertificate.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/VerifiedCertificate.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/VerifiedCertificate.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentMetadata.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentMetadata.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CheckInRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/CheckInRequestBody.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/CheckInRequestBody.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/CheckInRequestBody.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/EnrollRequestBody.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollRequestBody.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/EnrollRequestBody.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityConfig.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/MetadataValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/MetadataValidationResult.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RenewRequestBody.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewRequestBody.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RenewRequestBody.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyParseResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyParseResult.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyReader.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RequestBodyReader.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ResponseBodies.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateSigningRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateSigningRequest.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateStatus.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/CertificateStatus.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateStatus.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/CsrValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/CsrValidationResult.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IRootSigner.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/IRootSigner.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/IdentityCertificateIssuer.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalDecision.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalDecision.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalDecision.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalOutcome.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalOutcome.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalOutcome.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalOutcome.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalRules.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRules.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalRules.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshake.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshake.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshake.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelHandshakeResult.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelMessages.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelMessages.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelMessages.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelMessages.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpening.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpeningResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelOpeningResult.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ChannelProof.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/ReconnectReason.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ReconnectReason.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/ReconnectReason.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ReconnectReason.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRequestReason.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/RenewalRequestReason.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/RenewalRequestReason.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/RenewalRequestReason.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorization.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorizationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentAuthorizationResult.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentDecision.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentOutcome.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentOutcome.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentRules.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentToken.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentTokenState.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/EnrollmentTokenState.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/FriendlyNameFormat.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/IssuedKeyMatch.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityError.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCode.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCode.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCodeExtensions.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityErrorCodeExtensions.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityProtocol.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/AgentIdentityProtocol.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/AgentIdentityProtocol.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/AgentIdentityProtocol.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/BaseUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/BaseUrl.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/StrictBase64.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/StrictBase64.cs similarity index 100% rename from dotnet/AgentIdentity/Devolutions.AgentIdentity/StrictBase64.cs rename to dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/StrictBase64.cs From 942049cf1cd828feea7856943c7af08c9deae5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 06:30:33 +0900 Subject: [PATCH 46/93] test(dotnet-agent-identity): consolidate tests that check one rule Tests that checked one rule with different inputs become one theory, with a short string key per row, so a failure names its case and no two rows share a display name. Every input and expected value is kept: the merges change how the cases are grouped, never what they check, and no caught fault is lost. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RequestAuthenticatorTests.cs | 190 ++++--- .../Bodies/AgentMetadataTests.cs | 14 +- .../Bodies/IdentityConfigTests.cs | 59 +-- .../Bodies/RequestBodyTests.cs | 51 +- .../Bodies/ResponseBodiesTests.cs | 23 +- .../CertificateSigningRequestTests.cs | 466 +++++++++--------- .../IdentityCertificateIssuerTests.cs | 50 +- .../Certificates/RenewalRulesTests.cs | 23 - .../Channel/ChannelHandshakeTests.cs | 381 +++++++------- .../Channel/ChannelMessagesTests.cs | 30 +- .../EnrollmentAuthorizationTests.cs | 233 ++++----- .../Enrollment/EnrollmentRulesTests.cs | 211 +++----- .../Enrollment/EnrollmentTokenTests.cs | 72 +-- .../Enrollment/FriendlyNameFormatTests.cs | 98 ++-- .../Protocol/AgentIdentityProtocolTests.cs | 38 +- .../Protocol/StrictBase64Tests.cs | 269 +++++----- 16 files changed, 983 insertions(+), 1225 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs index 9442e21efd..5e932f7c6a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs @@ -29,10 +29,57 @@ public class RequestAuthenticatorTests }, }; - public static TheoryData Operations => new(Enum.GetValues()); + // Each request fails the two checks its key names. + // For a wrong key, device-b signs for the keyid of device-a; + // for a wrong digest, the body changes after signing, so only its digest fails. + private static readonly Dictionary> TwoFailures = + new() + { + ["revoked identity, wrong key"] = () => + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, identityRevoked: true); + return (authority, authority.Valid(AgentOperation.Renew) with { Key = DeviceB }); + }, + ["retired certificate, wrong key"] = () => + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, CertificateStatus.Retired); + return (authority, authority.Valid(AgentOperation.Renew) with { Key = DeviceB }); + }, + ["expired certificate, wrong key"] = () => + { + TestAuthority authority = new(DeviceACertificate.NotAfter.AddSeconds(1)); + return (authority, authority.Valid(AgentOperation.Confirm) with { Key = DeviceB }); + }, + ["beyond renewal grace, wrong digest"] = () => + { + TestAuthority authority = new(DeviceACertificate.NotAfter + DeviceALifetime); + return (authority, authority.Valid(AgentOperation.Renew) with { Body = [0x7B, 0x7D] }); + }, + ["retired certificate, revoked identity"] = () => + { + TestAuthority authority = new(Now); + authority.Registry.Register(DeviceA, CertificateStatus.Retired, identityRevoked: true); + return (authority, authority.Valid(AgentOperation.Renew)); + }, + ["expired certificate, revoked identity"] = () => + { + TestAuthority authority = new(DeviceACertificate.NotAfter.AddSeconds(1)); + authority.Registry.Register(DeviceA, identityRevoked: true); + return (authority, authority.Valid(AgentOperation.Confirm)); + }, + }; + + // The renewal grace of device-a's certificate ends at notAfter + (notAfter − notBefore). + private static readonly Dictionary> RenewalGraceInstants = new() + { + ["one tick after notAfter"] = () => DeviceACertificate.NotAfter.AddTicks(1), + ["one tick before grace ends"] = () => (DeviceACertificate.NotAfter + DeviceALifetime).AddTicks(-1), + ["when grace ends"] = () => DeviceACertificate.NotAfter + DeviceALifetime, + }; - public static TheoryData OperationsWithoutRenewalGrace => - new(AgentOperation.Confirm, AgentOperation.CheckIn, AgentOperation.Connect); + public static TheoryData Operations => new(Enum.GetValues()); public static TheoryData OperationsWithAcceptedStatuses { @@ -50,9 +97,6 @@ public static TheoryData OperationsWithAccept } } - public static TheoryData AcceptedStatuses => - new(CertificateStatus.Pending, CertificateStatus.Current); - public static TheoryData OversizedRequestNames => new(OversizedRequests.Keys); private static KeyVector DeviceA => TestVectors.Instance.Key("device-a"); @@ -99,68 +143,29 @@ public async Task BodyOfExactlyTheLimitIsAccepted() } // CONTRACT.md §6: the earliest failing step decides, - // so the identity's state is reported even when the signature would not verify. - // In these tests, device-b signs for the keyid of device-a. - [Fact] - public async Task RevokedIdentityWinsOverABadSignature() - { - TestAuthority authority = new(Now); - authority.Registry.Register(DeviceA, identityRevoked: true); - - string outcome = await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew) with { Key = DeviceB }); - - Assert.Equal("identity_revoked", outcome); - } - - [Fact] - public async Task RetiredCertificateWinsOverABadSignature() - { - TestAuthority authority = new(Now); - authority.Registry.Register(DeviceA, CertificateStatus.Retired); - - string outcome = await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew) with { Key = DeviceB }); - - Assert.Equal("identity_unknown", outcome); - } - - [Fact] - public async Task ExpiredCertificateWinsOverABadSignature() - { - TestAuthority authority = new(DeviceACertificate.NotAfter.AddSeconds(1)); - - string outcome = await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm) with { Key = DeviceB }); - - Assert.Equal("certificate_expired", outcome); - } - - // The body changes after signing, so only its digest fails. - [Fact] - public async Task CertificateBeyondRenewalGraceWinsOverABodyThatDoesNotMatchItsDigest() + // so the identity's state is reported even when the signature or the digest would not verify. + [Theory] + [InlineData("revoked identity, wrong key", "identity_revoked")] + [InlineData("retired certificate, wrong key", "identity_unknown")] + [InlineData("expired certificate, wrong key", "certificate_expired")] + [InlineData("beyond renewal grace, wrong digest", "certificate_expired")] + public async Task StepFourFailureWinsOverAStepFiveFailure(string request, string expected) { - TestAuthority authority = new(DeviceACertificate.NotAfter + DeviceALifetime); - TestRequest request = authority.Valid(AgentOperation.Renew) with { Body = [0x7B, 0x7D] }; + (TestAuthority authority, TestRequest failing) = TwoFailures[request](); - Assert.Equal("certificate_expired", await authority.OutcomeAsync(request)); + Assert.Equal(expected, await authority.OutcomeAsync(failing)); } // CONTRACT.md §6 step 4 resolves the certificate before it reads the identity's revocation, // and reads revocation before validity, so a revoked identity always draws the terminal identity_revoked. - [Fact] - public async Task RetiredCertificateWinsOverRevocation() - { - TestAuthority authority = new(Now); - authority.Registry.Register(DeviceA, CertificateStatus.Retired, identityRevoked: true); - - Assert.Equal("identity_unknown", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); - } - - [Fact] - public async Task RevocationWinsOverExpiry() + [Theory] + [InlineData("retired certificate, revoked identity", "identity_unknown")] + [InlineData("expired certificate, revoked identity", "identity_revoked")] + public async Task StepFourReturnsItsEarliestFailingCheck(string request, string expected) { - TestAuthority authority = new(DeviceACertificate.NotAfter.AddSeconds(1)); - authority.Registry.Register(DeviceA, identityRevoked: true); + (TestAuthority authority, TestRequest failing) = TwoFailures[request](); - Assert.Equal("identity_revoked", await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm))); + Assert.Equal(expected, await authority.OutcomeAsync(failing)); } // CONTRACT.md §6 table: every operation accepts a current or pending certificate. @@ -185,23 +190,24 @@ public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) } // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + // A clock truncated to the second would still accept the certificate one tick after its notAfter. + // Renew has no row one tick after, because its grace still accepts the certificate then. [Theory] - [MemberData(nameof(Operations))] - public async Task CertificateIsAcceptedAtItsNotAfter(AgentOperation operation) - { - TestAuthority authority = new(DeviceACertificate.NotAfter); - - Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); - } - - // A clock truncated to the second would still accept the certificate here. - [Theory] - [MemberData(nameof(OperationsWithoutRenewalGrace))] - public async Task CertificateIsExpiredOneTickAfterItsNotAfter(AgentOperation operation) + [InlineData(AgentOperation.Renew, 0, "ok")] + [InlineData(AgentOperation.Confirm, 0, "ok")] + [InlineData(AgentOperation.CheckIn, 0, "ok")] + [InlineData(AgentOperation.Connect, 0, "ok")] + [InlineData(AgentOperation.Confirm, 1, "certificate_expired")] + [InlineData(AgentOperation.CheckIn, 1, "certificate_expired")] + [InlineData(AgentOperation.Connect, 1, "certificate_expired")] + public async Task CertificateExpiresAfterTheExactInstantOfItsNotAfter( + AgentOperation operation, + int ticksAfterNotAfter, + string expected) { - TestAuthority authority = new(DeviceACertificate.NotAfter.AddTicks(1)); + TestAuthority authority = new(DeviceACertificate.NotAfter.AddTicks(ticksAfterNotAfter)); - Assert.Equal("certificate_expired", await authority.OutcomeAsync(authority.Valid(operation))); + Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(operation))); } // CONTRACT.md §6: notBefore is not checked, @@ -217,33 +223,21 @@ public async Task CertificateIsAcceptedBeforeItsNotBefore(AgentOperation operati // CONTRACT.md §6 table: renew accepts a certificate while now < notAfter + (notAfter − notBefore). [Theory] - [MemberData(nameof(AcceptedStatuses))] - public async Task RenewAcceptsACertificateOneTickAfterItsNotAfter(CertificateStatus status) - { - TestAuthority authority = new(DeviceACertificate.NotAfter.AddTicks(1)); - authority.Registry.Register(DeviceA, status); - - Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); - } - - [Theory] - [MemberData(nameof(AcceptedStatuses))] - public async Task RenewAcceptsACertificateOneTickBeforeItsGraceEnds(CertificateStatus status) - { - TestAuthority authority = new((DeviceACertificate.NotAfter + DeviceALifetime).AddTicks(-1)); - authority.Registry.Register(DeviceA, status); - - Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); - } - - [Theory] - [MemberData(nameof(AcceptedStatuses))] - public async Task RenewRejectsACertificateWhenItsGraceEnds(CertificateStatus status) + [InlineData(CertificateStatus.Pending, "one tick after notAfter", "ok")] + [InlineData(CertificateStatus.Current, "one tick after notAfter", "ok")] + [InlineData(CertificateStatus.Pending, "one tick before grace ends", "ok")] + [InlineData(CertificateStatus.Current, "one tick before grace ends", "ok")] + [InlineData(CertificateStatus.Pending, "when grace ends", "certificate_expired")] + [InlineData(CertificateStatus.Current, "when grace ends", "certificate_expired")] + public async Task RenewGraceIsTheCertificateLifetimeAfterItsNotAfter( + CertificateStatus status, + string instant, + string expected) { - TestAuthority authority = new(DeviceACertificate.NotAfter + DeviceALifetime); + TestAuthority authority = new(RenewalGraceInstants[instant]()); authority.Registry.Register(DeviceA, status); - Assert.Equal("certificate_expired", await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); + Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); } // A second reading of the clock between the window and the certificate check diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs index e0180586f2..7b283b3442 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs @@ -181,16 +181,12 @@ public void FailureDetailDoesNotQuoteAnInvalidKey() } // A null key or value can only come from the host, since neither JSON nor protobuf metadata can hold one. - [Fact] - public void ThrowsForNullKey() - { - Assert.Throws(() => AgentMetadata.Validate([new(null!, "value")])); - } - - [Fact] - public void ThrowsForNullValue() + [Theory] + [InlineData(null, "value")] + [InlineData("hostname", null)] + public void ThrowsForNullKeyOrValue(string? key, string? value) { - Assert.Throws(() => AgentMetadata.Validate([new("hostname", null!)])); + Assert.Throws(() => AgentMetadata.Validate([new(key!, value!)])); } private static AgentMetadata AssertValid(MetadataValidationResult result) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index 3f1495dbe4..36db61ae39 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -149,21 +149,6 @@ public void CreateRejectsAProductFieldNamedAfterAContractMember(string name) AssertRejectsProductFields([new(name, TestJson.Parse("2"))]); } - // RFC 8259 §4: with a repeated name, receivers disagree on the value, as CONTRACT.md §1 notes of request bodies. - [Fact] - public void CreateRejectsARepeatedProductFieldName() - { - AssertRejectsProductFields( - [new("psu_channel_url", TestJson.Parse("\"a\"")), new("psu_channel_url", TestJson.Parse("\"b\""))]); - } - - // CONTRACT.md §1: an absent field is omitted, never written as null. - [Fact] - public void CreateRejectsANullProductFieldValue() - { - AssertRejectsProductFields([new("psu_channel_url", TestJson.Parse("null"))]); - } - // The default JsonElement holds no JSON value at all. [Fact] public void CreateRejectsAProductFieldWithoutAValue() @@ -187,41 +172,49 @@ public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value) AssertRejectsProductFields([new("label", TestJson.Parse(value))]); } - // CONTRACT.md §1: JSON keys are snake_case, so the members inside a product field's value are too. + // CONTRACT.md §1: JSON keys are snake_case, so the members inside a product field's value are too, + // whether an object or an array holds them. [Theory] [InlineData("""{"maxSessions": 4}""")] [InlineData("""{"limits": {"max_sessions": 4, "Max_paths": 2}}""")] - public void CreateRejectsAMemberNameThatIsNotSnakeCaseInANestedObject(string value) - { - AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); - } - - [Theory] [InlineData("""[{"Path": "a"}]""")] [InlineData("""{"paths": [["a"], [{"pathName": "b"}]]}""")] - public void CreateRejectsAMemberNameThatIsNotSnakeCaseInAnObjectInAnArray(string value) + public void CreateRejectsAMemberNameThatIsNotSnakeCaseInAValue(string value) { AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); } // RFC 8259 §4: with a repeated name, receivers disagree on the value, as CONTRACT.md §1 notes of request bodies. - // A name and its escaped spelling are the same name. + // A product field is a member of the config, and a name and its escaped spelling are the same name. + private static readonly Dictionary[]> RepeatedMemberNames = new() + { + ["product field"] = + [new("psu_channel_url", TestJson.Parse("\"a\"")), new("psu_channel_url", TestJson.Parse("\"b\""))], + ["member of a value"] = + [new("policy_limits", TestJson.Parse("""{"max_sessions": 4, "max_sessions": 5}"""))], + ["member of an object in an array"] = + [new("policy_limits", TestJson.Parse("""{"limits": [{"max_sessions": 4, "max_sessions": 5}]}"""))], + ["member of a value with an escaped name"] = + [new("policy_limits", TestJson.Parse("""{"max_sessions": 4, "max\u005Fsessions": 5}"""))], + }; + + public static TheoryData RepeatedMemberNameForms => new(RepeatedMemberNames.Keys); + [Theory] - [InlineData("""{"max_sessions": 4, "max_sessions": 5}""")] - [InlineData("""{"limits": [{"max_sessions": 4, "max_sessions": 5}]}""")] - [InlineData("""{"max_sessions": 4, "max\u005Fsessions": 5}""")] - public void CreateRejectsAMemberNameRepeatedInOneObjectOfAValue(string value) + [MemberData(nameof(RepeatedMemberNameForms))] + public void CreateRejectsARepeatedMemberName(string form) { - AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); + AssertRejectsProductFields(RepeatedMemberNames[form]); } // CONTRACT.md §1: an absent field is omitted, never written as null, at any depth. [Theory] - [InlineData("""{"proxy": null}""")] - [InlineData("""[{"proxy": null}]""")] - public void CreateRejectsANullMemberInAValue(string value) + [InlineData("psu_channel_url", "null")] + [InlineData("policy_limits", """{"proxy": null}""")] + [InlineData("policy_limits", """[{"proxy": null}]""")] + public void CreateRejectsANullFieldOrMember(string name, string value) { - AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); + AssertRejectsProductFields([new(name, TestJson.Parse(value))]); } // A name repeats only within one object, and an array element is no field, so it may be null. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index 37788963a5..0b4b457118 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -68,16 +68,12 @@ public void AcceptsEmptyMetadata() } // CONTRACT.md §3: metadata is required, and §1 reads a null member as an absent one. - [Fact] - public void RejectsMissingMetadata() - { - AssertInvalid(ParseText(Body(null, ""))); - } - - [Fact] - public void RejectsNullMetadata() + [Theory] + [InlineData(null)] + [InlineData("null")] + public void RejectsMissingMetadata(string? metadata) { - AssertInvalid(ParseText(Body("null", ""))); + AssertInvalid(ParseText(Body(metadata, ""))); } // CONTRACT.md §3: metadata is a JSON object of string to string. @@ -122,23 +118,18 @@ public void RejectsMetadataBeyondTheLimits(string form, string metadata) AssertInvalid(ParseText(Body(metadata, "")), form); } - // CONTRACT.md §1: a body that repeats a member name at any depth is invalid, + // CONTRACT.md §1: a body that repeats a member name at any depth, such as one metadata key twice, is invalid, // whether the names are written alike or only unescape alike. [Theory] - [InlineData("metadata", """ "metadata": {}""")] - [InlineData("metadata with an escaped letter", """ "m\u0065tadata": {}""")] - [InlineData("unknown member", """ "extra": 1, "extra": 2""")] - [InlineData("member of an unknown member", """ "extra": {"a": 1, "a": 2}""")] - [InlineData("member of an object in nested arrays", """ "extra": [[{"x": {"a": null, "a": null}}]]""")] - public void RejectsRepeatedMemberName(string form, string extraMembers) + [InlineData("metadata", ValidMetadata, """ "metadata": {}""")] + [InlineData("metadata with an escaped letter", ValidMetadata, """ "m\u0065tadata": {}""")] + [InlineData("metadata key", """{"hostname": "a", "hostname": "a"}""", "")] + [InlineData("unknown member", ValidMetadata, """ "extra": 1, "extra": 2""")] + [InlineData("member of an unknown member", ValidMetadata, """ "extra": {"a": 1, "a": 2}""")] + [InlineData("member of an object in nested arrays", ValidMetadata, """ "extra": [[{"x": {"a": null, "a": null}}]]""")] + public void RejectsRepeatedMemberName(string form, string metadata, string extraMembers) { - AssertInvalid(ParseText(Body(ValidMetadata, extraMembers)), form); - } - - [Fact] - public void RejectsRepeatedMetadataKey() - { - AssertInvalid(ParseText(Body("""{"hostname": "a", "hostname": "a"}""", ""))); + AssertInvalid(ParseText(Body(metadata, extraMembers)), form); } // RFC 8259 §2: a JSON text is one value; RFC 8259 §8.1 lets a parser reject a byte order mark. @@ -300,16 +291,12 @@ public void ReturnsTheValidatedCsr() } // CONTRACT.md §5.2 and §5.3: csr is required, and §1 reads a null member as an absent one. - [Fact] - public void RejectsMissingCsr() - { - AssertInvalid(ParseText(BodyWithCsr(null, ValidMetadata))); - } - - [Fact] - public void RejectsNullCsr() + [Theory] + [InlineData(null)] + [InlineData("null")] + public void RejectsMissingCsr(string? csr) { - AssertInvalid(ParseText(BodyWithCsr("null", ValidMetadata))); + AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata))); } // CONTRACT.md §1: a CSR is DER encoded base64 in a JSON string. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs index 5708395861..444f1adc97 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs @@ -13,6 +13,7 @@ public class ResponseBodiesTests // The identity is device-a of the vector file, whose certificate the vector root issued. private const string AuthorityIdText = "7c9e6679-7425-40de-944b-e07fc1f90ae7"; private const string IdentityIdText = "0b3c6a52-7a38-4f55-9a1c-2f1a8d3c9e01"; + private const string NilIdText = "00000000-0000-0000-0000-000000000000"; private static readonly DateTimeOffset Now = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); @@ -138,23 +139,17 @@ public void EnrollHasExactlyTheContractMembers() Assert.Equal(JsonValueKind.Object, members["config"].ValueKind); } - // RFC 9562 §5.9: the nil UUID stands for the absence of a UUID, and CONTRACT.md §5.2 requires both IDs. - [Fact] - public void EnrollRejectsTheNilAuthorityId() - { - ArgumentException exception = Assert.Throws( - () => ResponseBodies.Enroll(Guid.Empty, IdentityId, [DeviceA, VectorRoot], Config)); - - Assert.Equal("authorityId", exception.ParamName); - } - - [Fact] - public void EnrollRejectsTheNilIdentityId() + // RFC 9562 §5.9: the nil UUID, with all 128 bits zero, stands for the absence of a UUID, + // and CONTRACT.md §5.2 requires both IDs. + [Theory] + [InlineData(NilIdText, IdentityIdText, "authorityId")] + [InlineData(AuthorityIdText, NilIdText, "identityId")] + public void EnrollRejectsANilId(string authorityId, string identityId, string parameter) { ArgumentException exception = Assert.Throws( - () => ResponseBodies.Enroll(AuthorityId, Guid.Empty, [DeviceA, VectorRoot], Config)); + () => ResponseBodies.Enroll(Guid.Parse(authorityId), Guid.Parse(identityId), [DeviceA, VectorRoot], Config)); - Assert.Equal("identityId", exception.ParamName); + Assert.Equal(parameter, exception.ParamName); } // CONTRACT.md §5.3: `200`: `{ "certificate_chain": [ ... ] }`. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 4be81dd175..d674038595 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -11,6 +11,201 @@ public class CertificateSigningRequestTests internal static readonly byte[] P256SubjectPublicKeyInfoPrefix = Convert.FromHexString("3059301306072A8648CE3D020106082A8648CE3D030107034200"); + // CONTRACT.md §4: the server ignores the CSR's subject and attributes, including requested extensions, + // and security invariant 2 lets a CSR supply only a verified public key. + private static readonly Dictionary> RequestsWithIgnoredContents = new() + { + ["subject, attributes and extensions"] = key => + { + CertificateRequest request = new("CN=Administrator, O=Another Identity", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + SubjectAlternativeNameBuilder alternativeName = new(); + alternativeName.AddUri(new Uri("urn:uuid:5d2e8f14-3c61-4b7a-8e29-6a0f4c1d7b02")); + request.CertificateExtensions.Add(alternativeName.Build()); + // RFC 2985 §5.4.1 challengePassword, a DirectoryString. + byte[] challengePassword = [0x0C, 0x02, 0x70, 0x77]; + request.OtherRequestAttributes.Add(new AsnEncodedData("1.2.840.113549.1.9.7", challengePassword)); + return request.CreateSigningRequest(); + }, + + // CONTRACT.md §4: the agent's CSR carries an empty attribute set, A0 00. + ["empty attribute set"] = key => + TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: [0xA0, 0x00]), key), + + // CONTRACT.md §4: the server ignores an extensionRequest with an empty Extensions list, + // which common CSR builders emit although RFC 5280 §4.1 gives Extensions SIZE (1..MAX). + // The attribute is the PKCS #9 extensionRequest (RFC 2985 §5.4.2) with one value, an empty SEQUENCE. + ["extensionRequest with empty Extensions"] = key => + { + byte[] extensionRequest = TestCsr.Sequence( + TestCsr.Oid("1.2.840.113549.1.9.14"), + TestCsr.Tlv(0x31, TestCsr.Sequence())); + byte[] info = TestCsr.RequestInfo( + key.ExportSubjectPublicKeyInfo(), + attributes: TestCsr.Tlv(0xA0, extensionRequest)); + return TestCsr.Request(info, key); + }, + }; + + // X.690 §10.1: DER writes each length in the fewest octets. + private static readonly Dictionary> RequestsWithALongerLength = new() + { + // The self-signature covers only the request info, so the request still verifies with a longer outer length. + ["CertificationRequest"] = () => TestCsr.TlvWithLongerLength(0x30, ValidVectorContents()), + + // The same rule inside the request info, which a test can only change by signing the request again. + ["subject"] = () => + { + using ECDsa key = NewKey(); + byte[] subject = TestCsr.TlvWithLongerLength(0x30, []); + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject); + return TestCsr.Request(info, key); + }, + }; + + // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker. + private static readonly Dictionary> RequestsWithAnExtraElement = new() + { + ["CertificationRequestInfo"] = key => + { + byte[] info = TestCsr.Sequence( + TestCsr.Integer(0), + TestCsr.EmptySubject, + key.ExportSubjectPublicKeyInfo(), + TestCsr.EmptyAttributes, + TestCsr.Null); + return TestCsr.Request(info, key); + }, + ["CertificationRequest"] = key => + { + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + return TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(TestCsr.Sign(key, info)), + TestCsr.Null); + }, + + // Another element would give the key a second SubjectPublicKeyInfo, and so a second SHA-256. + ["AlgorithmIdentifier of the key"] = key => + { + byte[] algorithm = TestCsr.Sequence( + TestCsr.Oid(TestCsr.IdEcPublicKey), + TestCsr.Oid(TestCsr.Secp256r1), + TestCsr.Null); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + return TestCsr.Request(TestCsr.RequestInfo(spki), key); + }, + ["SubjectPublicKeyInfo"] = key => + { + byte[] spki = TestCsr.Sequence( + TestCsr.P256Algorithm(), + TestCsr.BitString(TestCsr.UncompressedPoint(key)), + TestCsr.Null); + return TestCsr.Request(TestCsr.RequestInfo(spki), key); + }, + }; + + private static readonly Dictionary> RequestsForAKeyNotNamedP256 = new() + { + // CONTRACT.md §4: the key is a P-256 key. + ["P-384 key"] = () => + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP384); + return TestCsr.For(key); + }, + + // secp256k1 (SEC 2 §2.4.1) points also take 65 octets uncompressed, so only the curve name tells them apart. + ["P-256 point named secp256k1"] = () => + { + using ECDsa key = NewKey(); + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Oid("1.3.132.0.10")); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + return TestCsr.Request(TestCsr.RequestInfo(spki), key); + }, + }; + + // RFC 5480 §2.1.1: the parameters of id-ecPublicKey MUST be present, and only namedCurve is allowed. + private static readonly Dictionary> KeysWithoutANamedCurve = new() + { + ["no parameters"] = key => TestCsr.SubjectPublicKeyInfo( + TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey)), + TestCsr.UncompressedPoint(key)), + ["implicitCurve"] = key => TestCsr.SubjectPublicKeyInfo( + TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Null), + TestCsr.UncompressedPoint(key)), + + // The specified form of the P-256 domain parameters, + // which .NET writes for a key imported with explicit parameters. + ["specifiedCurve"] = key => + { + using ECDsa explicitKey = ECDsa.Create(key.ExportExplicitParameters(includePrivateParameters: false)); + return explicitKey.ExportSubjectPublicKeyInfo(); + }, + }; + + // CONTRACT.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. + private static readonly Dictionary> PointsNotUncompressed = new() + { + // SEC 1 §2.3.3: the compressed form is 02 for an even y or 03 for an odd y, then x. + ["compressed"] = key => + { + ECParameters parameters = key.ExportParameters(includePrivateParameters: false); + return [(byte)(0x02 | (parameters.Q.Y![^1] & 1)), .. parameters.Q.X!]; + }, + + // ANSI X9.62 also defines a hybrid form, 06 or 07 then both coordinates; RFC 5480 §2.2 rejects it. + ["hybrid"] = key => + { + byte[] point = TestCsr.UncompressedPoint(key); + point[0] = (byte)(0x06 | (point[^1] & 1)); + return point; + }, + ["without its form octet"] = key => TestCsr.UncompressedPoint(key)[1..], + ["with an extra octet"] = key => [.. TestCsr.UncompressedPoint(key), 0x00], + + // SEC 1 §2.3.3: the point at infinity is the single octet 00. + ["point at infinity"] = _ => [0x00], + }; + + // DER requires an unused bit to be zero, so each BIT STRING is one whose last octet is even. + private static readonly Dictionary> RequestsWithUnusedBits = new() + { + // RFC 5480 §2.2: the octets of the point become the bits of the BIT STRING, so none is unused. + ["subjectPublicKey"] = () => + { + using ECDsa key = NewKeyWhere(point => (point[^1] & 1) == 0); + byte[] spki = TestCsr.SubjectPublicKeyInfo( + TestCsr.P256Algorithm(), + TestCsr.UncompressedPoint(key), + unusedBits: 1); + return TestCsr.Request(TestCsr.RequestInfo(spki), key); + }, + ["signature"] = () => + { + using ECDsa key = NewKey(); + (byte[] info, byte[] signature) = SignedInfoWhere(key, (_, s) => (s[^1] & 1) == 0); + return TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(signature, unusedBits: 1)); + }, + }; + + public static TheoryData RequestWithIgnoredContentsNames => new(RequestsWithIgnoredContents.Keys); + + public static TheoryData RequestWithALongerLengthNames => new(RequestsWithALongerLength.Keys); + + public static TheoryData RequestWithAnExtraElementNames => new(RequestsWithAnExtraElement.Keys); + + public static TheoryData RequestForAKeyNotNamedP256Names => new(RequestsForAKeyNotNamedP256.Keys); + + public static TheoryData KeyWithoutANamedCurveNames => new(KeysWithoutANamedCurve.Keys); + + public static TheoryData PointNotUncompressedNames => new(PointsNotUncompressed.Keys); + + public static TheoryData RequestWithUnusedBitsNames => new(RequestsWithUnusedBits.Keys); + [Theory] [MemberData(nameof(VectorData.CsrNames), MemberType = typeof(VectorData))] public void CsrVectorHasItsExpectedOutcome(string name) @@ -47,53 +242,14 @@ public void AcceptsRequestWrittenByDotNet() Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } - // CONTRACT.md §4: the server ignores the CSR's subject and attributes, including requested extensions, - // and security invariant 2 lets a CSR supply only a verified public key. - [Fact] - public void AcceptsAnySubjectAttributesAndExtensions() - { - using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); - CertificateRequest request = new("CN=Administrator, O=Another Identity", key, HashAlgorithmName.SHA256); - request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); - SubjectAlternativeNameBuilder alternativeName = new(); - alternativeName.AddUri(new Uri("urn:uuid:5d2e8f14-3c61-4b7a-8e29-6a0f4c1d7b02")); - request.CertificateExtensions.Add(alternativeName.Build()); - // RFC 2985 §5.4.1 challengePassword, a DirectoryString. - byte[] challengePassword = [0x0C, 0x02, 0x70, 0x77]; - request.OtherRequestAttributes.Add(new AsnEncodedData("1.2.840.113549.1.9.7", challengePassword)); - - CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(request.CreateSigningRequest())); - - Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); - } - - // CONTRACT.md §4: the agent's CSR carries an empty attribute set, A0 00. - [Fact] - public void AcceptsEmptyAttributeSet() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: [0xA0, 0x00]); - - CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); - - Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); - } - - // CONTRACT.md §4: the server ignores an extensionRequest with an empty Extensions list, - // which common CSR builders emit although RFC 5280 §4.1 gives Extensions SIZE (1..MAX). - // The attribute is the PKCS #9 extensionRequest (RFC 2985 §5.4.2) with one value, an empty SEQUENCE. - [Fact] - public void AcceptsExtensionRequestWithEmptyExtensions() + [Theory] + [MemberData(nameof(RequestWithIgnoredContentsNames))] + public void AcceptsAnySubjectAndAttributes(string request) { using ECDsa key = NewKey(); - byte[] extensionRequest = TestCsr.Sequence( - TestCsr.Oid("1.2.840.113549.1.9.14"), - TestCsr.Tlv(0x31, TestCsr.Sequence())); - byte[] info = TestCsr.RequestInfo( - key.ExportSubjectPublicKeyInfo(), - attributes: TestCsr.Tlv(0xA0, extensionRequest)); - CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); + CertificateSigningRequest csr = + AssertValid(CertificateSigningRequest.Validate(RequestsWithIgnoredContents[request](key))); Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } @@ -117,14 +273,11 @@ public void RejectsTrailingBytes() AssertInvalid(CertificateSigningRequest.Validate([.. valid, 0x00])); } - // X.690 §10.1: DER writes each length in the fewest octets. - // The self-signature covers only the request info, so the request still verifies with a longer outer length. - [Fact] - public void RejectsLengthInMoreOctetsThanNeeded() + [Theory] + [MemberData(nameof(RequestWithALongerLengthNames))] + public void RejectsLengthInMoreOctetsThanNeeded(string element) { - byte[] contents = ValidVectorContents(); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.TlvWithLongerLength(0x30, contents))); + AssertInvalid(CertificateSigningRequest.Validate(RequestsWithALongerLength[element]())); } // X.690 §10.1: DER uses the definite form of length. @@ -136,17 +289,6 @@ public void RejectsIndefiniteLength() AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x80, .. contents, 0x00, 0x00])); } - // The same rule inside the request info, which a test can only change by signing the request again. - [Fact] - public void RejectsLengthInMoreOctetsThanNeededInsideTheRequestInfo() - { - using ECDsa key = NewKey(); - byte[] subject = TestCsr.TlvWithLongerLength(0x30, []); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); - } - // RFC 2986 §4.1: version is v1, encoded 0. [Fact] public void RejectsVersion1() @@ -172,101 +314,36 @@ public void RejectsSubjectThatIsNotASequence() } // RFC 2986 §4.1: attributes is a required field, a constructed [0] IMPLICIT SET OF Attribute. - [Fact] - public void RejectsRequestInfoWithoutAttributes() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.Sequence(TestCsr.Integer(0), TestCsr.EmptySubject, key.ExportSubjectPublicKeyInfo()); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); - } - - [Fact] - public void RejectsPrimitiveAttributes() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.Sequence( - TestCsr.Integer(0), - TestCsr.EmptySubject, - key.ExportSubjectPublicKeyInfo(), - [0x80, 0x00]); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); - } - - // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker. - [Fact] - public void RejectsExtraElementInRequestInfo() + // The rows leave it out and write it primitive, 80 00. + [Theory] + [InlineData("")] + [InlineData("8000")] + public void RejectsRequestInfoWithoutConstructedAttributes(string attributes) { using ECDsa key = NewKey(); byte[] info = TestCsr.Sequence( TestCsr.Integer(0), TestCsr.EmptySubject, key.ExportSubjectPublicKeyInfo(), - TestCsr.EmptyAttributes, - TestCsr.Null); + Convert.FromHexString(attributes)); AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); } - [Fact] - public void RejectsExtraElementInRequest() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); - byte[] der = TestCsr.Sequence( - info, - TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), - TestCsr.BitString(TestCsr.Sign(key, info)), - TestCsr.Null); - - AssertInvalid(CertificateSigningRequest.Validate(der)); - } - - // CONTRACT.md §4: the key is a P-256 key. - [Fact] - public void RejectsKeyOnAnotherCurve() - { - using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP384); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.For(key))); - } - - // secp256k1 (SEC 2 §2.4.1) points also take 65 octets uncompressed, so only the curve name tells them apart. - [Fact] - public void RejectsP256PointNamedAsAnotherCurve() - { - using ECDsa key = NewKey(); - byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Oid("1.3.132.0.10")); - byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); - } - - // Another element would give the key a second SubjectPublicKeyInfo, and so a second SHA-256. - [Fact] - public void RejectsKeyAlgorithmWithAnExtraElement() + [Theory] + [MemberData(nameof(RequestWithAnExtraElementNames))] + public void RejectsExtraElement(string sequence) { using ECDsa key = NewKey(); - byte[] algorithm = TestCsr.Sequence( - TestCsr.Oid(TestCsr.IdEcPublicKey), - TestCsr.Oid(TestCsr.Secp256r1), - TestCsr.Null); - byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + AssertInvalid(CertificateSigningRequest.Validate(RequestsWithAnExtraElement[sequence](key))); } - [Fact] - public void RejectsSubjectPublicKeyInfoWithAnExtraElement() + [Theory] + [MemberData(nameof(RequestForAKeyNotNamedP256Names))] + public void RejectsKeyNotNamedP256(string key) { - using ECDsa key = NewKey(); - byte[] spki = TestCsr.Sequence( - TestCsr.P256Algorithm(), - TestCsr.BitString(TestCsr.UncompressedPoint(key)), - TestCsr.Null); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + AssertInvalid(CertificateSigningRequest.Validate(RequestsForAKeyNotNamedP256[key]())); } // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; CONTRACT.md §4 needs a signing key. @@ -280,85 +357,23 @@ public void RejectsKeyRestrictedToKeyAgreement() AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); } - // RFC 5480 §2.1.1: the parameters of id-ecPublicKey MUST be present, and only namedCurve is allowed. - [Fact] - public void RejectsKeyWithoutCurveParameters() - { - using ECDsa key = NewKey(); - byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey)); - byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); - } - - [Fact] - public void RejectsImplicitCurve() - { - using ECDsa key = NewKey(); - byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Null); - byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); - } - - // The specified form of the P-256 domain parameters, which .NET writes for a key imported with explicit parameters. - [Fact] - public void RejectsSpecifiedCurve() + [Theory] + [MemberData(nameof(KeyWithoutANamedCurveNames))] + public void RejectsKeyWithoutANamedCurve(string parameters) { using ECDsa key = NewKey(); - using ECDsa explicitKey = ECDsa.Create(key.ExportExplicitParameters(includePrivateParameters: false)); - byte[] spki = explicitKey.ExportSubjectPublicKeyInfo(); + byte[] spki = KeysWithoutANamedCurve[parameters](key); AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); } - // CONTRACT.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. - // SEC 1 §2.3.3: the compressed form is 02 for an even y or 03 for an odd y, then x. - [Fact] - public void RejectsCompressedPoint() - { - using ECDsa key = NewKey(); - ECParameters parameters = key.ExportParameters(includePrivateParameters: false); - byte[] point = [(byte)(0x02 | (parameters.Q.Y![^1] & 1)), .. parameters.Q.X!]; - - AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); - } - - // ANSI X9.62 also defines a hybrid form, 06 or 07 then both coordinates; RFC 5480 §2.2 rejects it. - [Fact] - public void RejectsHybridPoint() - { - using ECDsa key = NewKey(); - byte[] point = TestCsr.UncompressedPoint(key); - point[0] = (byte)(0x06 | (point[^1] & 1)); - - AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); - } - - [Fact] - public void RejectsPointWithoutItsFormOctet() - { - using ECDsa key = NewKey(); - - AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, TestCsr.UncompressedPoint(key)[1..]))); - } - - [Fact] - public void RejectsPointWithAnExtraOctet() - { - using ECDsa key = NewKey(); - byte[] point = [.. TestCsr.UncompressedPoint(key), 0x00]; - - AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); - } - - // SEC 1 §2.3.3: the point at infinity is the single octet 00. - [Fact] - public void RejectsPointAtInfinity() + [Theory] + [MemberData(nameof(PointNotUncompressedNames))] + public void RejectsPointNotUncompressed(string point) { using ECDsa key = NewKey(); - AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, [0x00]))); + AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, PointsNotUncompressed[point](key)))); } // Flipping the low bit of y leaves the point off the curve, @@ -375,18 +390,11 @@ public void RejectsPointOffTheCurve() AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); } - // RFC 5480 §2.2: the octets of the point become the bits of the BIT STRING, so none is unused. - // DER requires an unused bit to be zero, so the key is one whose last octet is even. - [Fact] - public void RejectsKeyBitStringWithUnusedBits() + [Theory] + [MemberData(nameof(RequestWithUnusedBitsNames))] + public void RejectsBitStringWithUnusedBits(string bitString) { - using ECDsa key = NewKeyWhere(point => (point[^1] & 1) == 0); - byte[] spki = TestCsr.SubjectPublicKeyInfo( - TestCsr.P256Algorithm(), - TestCsr.UncompressedPoint(key), - unusedBits: 1); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); + AssertInvalid(CertificateSigningRequest.Validate(RequestsWithUnusedBits[bitString]())); } // CONTRACT.md §4: the signature algorithm is ecdsa-with-SHA256, even when the signature verifies under it. @@ -461,20 +469,6 @@ public void RejectsSignatureWithNegativeR() AssertInvalid(CertificateSigningRequest.Validate(der)); } - // DER requires an unused bit to be zero, so the signature is one whose last octet is even. - [Fact] - public void RejectsSignatureBitStringWithUnusedBits() - { - using ECDsa key = NewKey(); - (byte[] info, byte[] signature) = SignedInfoWhere(key, (_, s) => (s[^1] & 1) == 0); - byte[] der = TestCsr.Sequence( - info, - TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), - TestCsr.BitString(signature, unusedBits: 1)); - - AssertInvalid(CertificateSigningRequest.Validate(der)); - } - private static CertificateSigningRequest AssertValid(CsrValidationResult result) { Assert.True(result.Succeeded, result.FailureDetail); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 3e89a76e53..24c86c8859 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -209,19 +209,15 @@ public void NotBeforeIsTheClockTruncatedToTheSecond() Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); } - [Fact] - public void NotAfterIsNotBeforePlusTheLifetime() + // A certificate holds whole seconds, so a fraction of a second in the lifetime is dropped. + [Theory] + [InlineData(0)] + [InlineData(500)] + public void NotAfterIsNotBeforePlusTheWholeSecondsOfTheLifetime(int lifetimeMilliseconds) { - using X509Certificate2 leaf = Issue(lifetime: TimeSpan.FromDays(30)); - - Assert.Equal(NowInSeconds.AddDays(30).UtcDateTime, leaf.NotAfter.ToUniversalTime()); - } + TimeSpan lifetime = TimeSpan.FromDays(30) + TimeSpan.FromMilliseconds(lifetimeMilliseconds); - // A certificate holds whole seconds. - [Fact] - public void NotAfterDropsTheFractionOfASecondOfTheLifetime() - { - using X509Certificate2 leaf = Issue(lifetime: TimeSpan.FromDays(30) + TimeSpan.FromMilliseconds(500)); + using X509Certificate2 leaf = Issue(lifetime: lifetime); Assert.Equal(NowInSeconds.AddDays(30).UtcDateTime, leaf.NotAfter.ToUniversalTime()); } @@ -335,28 +331,44 @@ public async Task RenewalCertifiesTheKeyOfTheCsrForTheIdentityThatSignedTheRenew Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo()); } + // INTENT.md: code that acts for an identity takes the identity that the signature check produced, + // and only a renew request proves the identity that a renewal acts for. // An identity that proved its key for another operation never had a renew request accepted for it. [Theory] - [InlineData(AgentOperation.Confirm)] - [InlineData(AgentOperation.CheckIn)] - [InlineData(AgentOperation.Connect)] - public async Task RenewalThrowsForAnIdentityAuthenticatedForAnotherOperation(AgentOperation operation) + [InlineData(nameof(IdentityCertificateIssuer), AgentOperation.Confirm)] + [InlineData(nameof(IdentityCertificateIssuer), AgentOperation.CheckIn)] + [InlineData(nameof(IdentityCertificateIssuer), AgentOperation.Connect)] + [InlineData(nameof(RenewalRules), AgentOperation.Confirm)] + [InlineData(nameof(RenewalRules), AgentOperation.CheckIn)] + [InlineData(nameof(RenewalRules), AgentOperation.Connect)] + public async Task RenewalThrowsForAnIdentityAuthenticatedForAnotherOperation( + string renewal, + AgentOperation operation) { AuthenticatedIdentity identity = await TestIdentity.AuthenticateAsync(operation); - ArgumentException exception = Assert.Throws(() => IssueForRenewal(identity)); + ArgumentException exception = Assert.Throws(() => Renewals[renewal](identity)); Assert.Equal("renewing", exception.ParamName); } - [Fact] - public void RenewalThrowsForANullIdentity() + [Theory] + [InlineData(nameof(IdentityCertificateIssuer))] + [InlineData(nameof(RenewalRules))] + public void RenewalThrowsForANullIdentity(string renewal) { - ArgumentNullException exception = Assert.Throws(() => IssueForRenewal(null!)); + ArgumentNullException exception = Assert.Throws(() => Renewals[renewal](null!)); Assert.Equal("renewing", exception.ParamName); } + // The two calls that act for a renewing identity, named by the class of each. + private Dictionary> Renewals => new() + { + [nameof(IdentityCertificateIssuer)] = renewing => IssueForRenewal(renewing).Dispose(), + [nameof(RenewalRules)] = renewing => RenewalRules.Decide(renewing, null), + }; + private X509Certificate2 IssueForRenewal(AuthenticatedIdentity renewing) { return IdentityCertificateIssuer.Issue( diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs index a855a1af4d..c94b446c55 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs @@ -81,27 +81,4 @@ public async Task IssuesAPendingCertificateForAKeyNeverIssued() Assert.Null(decision.Error); Assert.Null(decision.FailureDetail); } - - // INTENT.md: code that acts for an identity takes the identity that the signature check produced, - // and only a renew request proves the identity that a renewal acts for. - [Theory] - [InlineData(AgentOperation.Confirm)] - [InlineData(AgentOperation.CheckIn)] - [InlineData(AgentOperation.Connect)] - public async Task ThrowsForAnIdentityAuthenticatedForAnotherOperation(AgentOperation operation) - { - AuthenticatedIdentity identity = await TestIdentity.AuthenticateAsync(operation); - - ArgumentException exception = Assert.Throws(() => RenewalRules.Decide(identity, null)); - - Assert.Equal("renewing", exception.ParamName); - } - - [Fact] - public void ThrowsForANullIdentity() - { - ArgumentNullException exception = Assert.Throws(() => RenewalRules.Decide(null!, null)); - - Assert.Equal("renewing", exception.ParamName); - } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index cfeb3eab2e..a92f703844 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -45,14 +45,29 @@ public class ChannelHandshakeTests VectorChannel.HelloMessage(opening, [.. ValidProof, 0x00]), }; - // Alterations that break a rule which §7.3 step 4 checks after the one a message of SignatureFailures breaks. + // Alterations that break a rule which §7.3 step 4 checks after the one a message of SignatureFailures breaks, + // by the name of the message failure that each makes of a valid Hello. private static readonly Dictionary> LaterFailures = new() { - ["no correlation_id"] = message => message.ClearCorrelationId(), - ["a proof by another device"] = message => message.Hello.Proof = ByteString.CopyFrom(OtherKeyProof), - ["invalid metadata"] = message => message.Hello.Metadata.Add("Hostname", "device-a"), + ["a Hello without correlation_id"] = message => message.ClearCorrelationId(), + ["a Hello with the proof of another device"] = + message => message.Hello.Proof = ByteString.CopyFrom(OtherKeyProof), + ["a Hello with invalid metadata"] = message => message.Hello.Metadata.Add("Hostname", "device-a"), }; + // Messages that break two rules of §7.3 step 4, by the name of the earlier failure and of the later one. + private static readonly (string Earlier, string Later)[] MessageFailurePairs = + [ + ("an Ack", "a Hello without correlation_id"), + ("a message without payload", "a Hello without correlation_id"), + ("a Hello without correlation_id", "a Hello with the proof of another device"), + ("a Hello correlated to another id", "a Hello with the proof of another device"), + ("a Hello without correlation_id", "a Hello with invalid metadata"), + ("a Hello correlated to another id", "a Hello with invalid metadata"), + ("a Hello with the proof of another device", "a Hello with invalid metadata"), + ("a Hello with a 63-byte proof", "a Hello with invalid metadata"), + ]; + // Answers that the registry must never give for the thumbprint of the certificate that opened the stream. private static readonly Dictionary> WrongRegistryAnswers = new() { @@ -70,15 +85,25 @@ public class ChannelHandshakeTests ["a Hello with the proof of another device"] = opening => opening.VerifyHelloAsync(VectorChannel.HelloMessage(opening, OtherKeyProof)), ["a Hello with invalid metadata"] = opening => opening.VerifyHelloAsync( - Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"])), + Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["a Hello with invalid metadata"])), ["no Hello in time"] = opening => opening.HelloTimedOutAsync(), }; + // The calls that end the handshake of a stream that OpenBeforeNotAfterAsync opens. + private static readonly Dictionary>> + HandshakeEndsBeforeNotAfter = new() + { + ["a valid Hello"] = (opening, hello) => opening.VerifyHelloAsync(hello), + ["no Hello in time"] = (opening, _) => opening.HelloTimedOutAsync(), + }; + public static TheoryData SignatureFailureNames => new(SignatureFailures.Keys); public static TheoryData ScenarioNames => new(Scenarios.Keys); - public static TheoryData ClosureNames => new(Closures.Keys); + // No Hello in time, alone and after each closure. + public static TheoryData NoHelloNames => + new(Closures.Keys.Select(WithoutHello).Prepend("no Hello in time")); // Each wrong answer of the registry, to each of the two calls that read it. public static TheoryData WrongRegistryAnswersAndCalls @@ -97,40 +122,16 @@ public static TheoryData WrongRegistryAnswersAndCalls } } - // Each failure decided before the message, with each failure that the handshake checks after it. - public static TheoryData EarlyAndLaterFailures - { - get - { - TheoryData cases = new(); - string[] early = [.. EarlyFailures.Keys]; - - for (int i = 0; i < early.Length; i++) - { - foreach (string later in early.Skip(i + 1).Concat(MessageFailures.Keys)) - { - cases.Add(early[i], later); - } - } - - return cases; - } - } - - // Each closure, with each closure checked after it and with the timeout itself. - public static TheoryData ClosuresAndLaterTimeoutFailures + // Each pair of failures that the handshake checks one after the other. + public static TheoryData FailurePairNames { get { TheoryData cases = new(); - string[] closures = [.. Closures.Keys]; - for (int i = 0; i < closures.Length; i++) + foreach ((string earlier, string later) in FailurePairs.Keys) { - foreach (string later in closures.Skip(i + 1).Append("no Hello in time")) - { - cases.Add(closures[i], later); - } + cases.Add(earlier, later); } return cases; @@ -154,76 +155,105 @@ public static TheoryData HandshakeEndsThenAnotherCall } } - public static TheoryData FailurePairs => new() - { - { "an Ack", "no correlation_id" }, - { "a message without payload", "no correlation_id" }, - { "a Hello without correlation_id", "a proof by another device" }, - { "a Hello correlated to another id", "a proof by another device" }, - { "a Hello without correlation_id", "invalid metadata" }, - { "a Hello correlated to another id", "invalid metadata" }, - { "a Hello with the proof of another device", "invalid metadata" }, - { "a Hello with a 63-byte proof", "invalid metadata" }, - }; - private static byte[] ValidProof => TestVectors.Instance.ChannelProof.Case("valid").Proof; - // Every message that breaks one rule of §7.3 step 4: SignatureFailures, and invalid metadata in a valid Hello. - private static Dictionary> MessageFailures => - new(SignatureFailures) - { - ["a Hello with invalid metadata"] = opening => - Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["invalid metadata"]), - }; - // CONTRACT.md §7.4: the closures that apply to a stream even while it awaits its Hello, in the order the // handshake checks them, with the code and the gRPC status that close the stream. // The certificate of device-a expires 70 days after the vector opening, so a reading after its notAfter is also // after the Hello timeout. - private static OrderedDictionary - Closures => new() - { - ["the certificate is no longer registered"] = ( - new Scenario { CertificateRemoved = true }, - AgentIdentityErrorCode.IdentityUnknown, - StatusCode.Unauthenticated), - ["the identity is revoked"] = ( - new Scenario { IdentityRevoked = true }, - AgentIdentityErrorCode.IdentityRevoked, - StatusCode.PermissionDenied), - ["the certificate has expired"] = ( - new Scenario { HandshakeTime = DeviceANotAfter.AddTicks(1) }, - AgentIdentityErrorCode.CertificateExpired, - StatusCode.Unauthenticated), - }; + private static OrderedDictionary Closures => new() + { + ["the certificate is no longer registered"] = new( + new Scenario { CertificateRemoved = true }, + AgentIdentityErrorCode.IdentityUnknown, + StatusCode.Unauthenticated), + ["the identity is revoked"] = new( + new Scenario { IdentityRevoked = true }, + AgentIdentityErrorCode.IdentityRevoked, + StatusCode.PermissionDenied), + ["the certificate has expired"] = new( + new Scenario { HandshakeTime = DeviceANotAfter.AddTicks(1) }, + AgentIdentityErrorCode.CertificateExpired, + StatusCode.Unauthenticated), + }; // The failures that the handshake decides from the registry and the clock before it reads the message: // the closures, then a late Hello. - private static OrderedDictionary - EarlyFailures => new(Closures) - { - ["the Hello is late"] = ( - new Scenario { HandshakeTime = VectorChannel.OpeningTime.AddSeconds(10).AddTicks(1) }, - AgentIdentityErrorCode.SignatureInvalid, - StatusCode.Unauthenticated), - }; + private static OrderedDictionary EarlyFailures => new(Closures) + { + ["the Hello is late"] = new( + new Scenario { HandshakeTime = VectorChannel.OpeningTime.AddSeconds(10).AddTicks(1) }, + AgentIdentityErrorCode.SignatureInvalid, + StatusCode.Unauthenticated), + }; + + // CONTRACT.md §7.3 step 4: every message that breaks one of its rules, + // each of SignatureFailures with signature_invalid, and invalid metadata in a valid Hello with invalid_request. + private static Dictionary MessageFailures => + SignatureFailures + .Select(failure => KeyValuePair.Create( + failure.Key, + new Failure( + new Scenario { FirstMessage = failure.Value }, + AgentIdentityErrorCode.SignatureInvalid, + StatusCode.Unauthenticated))) + .Append(KeyValuePair.Create( + "a Hello with invalid metadata", + new Failure( + new Scenario + { + FirstMessage = opening => Altered( + VectorChannel.ValidHelloMessage(opening), + LaterFailures["a Hello with invalid metadata"]), + }, + AgentIdentityErrorCode.InvalidRequest, + StatusCode.InvalidArgument))) + .ToDictionary(); // No first message arrives, and the host ends the handshake for the timeout. private static Scenario NoHello => new() { FirstMessage = null }; - // Every scenario by name: a valid handshake, each early failure, each message failure, - // and the timeout, alone and after each closure. - private static Dictionary Scenarios => - new[] { KeyValuePair.Create("a valid Hello", new Scenario()) } - .Concat(EarlyFailures.Select(failure => KeyValuePair.Create(failure.Key, failure.Value.Scenario))) - .Concat(MessageFailures.Select( - failure => KeyValuePair.Create(failure.Key, new Scenario { FirstMessage = failure.Value }))) - .Append(KeyValuePair.Create("no Hello in time", NoHello)) + // Every failure by name: each early failure, each message failure, + // and the timeout, alone with signature_invalid (§7.3 step 4) and after each closure, which §7.4 applies first. + private static Dictionary Failures => + EarlyFailures + .Concat(MessageFailures) + .Append(KeyValuePair.Create( + "no Hello in time", + new Failure(NoHello, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated))) .Concat(Closures.Select(closure => KeyValuePair.Create( - $"no Hello in time, and {closure.Key}", - closure.Value.Scenario.And(NoHello)))) + WithoutHello(closure.Key), + closure.Value with { Scenario = closure.Value.Scenario.And(NoHello) }))) + .ToDictionary(); + + // Every scenario by name: a valid handshake and each failure. + private static Dictionary Scenarios => + Failures + .Select(failure => KeyValuePair.Create(failure.Key, failure.Value.Scenario)) + .Prepend(KeyValuePair.Create("a valid Hello", new Scenario())) .ToDictionary(); + // Each pair of failures that the handshake checks one after the other, by name, with the scenario failing both: + // a message breaking two rules of §7.3 step 4, a failure decided before the message with each failure checked + // after it, and a closure of a stream without Hello with each closure checked after it and with the timeout. + private static Dictionary<(string Earlier, string Later), Scenario> FailurePairs + { + get + { + Dictionary<(string Earlier, string Later), Scenario> pairs = MessageFailurePairs.ToDictionary( + pair => pair, + pair => new Scenario + { + FirstMessage = opening => + Altered(SignatureFailures[pair.Earlier](opening), LaterFailures[pair.Later]), + }); + + AddOrderedPairs(pairs, [.. EarlyFailures.Keys], MessageFailures.Keys); + AddOrderedPairs(pairs, [.. Closures.Keys.Select(WithoutHello)], ["no Hello in time"]); + return pairs; + } + } + // A raw 64-byte signature over the right bytes, by the key of device-b. private static byte[] OtherKeyProof => TestVectors.Instance.ChannelProof.Case("wrong_key").Proof; @@ -253,49 +283,35 @@ public async Task HelloWithInvalidMetadataIsInvalidRequest() { ChannelOpening opening = await VectorChannel.OpenAsync(); AgentMessage hello = VectorChannel.ValidHelloMessage(opening); - LaterFailures["invalid metadata"](hello); + LaterFailures["a Hello with invalid metadata"](hello); ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); AssertFailed(result, AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument); } - // CONTRACT.md §7.3 step 4 checks the payload, the correlation_id, the proof, then the metadata, - // and a message breaking two rules fails as it would for the earlier rule alone. - // An agent that has not proved the key never learns whether its metadata would pass. - [Theory] - [MemberData(nameof(FailurePairs))] - public async Task EarlierFailureWins(string earlier, string later) - { - ChannelHandshakeResult earlierAlone = await VerifyAsync(SignatureFailures[earlier]); - ChannelHandshakeResult laterAlone = - await VerifyAsync(opening => Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures[later])); - - ChannelHandshakeResult result = - await VerifyAsync(opening => Altered(SignatureFailures[earlier](opening), LaterFailures[later])); - - Assert.NotEqual(laterAlone.FailureDetail, earlierAlone.FailureDetail); - AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); - Assert.Equal(earlierAlone.FailureDetail, result.FailureDetail); - } - + // The handshake checks its failures in an order, and a stream that fails two of them closes as it would for the + // earlier one alone. // CONTRACT.md §6 step 4 and §7.4: the registry and the clock decide before the message, in this order: // a certificate no longer registered, a revoked identity, an expired certificate, then a late Hello. - // A stream that fails one of them is closed for that reason, whatever the message holds. + // A stream that fails one of them is closed for that reason, whatever the message holds, and so is a stream + // without Hello, which gets identity_unknown for an identity revoked and then deleted. // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. + // CONTRACT.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, + // so an agent that has not proved the key never learns whether its metadata would pass. [Theory] - [MemberData(nameof(EarlyAndLaterFailures))] - public async Task EarlyFailureWinsOverLaterFailure(string early, string later) + [MemberData(nameof(FailurePairNames))] + public async Task EarlierFailureWins(string earlier, string later) { - (Scenario earlyScenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = EarlyFailures[early]; - ChannelHandshakeResult earlyAlone = await HandshakeAsync(earlyScenario); + (_, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[earlier]; + ChannelHandshakeResult earlierAlone = await HandshakeAsync(Scenarios[earlier]); ChannelHandshakeResult laterAlone = await HandshakeAsync(Scenarios[later]); - ChannelHandshakeResult result = await HandshakeAsync(earlyScenario.And(Scenarios[later])); + ChannelHandshakeResult result = await HandshakeAsync(FailurePairs[(earlier, later)]); - Assert.NotEqual(laterAlone.FailureDetail, earlyAlone.FailureDetail); + Assert.NotEqual(laterAlone.FailureDetail, earlierAlone.FailureDetail); AssertFailed(result, expectedCode, expectedStatus); - Assert.Equal(earlyAlone.FailureDetail, result.FailureDetail); + Assert.Equal(earlierAlone.FailureDetail, result.FailureDetail); } // The shared intent, security invariant 5: revocation blocks authentication, including of a stream whose identity @@ -325,31 +341,28 @@ public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() AssertFailed(result, AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated); } + // The identity reports the status that the registry holds for the opening certificate when the handshake ends. + // CONTRACT.md §6 table: a channel opening accepts a pending certificate as well as a current one. + // CONTRACT.md §5.5: a confirm during the handshake makes a pending certificate current. // CONTRACT.md §7.4: a stream whose certificate is retired while it awaits its Hello still completes the handshake, // then gets the treatment of every stream of a retired certificate, so the result reports the retirement. // Failing it would send a healthy agent the terminal identity_unknown. - [Fact] - public async Task CertificateRetiredDuringTheHandshakeAuthenticatesAsRetired() - { - ChannelHandshakeResult result = await HandshakeAsync(new Scenario { Status = CertificateStatus.Retired }); - - Assert.True(result.Succeeded, result.FailureDetail); - Assert.Equal(CertificateStatus.Retired, result.Identity.CertificateStatus); - } - - // CONTRACT.md §5.5: a confirm makes a pending certificate current, - // and the identity reports the status that the registry holds when the handshake ends. - [Fact] - public async Task ConfirmDuringTheHandshakeIsReported() + [Theory] + [InlineData(CertificateStatus.Pending, CertificateStatus.Pending)] + [InlineData(CertificateStatus.Pending, CertificateStatus.Current)] + [InlineData(CertificateStatus.Current, CertificateStatus.Retired)] + public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake( + CertificateStatus opened, + CertificateStatus atHandshake) { VectorAuthority authority = new(); - ChannelOpening opening = await VectorChannel.OpenAsync(authority, status: CertificateStatus.Pending); - VectorChannel.RegisterDevice(authority.Registry, CertificateStatus.Current, identityRevoked: false); + ChannelOpening opening = await VectorChannel.OpenAsync(authority, status: opened); + VectorChannel.RegisterDevice(authority.Registry, atHandshake, identityRevoked: false); ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); - Assert.Equal(CertificateStatus.Current, result.Identity.CertificateStatus); + Assert.Equal(atHandshake, result.Identity.CertificateStatus); } // A retired certificate passes the handshake, but the revocation of its identity still blocks it. @@ -473,19 +486,6 @@ public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() Assert.Null(result.FailureDetail); } - // CONTRACT.md §6 table: a channel opening accepts a pending certificate as well as a current one, - // and the identity reports which of the two opened the stream. - [Fact] - public async Task IdentityHasTheStatusOfTheOpeningCertificate() - { - ChannelOpening opening = await VectorChannel.OpenAsync(status: CertificateStatus.Pending); - - ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); - - Assert.True(result.Succeeded, result.FailureDetail); - Assert.Equal(CertificateStatus.Pending, result.Identity.CertificateStatus); - } - // CONTRACT.md §3: the server replaces the identity's metadata with the metadata of each authenticated Hello. [Fact] public async Task ValidHelloYieldsItsMetadata() @@ -582,75 +582,41 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() Assert.Equal(Timestamp.FromDateTimeOffset(DeviceANotAfter), result.Welcome.Welcome.ServerTime); } + // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. // CONTRACT.md §7.4 and the shared intent, security invariant 8: a stream closes no later than the notAfter of the - // certificate that opened it, with UNAUTHENTICATED and error-code certificate_expired. - [Fact] - public async Task HandshakeOneTickAfterTheCertificateNotAfterIsCertificateExpired() - { - (ChannelOpening opening, AgentMessage hello) = await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(1)); - - ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); - - AssertFailed(result, AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated); - } - - // CONTRACT.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. - [Fact] - public async Task NoHelloInTimeIsSignatureInvalid() - { - ChannelOpening opening = await VectorChannel.OpenAsync(); - - ChannelHandshakeResult result = await opening.HelloTimedOutAsync(); - - AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); - } - - // CONTRACT.md §7.4: a stream still waiting for its Hello after its certificate's notAfter closes with - // certificate_expired rather than as a timeout. + // certificate that opened it, with UNAUTHENTICATED and error-code certificate_expired, + // even while it waits for its Hello, so the timeout fails as a timeout only until then. [Theory] - [InlineData(0, AgentIdentityErrorCode.SignatureInvalid)] - [InlineData(1, AgentIdentityErrorCode.CertificateExpired)] - public async Task TimeoutIsCertificateExpiredOnlyAfterTheNotAfter( + [InlineData("a valid Hello", 1, AgentIdentityErrorCode.CertificateExpired)] + [InlineData("no Hello in time", 0, AgentIdentityErrorCode.SignatureInvalid)] + [InlineData("no Hello in time", 1, AgentIdentityErrorCode.CertificateExpired)] + public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( + string end, long ticksAfterNotAfter, AgentIdentityErrorCode expectedCode) { - (ChannelOpening opening, _) = await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(ticksAfterNotAfter)); + (ChannelOpening opening, AgentMessage hello) = + await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(ticksAfterNotAfter)); - ChannelHandshakeResult result = await opening.HelloTimedOutAsync(); + ChannelHandshakeResult result = await HandshakeEndsBeforeNotAfter[end](opening, hello); AssertFailed(result, expectedCode, StatusCode.Unauthenticated); } + // CONTRACT.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. // CONTRACT.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the failures of §7.3 // step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. [Theory] - [MemberData(nameof(ClosureNames))] - public async Task TimeoutOfAClosedStreamFailsWithTheClosure(string closure) + [MemberData(nameof(NoHelloNames))] + public async Task NoHelloInTimeIsSignatureInvalidUnlessAClosureApplies(string noHello) { - (Scenario scenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Closures[closure]; + (Scenario scenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[noHello]; - ChannelHandshakeResult result = await HandshakeAsync(scenario.And(NoHello)); + ChannelHandshakeResult result = await HandshakeAsync(scenario); AssertFailed(result, expectedCode, expectedStatus); } - // CONTRACT.md §7.4: the closures keep their order while the stream awaits its Hello, - // so an identity revoked and then deleted during that wait gets identity_unknown. - [Theory] - [MemberData(nameof(ClosuresAndLaterTimeoutFailures))] - public async Task ClosureWinsOverLaterFailureWithoutHello(string early, string later) - { - (Scenario earlyScenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Closures[early]; - ChannelHandshakeResult earlyAlone = await HandshakeAsync(earlyScenario.And(NoHello)); - ChannelHandshakeResult laterAlone = await HandshakeAsync(Scenarios[later].And(NoHello)); - - ChannelHandshakeResult result = await HandshakeAsync(earlyScenario.And(Scenarios[later]).And(NoHello)); - - Assert.NotEqual(laterAlone.FailureDetail, earlyAlone.FailureDetail); - AssertFailed(result, expectedCode, expectedStatus); - Assert.Equal(earlyAlone.FailureDetail, result.FailureDetail); - } - // CONTRACT.md §7.3 step 4: the server waits 10 s for the Hello. [Fact] public void HelloTimeoutIs10Seconds() @@ -761,6 +727,28 @@ private static AgentMessage Altered(AgentMessage message, Action a return message; } + // Pairs each failure with every failure after it in the order, then with each failure checked after all of them, + // and fails both by combining their scenarios. + private static void AddOrderedPairs( + Dictionary<(string Earlier, string Later), Scenario> pairs, + string[] ordered, + IEnumerable checkedAfter) + { + for (int i = 0; i < ordered.Length; i++) + { + foreach (string later in ordered.Skip(i + 1).Concat(checkedAfter)) + { + pairs.Add((ordered[i], later), Scenarios[ordered[i]].And(Scenarios[later])); + } + } + } + + // The name of the failure of a stream that the closure ends before any message arrives. + private static string WithoutHello(string closure) + { + return $"no Hello in time, and {closure}"; + } + // Opens the vector stream and verifies the first message built for it. private static Task VerifyAsync(Func firstMessage) { @@ -852,4 +840,7 @@ public Scenario And(Scenario later) }; } } + + // A scenario that fails the handshake, with the code and the gRPC status that close the stream. + private sealed record Failure(Scenario Scenario, AgentIdentityErrorCode Code, StatusCode Status); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs index 707b77b838..7d4c6fbe8c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs @@ -19,6 +19,13 @@ public class ChannelMessagesTests ["Reconnect"] = () => ChannelMessages.Reconnect(ReconnectReason.CertificateRotated), }; + // The messages that carry a reason, built with the reason given as its integer value. + private static readonly Dictionary> MessagesWithAReason = new() + { + ["RenewRequested"] = reason => ChannelMessages.RenewRequested((RenewalRequestReason)reason), + ["Reconnect"] = reason => ChannelMessages.Reconnect((ReconnectReason)reason), + }; + public static TheoryData MessageNames => new(Messages.Keys); // CONTRACT.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. @@ -83,23 +90,14 @@ public void ReconnectCarriesItsReason() // Reasons start at 1, so a reason that the host never set is not a valid one, // and a value after the last reason is not one either. [Theory] - [InlineData(0)] - [InlineData(3)] - public void RenewRequestedRejectsAnUndefinedReason(int reason) - { - ArgumentOutOfRangeException exception = Assert.Throws( - () => ChannelMessages.RenewRequested((RenewalRequestReason)reason)); - - Assert.Equal("reason", exception.ParamName); - } - - [Theory] - [InlineData(0)] - [InlineData(2)] - public void ReconnectRejectsAnUndefinedReason(int reason) + [InlineData("RenewRequested", 0)] + [InlineData("RenewRequested", 3)] + [InlineData("Reconnect", 0)] + [InlineData("Reconnect", 2)] + public void MessageRejectsAnUndefinedReason(string message, int reason) { - ArgumentOutOfRangeException exception = Assert.Throws( - () => ChannelMessages.Reconnect((ReconnectReason)reason)); + ArgumentOutOfRangeException exception = + Assert.Throws(() => MessagesWithAReason[message](reason)); Assert.Equal("reason", exception.ParamName); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs index c051d2f935..5ba7ca6318 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs @@ -10,125 +10,114 @@ public class EnrollmentAuthorizationTests private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; private const string Token = "dvaet1." + Bag + "." + Secret; + public static TheoryData FieldLinesWithoutABearerTokenNames => new(FieldLinesWithoutABearerToken.Keys); + + // Field lines that hold no bearer token, by what is wrong with them. + private static Dictionary FieldLinesWithoutABearerToken + { + get + { + Dictionary fieldLines = new() + { + // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is missing. + ["no field"] = [], + + // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is repeated, + // even when every field line holds the same valid token. + ["the same token twice"] = ["Bearer " + Token, "Bearer " + Token], + ["a token, then an empty field"] = ["Bearer " + Token, ""], + ["an empty field, then a token"] = ["", "Bearer " + Token], + + // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is not `Bearer`. + ["Basic"] = ["Basic " + Token], + ["DPoP"] = ["DPoP " + Token], + ["Bearers"] = ["Bearers " + Token], + ["Bear"] = ["Bear " + Token], + ["no scheme"] = [Token], + + // RFC 9110 §11.4: one or more spaces, and nothing else, separate the scheme from the token. + ["the scheme alone"] = ["Bearer"], + ["the scheme and a space"] = ["Bearer "], + ["no space"] = ["Bearer" + Token], + ["a tab"] = ["Bearer\t" + Token], + ["a space and a tab"] = ["Bearer \t" + Token], + }; + + // CONTRACT.md §2.1 (1): a field that holds no token gets `token_invalid`. + foreach ((string name, string token) in MalformedTokens) + { + fieldLines.Add(name, ["Bearer " + token]); + } + + return fieldLines; + } + } + + // CONTRACT.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`. + // The pattern covers the whole value, so a line terminator after the token is not part of a match. + private static Dictionary MalformedTokens => new() + { + ["another version"] = "dvaet2." + Bag + "." + Secret, + ["an uppercase prefix"] = "DVAET1." + Bag + "." + Secret, + ["no dot after the prefix"] = "dvaet1" + Bag + "." + Secret, + ["no prefix"] = Bag + "." + Secret, + ["an empty bag"] = "dvaet1.." + Secret, + ["a padded bag"] = "dvaet1." + Bag + "=." + Secret, + ["base64 characters in the bag"] = "dvaet1." + Bag + "+/." + Secret, + ["a space in the bag"] = "dvaet1." + Bag + " ." + Secret, + ["a fullwidth letter in the bag"] = "dvaet1." + Bag + "\uFF41." + Secret, + ["a third dot"] = "dvaet1." + Bag + "." + Bag + "." + Secret, + ["no secret"] = "dvaet1." + Bag, + ["an empty secret"] = "dvaet1." + Bag + ".", + ["a 42-character secret"] = "dvaet1." + Bag + "." + Secret[..42], + ["a 44-character secret"] = "dvaet1." + Bag + "." + Secret + "A", + ["a padded secret"] = "dvaet1." + Bag + "." + Secret[..42] + "=", + ["a base64 character in the secret"] = "dvaet1." + Bag + "." + Secret[..42] + "/", + ["a line feed after the token"] = Token + "\n", + ["a CRLF after the token"] = Token + "\r\n", + ["a NUL after the token"] = Token + "\0", + ["a comma after the token"] = Token + ",", + ["a second credential after the token"] = Token + ", Bearer " + Token, + }; + // CONTRACT.md §2.1: the authority finds the token whose SHA-256(secret) matches, // and §2 makes it match the secret text exactly. - [Fact] - public void ReadsTheSecretHashOfABearerToken() - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + Token]); - - Assert.True(result.Succeeded); - Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); - } - - // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is missing. - [Fact] - public void RejectsARequestWithoutAuthorization() - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([]); - - AssertTokenInvalid(result); - } - - // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is repeated, - // even when every field line holds the same valid token. [Theory] - [InlineData("Bearer " + Token, "Bearer " + Token)] - [InlineData("Bearer " + Token, "")] - [InlineData("", "Bearer " + Token)] - public void RejectsARepeatedAuthorizationField(string first, string second) - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([first, second]); - - AssertTokenInvalid(result); - } - + [InlineData("a bearer token", "Bearer " + Token)] // RFC 9110 §11.1: the authentication scheme is case-insensitive. - [Theory] - [InlineData("bearer")] - [InlineData("BEARER")] - [InlineData("bEaReR")] - public void AcceptsTheBearerSchemeInAnyCase(string scheme) - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([scheme + " " + Token]); - - Assert.True(result.Succeeded); - Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); - } - - // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is not `Bearer`. - [Theory] - [InlineData("Basic " + Token)] - [InlineData("DPoP " + Token)] - [InlineData("Bearers " + Token)] - [InlineData("Bear " + Token)] - [InlineData(Token)] - public void RejectsAnotherScheme(string fieldValue) - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); - - AssertTokenInvalid(result); - } - + [InlineData("a lowercase scheme", "bearer " + Token)] + [InlineData("an uppercase scheme", "BEARER " + Token)] + [InlineData("a mixed-case scheme", "bEaReR " + Token)] // RFC 9110 §11.4: `credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]`. - [Theory] - [InlineData("Bearer " + Token)] - [InlineData("Bearer " + Token)] - public void AcceptsSeveralSpacesBeforeTheToken(string fieldValue) + [InlineData("two spaces", "Bearer " + Token)] + [InlineData("three spaces", "Bearer " + Token)] + // CONTRACT.md §2.1 (1): the authority finds a token by `SHA-256(secret)` alone, + // and §2 makes the bag routing data that the authority never trusts, so it is not decoded. + // The rows are a bag of an impossible base64url length, one that decodes to bytes that are not JSON, + // and the bag of {"u":"https://other/psu"}. + [InlineData("a one-character bag", "Bearer dvaet1.A." + Secret)] + [InlineData("a bag that is not JSON", "Bearer dvaet1._-_-." + Secret)] + [InlineData("the bag of another URL", "Bearer dvaet1.eyJ1IjoiaHR0cHM6Ly9vdGhlci9wc3UifQ." + Secret)] + // RFC 9110 §5.5: a field value excludes the white space around it, + // which a parser removes before evaluating the value. + [InlineData("a space before", " Bearer " + Token)] + [InlineData("a space after", "Bearer " + Token + " ")] + [InlineData("tabs around", "\tBearer " + Token + "\t")] + [InlineData("spaces and tabs around", " \t Bearer " + Token + " \t ")] + public void ReadsTheSecretHashOfABearerToken(string name, string fieldValue) { EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); - Assert.True(result.Succeeded); + Assert.True(result.Succeeded, name); Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); } - // RFC 9110 §11.4: one or more spaces, and nothing else, separate the scheme from the token. [Theory] - [InlineData("Bearer")] - [InlineData("Bearer ")] - [InlineData("Bearer" + Token)] - [InlineData("Bearer\t" + Token)] - [InlineData("Bearer \t" + Token)] - public void RejectsAFieldWithoutSpacesThenAToken(string fieldValue) + [MemberData(nameof(FieldLinesWithoutABearerTokenNames))] + public void RejectsFieldLinesWithoutABearerToken(string fieldLines) { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); - - AssertTokenInvalid(result); - } - - // CONTRACT.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`; - // §2.1 (1): a field that holds no token gets `token_invalid`. - // The pattern covers the whole value, so a line terminator after the token is not part of a match. - public static TheoryData MalformedTokens => new() - { - "dvaet2." + Bag + "." + Secret, - "DVAET1." + Bag + "." + Secret, - "dvaet1" + Bag + "." + Secret, - Bag + "." + Secret, - "dvaet1.." + Secret, - "dvaet1." + Bag + "=." + Secret, - "dvaet1." + Bag + "+/." + Secret, - "dvaet1." + Bag + " ." + Secret, - "dvaet1." + Bag + "\uFF41." + Secret, - "dvaet1." + Bag + "." + Bag + "." + Secret, - "dvaet1." + Bag, - "dvaet1." + Bag + ".", - "dvaet1." + Bag + "." + Secret[..42], - "dvaet1." + Bag + "." + Secret + "A", - "dvaet1." + Bag + "." + Secret[..42] + "=", - "dvaet1." + Bag + "." + Secret[..42] + "/", - Token + "\n", - Token + "\r\n", - Token + "\0", - Token + ",", - Token + ", Bearer " + Token, - }; - - [Theory] - [MemberData(nameof(MalformedTokens))] - public void RejectsAValueThatIsNotADvaet1Token(string token) - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + token]); + EnrollmentAuthorizationResult result = + EnrollmentAuthorization.ReadBearerToken(FieldLinesWithoutABearerToken[fieldLines]); AssertTokenInvalid(result); } @@ -158,23 +147,6 @@ public void RejectsATokenLongerThan4096Bytes() AssertTokenInvalid(result); } - // CONTRACT.md §2.1 (1): the authority finds a token by `SHA-256(secret)` alone, - // and §2 makes the bag routing data that the authority never trusts, so it is not decoded. - // The rows are a bag of an impossible base64url length, one that decodes to bytes that are not JSON, - // and the bag of {"u":"https://other/psu"}. - [Theory] - [InlineData("A")] - [InlineData("_-_-")] - [InlineData("eyJ1IjoiaHR0cHM6Ly9vdGhlci9wc3UifQ")] - public void ReadsTheSecretHashWhateverTheBag(string bag) - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken( - ["Bearer dvaet1." + bag + "." + Secret]); - - Assert.True(result.Succeeded); - Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); - } - // CONTRACT.md §2: the authority matches the `secret` text exactly. // Ending in '9' (sextet 61) instead of '8' (sextet 60) sets a pad bit, // so a lenient decoder reads the same 32 bytes, but the text, and so its hash, differs. @@ -191,21 +163,6 @@ public void HashesANonCanonicalSecretAsItsText() Assert.NotEqual(HashOf(Secret), result.SecretHash.ToArray()); } - // RFC 9110 §5.5: a field value excludes the white space around it, - // which a parser removes before evaluating the value. - [Theory] - [InlineData(" Bearer " + Token)] - [InlineData("Bearer " + Token + " ")] - [InlineData("\tBearer " + Token + "\t")] - [InlineData(" \t Bearer " + Token + " \t ")] - public void IgnoresWhiteSpaceAroundTheFieldValue(string fieldValue) - { - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); - - Assert.True(result.Succeeded); - Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); - } - // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially, // and a host logs the failure detail, the error and the result of a refused enrollment. // Each row takes another failure path with the token's bag and secret in the field. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index dd3f05f480..0d6948a0bf 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -6,88 +6,67 @@ public class EnrollmentRulesTests private static readonly Guid IdentityId = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"); - // CONTRACT.md §2.1: a token creates an identity when it exists, has not been deleted, has not expired, - // and fewer than `max_uses` identities have been created with it; (6) a new identity. - [Fact] - public void CreatesAnIdentityForANewKeyWithAUsableToken() - { - EnrollmentDecision decision = EnrollmentRules.Decide(Token(), null, Now); + public static TheoryData KeysButTheReplayNames => new(KeysButTheReplay.Keys); - Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); - Assert.Null(decision.ExistingIdentityId); - Assert.Null(decision.Error); - } - - // CONTRACT.md §5.4: `token_expired` when `now ≥ expires_at`. - [Fact] - public void RejectsATokenAtItsExpiryWithTokenExpired() + // The issued keys that each key rule of CONTRACT.md §2.1 matches, except the replay, by name. + private static Dictionary KeysButTheReplay => new() { - EnrollmentTokenState token = new(false, expiresAt: Now, maxUses: 10, identitiesCreated: 3); - - EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); + ["the key of another token's identity"] = + IssuedKeyMatch.ForIdentity(IdentityId, false, false, CertificateStatus.Current), + ["a key of a revoked identity"] = IssuedKeyMatch.ForIdentity(IdentityId, true, true, CertificateStatus.Current), + ["the key of a pending certificate"] = + IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Pending), + ["the key of a retired certificate"] = + IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Retired), + ["the key of a deleted identity"] = IssuedKeyMatch.ForDeletedIdentity(), + }; - AssertRejected(AgentIdentityErrorCode.TokenExpired, decision); - } - - [Fact] - public void CreatesAnIdentityJustBeforeTheTokenExpiry() - { - EnrollmentTokenState token = new(false, expiresAt: Now.AddTicks(1), maxUses: 10, identitiesCreated: 3); - - EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); - - Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); - } - - // CONTRACT.md §2.1: a token creates an identity only while fewer than `max_uses` identities have been created - // with it; (5) `token_exhausted`. + // CONTRACT.md §2.1: a token creates an identity when it exists, has not been deleted, has not expired, + // and fewer than `max_uses` identities have been created with it; (6) a new identity. + // The rows are a usable token, one that expires one tick after now, and one that can create one more identity. [Theory] - [InlineData(10, 10)] - [InlineData(10, 11)] - [InlineData(0, 0)] - public void RejectsATokenThatCreatedMaxUsesIdentitiesWithTokenExhausted(int maxUses, int identitiesCreated) + [InlineData(TimeSpan.TicksPerDay, 3)] + [InlineData(1L, 3)] + [InlineData(TimeSpan.TicksPerDay, 9)] + public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int identitiesCreated) { - EnrollmentTokenState token = new(false, Now.AddDays(1), maxUses, identitiesCreated); - - EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); - - AssertRejected(AgentIdentityErrorCode.TokenExhausted, decision); - } - - [Fact] - public void CreatesTheLastIdentityThatATokenAllows() - { - EnrollmentTokenState token = new(false, Now.AddDays(1), maxUses: 10, identitiesCreated: 9); + EnrollmentTokenState token = new(false, Now.AddTicks(ticksToExpiry), maxUses: 10, identitiesCreated); EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); - } - - // CONTRACT.md §2.1: a deleted token authenticates only for an idempotent replay; - // every other use returns `token_invalid`. - [Fact] - public void RejectsADeletedTokenWithTokenInvalid() - { - EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted: true), null, Now); - - AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); + Assert.Null(decision.ExistingIdentityId); + Assert.Null(decision.Error); } // CONTRACT.md §2.1: when several outcomes apply, the earliest wins: // (4) `token_invalid` for a deleted token, then `token_expired` for an expired one; (5) `token_exhausted`. [Theory] - [InlineData(true, true, false, AgentIdentityErrorCode.TokenInvalid)] - [InlineData(true, false, true, AgentIdentityErrorCode.TokenInvalid)] - [InlineData(true, true, true, AgentIdentityErrorCode.TokenInvalid)] - [InlineData(false, true, true, AgentIdentityErrorCode.TokenExpired)] + // CONTRACT.md §5.4: `token_expired` when `now ≥ expires_at`. + [InlineData(false, 0L, 10, 3, AgentIdentityErrorCode.TokenExpired)] + // CONTRACT.md §2.1: a token creates an identity only while fewer than `max_uses` identities have been created + // with it. + [InlineData(false, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenExhausted)] + [InlineData(false, TimeSpan.TicksPerDay, 10, 11, AgentIdentityErrorCode.TokenExhausted)] + [InlineData(false, TimeSpan.TicksPerDay, 0, 0, AgentIdentityErrorCode.TokenExhausted)] + // CONTRACT.md §2.1: a deleted token authenticates only for an idempotent replay; + // every other use returns `token_invalid`, whether or not the token has also expired or been exhausted. + [InlineData(true, TimeSpan.TicksPerDay, 10, 3, AgentIdentityErrorCode.TokenInvalid)] + [InlineData(true, 0L, 10, 3, AgentIdentityErrorCode.TokenInvalid)] + [InlineData(true, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenInvalid)] + [InlineData(true, 0L, 10, 10, AgentIdentityErrorCode.TokenInvalid)] + // CONTRACT.md §2.1: (4) `token_expired` comes before (5) `token_exhausted`. + [InlineData(false, 0L, 10, 10, AgentIdentityErrorCode.TokenExpired)] public void RejectsANewKeyWithTheEarliestTokenOutcome( bool deleted, - bool expired, - bool exhausted, + long ticksToExpiry, + int maxUses, + int identitiesCreated, AgentIdentityErrorCode expected) { - EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted, expired, exhausted), null, Now); + EnrollmentTokenState token = new(deleted, Now.AddTicks(ticksToExpiry), maxUses, identitiesCreated); + + EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); AssertRejected(expected, decision); } @@ -120,48 +99,22 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, // the key of a pending or retired certificate of a non-revoked identity → `invalid_request`, // instead of the replay, which needs the current certificate. [Theory] - [InlineData(false, false, CertificateStatus.Current, AgentIdentityErrorCode.InvalidRequest)] - [InlineData(false, false, CertificateStatus.Pending, AgentIdentityErrorCode.InvalidRequest)] - [InlineData(false, false, CertificateStatus.Retired, AgentIdentityErrorCode.InvalidRequest)] - [InlineData(false, true, CertificateStatus.Current, AgentIdentityErrorCode.InvalidRequest)] - [InlineData(true, true, CertificateStatus.Current, AgentIdentityErrorCode.IdentityRevoked)] - [InlineData(true, true, CertificateStatus.Pending, AgentIdentityErrorCode.IdentityRevoked)] - [InlineData(true, true, CertificateStatus.Retired, AgentIdentityErrorCode.IdentityRevoked)] - [InlineData(true, false, CertificateStatus.Pending, AgentIdentityErrorCode.InvalidRequest)] - [InlineData(true, false, CertificateStatus.Retired, AgentIdentityErrorCode.InvalidRequest)] - public void RejectsAnIssuedKeyByTheFirstMatchingRule( - bool enrolledWithPresentedToken, - bool identityRevoked, - CertificateStatus status, - AgentIdentityErrorCode expected) - { - IssuedKeyMatch keyMatch = - IssuedKeyMatch.ForIdentity(IdentityId, enrolledWithPresentedToken, identityRevoked, status); - - EnrollmentDecision decision = EnrollmentRules.Decide(Token(), keyMatch, Now); - - AssertRejected(expected, decision); - } - - // CONTRACT.md §2.1: the key of a certificate of a deleted identity → `identity_revoked`. - // The authority keeps only the SPKI SHA-256 and issuance time of a deleted identity's keys, - // so no other key rule applies to them. - [Fact] - public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked() - { - EnrollmentDecision decision = EnrollmentRules.Decide(Token(), IssuedKeyMatch.ForDeletedIdentity(), Now); - - AssertRejected(AgentIdentityErrorCode.IdentityRevoked, decision); - } - + [InlineData(false, false, CertificateStatus.Current, false, false, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(false, false, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(false, false, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(false, true, CertificateStatus.Current, false, false, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(true, true, CertificateStatus.Current, false, false, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, true, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, true, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.IdentityRevoked)] + [InlineData(true, false, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.InvalidRequest)] + [InlineData(true, false, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.InvalidRequest)] // CONTRACT.md §2.1: (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. - [Theory] [InlineData(false, false, CertificateStatus.Current, true, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(false, false, CertificateStatus.Current, false, true, AgentIdentityErrorCode.InvalidRequest)] [InlineData(true, true, CertificateStatus.Current, true, false, AgentIdentityErrorCode.IdentityRevoked)] [InlineData(true, true, CertificateStatus.Retired, false, true, AgentIdentityErrorCode.IdentityRevoked)] [InlineData(true, false, CertificateStatus.Pending, true, true, AgentIdentityErrorCode.InvalidRequest)] - public void RejectsAnIssuedKeyBeforeAnExpiredOrExhaustedToken( + public void RejectsAnIssuedKeyByTheFirstMatchingRule( bool enrolledWithPresentedToken, bool identityRevoked, CertificateStatus status, @@ -178,11 +131,17 @@ public void RejectsAnIssuedKeyBeforeAnExpiredOrExhaustedToken( AssertRejected(expected, decision); } - [Fact] - public void RejectsTheKeyOfADeletedIdentityBeforeAnExpiredAndExhaustedToken() + // CONTRACT.md §2.1: the key of a certificate of a deleted identity → `identity_revoked`, + // and (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. + // The authority keeps only the SPKI SHA-256 and issuance time of a deleted identity's keys, + // so no other key rule applies to them. + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, bool exhausted) { EnrollmentDecision decision = EnrollmentRules.Decide( - Token(expired: true, exhausted: true), + Token(expired: expired, exhausted: exhausted), IssuedKeyMatch.ForDeletedIdentity(), Now); @@ -192,30 +151,10 @@ public void RejectsTheKeyOfADeletedIdentityBeforeAnExpiredAndExhaustedToken() // CONTRACT.md §2.1 (3): a deleted token gets `token_invalid` for anything but an idempotent replay, // whatever the CSR public key matches. [Theory] - [InlineData(false, false, CertificateStatus.Current)] - [InlineData(true, true, CertificateStatus.Current)] - [InlineData(true, false, CertificateStatus.Pending)] - [InlineData(true, false, CertificateStatus.Retired)] - public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay( - bool enrolledWithPresentedToken, - bool identityRevoked, - CertificateStatus status) - { - IssuedKeyMatch keyMatch = - IssuedKeyMatch.ForIdentity(IdentityId, enrolledWithPresentedToken, identityRevoked, status); - - EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted: true), keyMatch, Now); - - AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); - } - - [Fact] - public void RejectsADeletedTokenWithTokenInvalidForTheKeyOfADeletedIdentity() + [MemberData(nameof(KeysButTheReplayNames))] + public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(string key) { - EnrollmentDecision decision = EnrollmentRules.Decide( - Token(deleted: true), - IssuedKeyMatch.ForDeletedIdentity(), - Now); + EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted: true), KeysButTheReplay[key], Now); AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); } @@ -231,21 +170,15 @@ public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, Assert.Equal(parameter, exception.ParamName); } - [Fact] - public void KeyMatchRejectsAnUndefinedCertificateStatus() - { - ArgumentOutOfRangeException exception = Assert.Throws( - () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, (CertificateStatus)4)); - - Assert.Equal("status", exception.ParamName); - } - - // Statuses start at 1, so a status that the host never set is not a valid one. - [Fact] - public void KeyMatchRejectsAnUninitializedCertificateStatus() + // Statuses start at 1, so a status that the host never set is not a valid one, + // and a value after the last status is not one either. + [Theory] + [InlineData(0)] + [InlineData(4)] + public void KeyMatchRejectsAnUndefinedCertificateStatus(int status) { ArgumentOutOfRangeException exception = Assert.Throws( - () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, default)); + () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, (CertificateStatus)status)); Assert.Equal("status", exception.ParamName); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index 0484ba2938..f8b532afb9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -77,21 +77,20 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() } // CONTRACT.md §2: `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL. - [Fact] - public void CreatePutsTheProductBaseUrlInTheBag() - { - EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); - - Assert.Equal(("u", "https://host/dvls"), Assert.Single(BagMembers(token))); - } - - // The agent appends `/api/agent-identity/v1/...` to `u` (CONTRACT.md §5), so `u` ends without a slash. [Theory] + [InlineData("https://host/dvls", "https://host/dvls")] + // The agent appends `/api/agent-identity/v1/...` to `u` (CONTRACT.md §5), so `u` ends without a slash. [InlineData("https://host/dvls/", "https://host/dvls")] [InlineData("https://host/dvls//", "https://host/dvls")] [InlineData("https://host/", "https://host")] [InlineData("https://host", "https://host")] - public void CreateRemovesTrailingSlashesFromTheUrl(string productBaseUrl, string expected) + // A URI holds no white space (RFC 3986 §2): + // white space around it is not part of it (Appendix C), + // and a space or a non-ASCII character inside it is percent-encoded (§2.1; RFC 3987 §3.1). + [InlineData(" https://host/dvls\t", "https://host/dvls")] + [InlineData("https://host/my dvls", "https://host/my%20dvls")] + [InlineData("https://host/dvls\u00E9", "https://host/dvls%C3%A9")] + public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl, string expected) { EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); @@ -116,10 +115,11 @@ public void CreateRemovesTrailingSlashesFromTheUrl(string productBaseUrl, string "https://@host/dvls", ]; - public static TheoryData UnacceptableUrlRows => new(UnacceptableUrls); + // The unacceptable URLs, and a URL one byte longer than the longest that fits in a token (CONTRACT.md §2). + public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls, UrlOfLength(3026)]); [Theory] - [MemberData(nameof(UnacceptableUrlRows))] + [MemberData(nameof(RejectedUrlRows))] public void CreateRejectsAnotherUrl(string productBaseUrl) { ArgumentException exception = Assert.Throws( @@ -128,20 +128,6 @@ public void CreateRejectsAnotherUrl(string productBaseUrl) Assert.Equal("productBaseUrl", exception.ParamName); } - // A URI holds no white space (RFC 3986 §2): - // white space around it is not part of it (Appendix C), - // and a space or a non-ASCII character inside it is percent-encoded (§2.1; RFC 3987 §3.1). - [Theory] - [InlineData(" https://host/dvls\t", "https://host/dvls")] - [InlineData("https://host/my dvls", "https://host/my%20dvls")] - [InlineData("https://host/dvls\u00E9", "https://host/dvls%C3%A9")] - public void CreateWritesTheUrlInUriSyntax(string productBaseUrl, string expected) - { - EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); - - Assert.Equal(("u", expected), Assert.Single(BagMembers(token))); - } - // CONTRACT.md §2: a token is at most 4096 bytes. // A 3025-byte URL makes a 3033-byte bag object, a 4044-character bag and a 4095-byte token. // One more byte makes a 4046-character bag and a 4097-byte token, since no base64url text has 4045 characters. @@ -153,15 +139,6 @@ public void CreateAcceptsTheLongestUrlThatFits() Assert.Equal(4095, token.Value.Length); } - [Fact] - public void CreateRejectsAUrlThatMakesTheTokenTooLong() - { - ArgumentException exception = Assert.Throws( - () => EnrollmentToken.Create(UrlOfLength(3026), new ReplayingGenerator(SecretBytes))); - - Assert.Equal("productBaseUrl", exception.ParamName); - } - // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially, // and a log statement may format the token object. [Fact] @@ -174,8 +151,6 @@ public void ToStringRevealsNoPartOfTheToken() } // INTENT.agent-identity.md, security invariant 9: a host may log the exception of a failed creation. - public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls, UrlOfLength(3026)]); - [Theory] [MemberData(nameof(RejectedUrlRows))] public void CreateFailureRevealsNoPartOfTheSecret(string productBaseUrl) @@ -186,23 +161,22 @@ public void CreateFailureRevealsNoPartOfTheSecret(string productBaseUrl) EnrollmentTokenAssert.RevealsNoPart(exception.Message, SecretText); } - [Fact] - public void CreateRejectsANullUrl() + [Theory] + [InlineData("productBaseUrl")] + [InlineData("randomNumberGenerator")] + public void CreateRejectsANullArgument(string parameter) { - ArgumentNullException exception = Assert.Throws( - () => EnrollmentToken.Create(null!, new ReplayingGenerator(SecretBytes))); + ArgumentNullException exception = Assert.Throws(CreationsWithANullArgument[parameter]); - Assert.Equal("productBaseUrl", exception.ParamName); + Assert.Equal(parameter, exception.ParamName); } - [Fact] - public void CreateRejectsANullGenerator() + // A creation with one null argument, by the name of the parameter that receives it. + private static Dictionary CreationsWithANullArgument => new() { - ArgumentNullException exception = Assert.Throws( - () => EnrollmentToken.Create("https://host/dvls", null!)); - - Assert.Equal("randomNumberGenerator", exception.ParamName); - } + ["productBaseUrl"] = () => EnrollmentToken.Create(null!, new ReplayingGenerator(SecretBytes)), + ["randomNumberGenerator"] = () => EnrollmentToken.Create("https://host/dvls", null!), + }; private static string UrlOfLength(int length) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs index 80f7a860ae..1c7fbefe0f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs @@ -45,13 +45,7 @@ public class FriendlyNameFormatTests [InlineData("{agent_version}", "2026.3.0")] [InlineData("{machine_id}", "4c4c4544-0042-3510-8052-b4c04f4a4e32")] [InlineData("{token_name}", "Fleet")] - public void EvaluatesEachPlaceholderToItsValue(string format, string expected) - { - Assert.Equal(expected, Evaluate(format)); - } - // INTENT.agent-identity.md, "Enrollment": `{{` and `}}` escape braces, and other text is literal. - [Theory] [InlineData("{{", "{")] [InlineData("}}", "}")] [InlineData("}}{{", "}{")] @@ -59,7 +53,7 @@ public void EvaluatesEachPlaceholderToItsValue(string format, string expected) [InlineData("{{{hostname}}}", "{pc-042}")] [InlineData("Laptop {hostname} ({os_name}, {arch})", "Laptop pc-042 (Windows, x86_64)")] [InlineData("{token_name}/{hostname}{hostname}", "Fleet/pc-042pc-042")] - public void EvaluatesLiteralTextAndEscapedBraces(string format, string expected) + public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expected) { Assert.Equal(expected, Evaluate(format)); } @@ -128,19 +122,14 @@ public void TryParseErrorListsThePlaceholders(string format) // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially. // A format is administrator input that may hold a pasted token, and a host may log why a format was refused. // Each row holds the token in an unknown placeholder, or as literal text before each kind of error. - public static TheoryData FormatsHoldingAToken => new() - { - "{" + Token + "}", - "{hostname} " + Token + " {Hostname}", - "{hostname} " + Token + " {", - Token + "}", - }; - [Theory] - [MemberData(nameof(FormatsHoldingAToken))] - public void TryParseErrorRevealsNoPartOfTheFormat(string format) + [InlineData("in an unknown placeholder", "{" + Token + "}")] + [InlineData("before an unknown placeholder", "{hostname} " + Token + " {Hostname}")] + [InlineData("before an unpaired opening brace", "{hostname} " + Token + " {")] + [InlineData("before an unpaired closing brace", Token + "}")] + public void TryParseErrorRevealsNoPartOfTheFormat(string name, string format) { - Assert.False(FriendlyNameFormat.TryParse(format, out _, out string? error)); + Assert.False(FriendlyNameFormat.TryParse(format, out _, out string? error), name); EnrollmentTokenAssert.RevealsNoPart(error, Bag, Secret); } @@ -230,44 +219,33 @@ public void KeepsEveryOtherCodePointAtBothEnds() Assert.Empty(trimmed); } - // INTENT.agent-identity.md, "Enrollment": the result is truncated to 255 Unicode scalar values - // without splitting one. + // INTENT.agent-identity.md, "Enrollment": the result is trimmed, truncated to 255 Unicode scalar values + // without splitting one, and trimmed again, so a stored name never starts or ends with white space. // U+1F600 is one scalar value that UTF-16 writes as a surrogate pair, so a cut by UTF-16 code unit would split it, // and U+0301 COMBINING ACUTE ACCENT is a scalar value of its own. - public static TheoryData TruncationRows => new() + // Leading white space is trimmed before truncation, so it never takes the place of a kept scalar value. + private static Dictionary TokenNamesToTruncate => new() { - { new string('a', 255), new string('a', 255) }, - { new string('a', 256), new string('a', 255) }, - { new string('a', 254) + "\U0001F600b", new string('a', 254) + "\U0001F600" }, - { new string('a', 255) + "\U0001F600", new string('a', 255) }, - { Repeat("\U0001F600", 300), Repeat("\U0001F600", 255) }, - { Repeat("e\u0301", 200), Repeat("e\u0301", 127) + "e" }, + ["255 letters"] = (new string('a', 255), new string('a', 255)), + ["256 letters"] = (new string('a', 256), new string('a', 255)), + ["an emoji at the cut"] = (new string('a', 254) + "\U0001F600b", new string('a', 254) + "\U0001F600"), + ["an emoji after the cut"] = (new string('a', 255) + "\U0001F600", new string('a', 255)), + ["300 emoji"] = (Repeat("\U0001F600", 300), Repeat("\U0001F600", 255)), + ["200 accented letters"] = (Repeat("e\u0301", 200), Repeat("e\u0301", 127) + "e"), + ["white space at the cut"] = (new string('a', 250) + new string(' ', 10) + "bbbbb", new string('a', 250)), + ["white space before 255 letters"] = + ("\u3000" + new string(' ', 9) + new string('a', 255), new string('a', 255)), }; - [Theory] - [MemberData(nameof(TruncationRows))] - public void TruncatesTo255ScalarValues(string tokenName, string expected) - { - Assert.Equal(expected, Evaluate("{token_name}", tokenName: tokenName)); - } + public static TheoryData TokenNamesToTruncateNames => new(TokenNamesToTruncate.Keys); - // INTENT.agent-identity.md, "Enrollment": the result is trimmed, truncated and trimmed again, - // so a stored name never starts or ends with white space. - [Fact] - public void TrimsTheWhiteSpaceThatTruncationLeavesAtTheEnd() - { - string tokenName = new string('a', 250) + new string(' ', 10) + "bbbbb"; - - Assert.Equal(new string('a', 250), Evaluate("{token_name}", tokenName: tokenName)); - } - - // Leading white space is trimmed before truncation, so it never takes the place of a kept scalar value. - [Fact] - public void TrimsBeforeTruncating() + [Theory] + [MemberData(nameof(TokenNamesToTruncateNames))] + public void TruncatesTo255ScalarValuesBetweenTwoTrims(string tokenName) { - string tokenName = "\u3000" + new string(' ', 9) + new string('a', 255); + (string value, string expected) = TokenNamesToTruncate[tokenName]; - Assert.Equal(new string('a', 255), Evaluate("{token_name}", tokenName: tokenName)); + Assert.Equal(expected, Evaluate("{token_name}", tokenName: value)); } // INTENT.agent-identity.md, "Enrollment": an empty result falls back to the identity ID, @@ -317,23 +295,23 @@ public void TryParseRejectsANullFormat() Assert.Equal("format", exception.ParamName); } - [Fact] - public void EvaluateRejectsNullMetadata() + [Theory] + [InlineData("metadata")] + [InlineData("tokenName")] + public void EvaluateRejectsANullArgument(string parameter) { - ArgumentNullException exception = Assert.Throws( - () => FriendlyNameFormat.Default.Evaluate(null!, TokenName, IdentityId)); + ArgumentNullException exception = + Assert.Throws(EvaluationsWithANullArgument[parameter]); - Assert.Equal("metadata", exception.ParamName); + Assert.Equal(parameter, exception.ParamName); } - [Fact] - public void EvaluateRejectsANullTokenName() + // An evaluation with one null argument, by the name of the parameter that receives it. + private static Dictionary EvaluationsWithANullArgument => new() { - ArgumentNullException exception = Assert.Throws( - () => FriendlyNameFormat.Default.Evaluate(Metadata, null!, IdentityId)); - - Assert.Equal("tokenName", exception.ParamName); - } + ["metadata"] = () => FriendlyNameFormat.Default.Evaluate(null!, TokenName, IdentityId), + ["tokenName"] = () => FriendlyNameFormat.Default.Evaluate(Metadata, null!, IdentityId), + }; private static string Evaluate(string format, AgentMetadata? metadata = null, string tokenName = TokenName) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs index 0965e2a1fc..67bf80bb03 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs @@ -4,6 +4,17 @@ namespace Devolutions.AgentIdentity.Tests; public class AgentIdentityProtocolTests { + public static TheoryData VectorCertificateNames => new(VectorCertificates.Select(vector => vector.Name)); + + // Each certificate of the vector file, named by its path in the file. + private static IEnumerable<(string Name, byte[] Certificate, string Thumbprint)> VectorCertificates => + [ + .. TestVectors.Instance.Keys.Select(key => ($"keys[{key.Name}]", key.Certificate, key.Thumbprint)), + ("root", TestVectors.Instance.Root.Certificate, TestVectors.Instance.Root.Thumbprint), + .. TestVectors.Instance.HttpSignature.Registered.Select( + registered => ($"http_signature.registered[{registered.Name}]", registered.Certificate, registered.KeyId)), + ]; + // CONTRACT.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. // A timestamp names the whole second that contains the instant. [Theory] @@ -40,32 +51,15 @@ public void FormatTimestampIgnoresTheCurrentCulture() } // CONTRACT.md §1: a thumbprint is base64url(SHA-256(certificate DER)). - // The vector file gives the thumbprint of each certificate. - [Theory] - [MemberData(nameof(VectorData.KeyNames), MemberType = typeof(VectorData))] - public void KeyCertificateHasTheVectorThumbprint(string name) - { - KeyVector key = TestVectors.Instance.Key(name); - - Assert.Equal(key.Thumbprint, AgentIdentityProtocol.ComputeThumbprint(key.Certificate)); - } - - [Fact] - public void RootCertificateHasTheVectorThumbprint() - { - RootVector root = TestVectors.Instance.Root; - - Assert.Equal(root.Thumbprint, AgentIdentityProtocol.ComputeThumbprint(root.Certificate)); - } - // CONTRACT.md §6: a request's `keyid` is the thumbprint of the certificate that signs it. + // The vector file gives each certificate with its thumbprint, or a registered certificate with its keyid. [Theory] - [MemberData(nameof(VectorData.RegisteredCertificateNames), MemberType = typeof(VectorData))] - public void RegisteredCertificateHasTheVectorKeyId(string name) + [MemberData(nameof(VectorCertificateNames))] + public void CertificateHasTheVectorThumbprint(string name) { - RegisteredCertificateVector registered = TestVectors.Instance.HttpSignature.RegisteredCertificate(name); + (_, byte[] certificate, string thumbprint) = VectorCertificates.Single(vector => vector.Name == name); - Assert.Equal(registered.KeyId, AgentIdentityProtocol.ComputeThumbprint(registered.Certificate)); + Assert.Equal(thumbprint, AgentIdentityProtocol.ComputeThumbprint(certificate)); } // CONTRACT.md §5.4: servers accept request bodies up to 64 KiB. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs index e3b745962a..254ea49b02 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs @@ -1,154 +1,134 @@ +using System.Diagnostics.CodeAnalysis; using System.Text; namespace Devolutions.AgentIdentity.Tests; public class StrictBase64Tests { - // RFC 4648 §10 test vectors. - [Theory] - [InlineData("", "")] - [InlineData("Zg==", "f")] - [InlineData("Zm8=", "fo")] - [InlineData("Zm9v", "foo")] - [InlineData("Zm9vYg==", "foob")] - [InlineData("Zm9vYmE=", "fooba")] - [InlineData("Zm9vYmFy", "foobar")] - public void Base64DecodesRfc4648Vector(string encoded, string expected) - { - Assert.True(StrictBase64.TryDecodeBase64(encoded, out byte[]? bytes)); - Assert.Equal(Encoding.ASCII.GetBytes(expected), bytes); - } + // RFC 4648 Table 1 and Table 2. + private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - // Each row is a form that RFC 4648 §4 does not produce for any byte string, - // so accepting it would give one value two encodings. - [Theory] - [InlineData("missing padding", "Zg")] - [InlineData("missing one padding character", "Zm9vYg=")] - [InlineData("padding after a complete quantum", "Zm9v====")] - [InlineData("padding inside the text", "Zg==Zm9v")] - [InlineData("padding only", "====")] - [InlineData("line break between quanta", "Zm9v\r\nYmFy")] - [InlineData("trailing space", "Zm9vYmFy ")] - [InlineData("non-zero pad bits after one byte", "Zh==")] - [InlineData("non-zero pad bits after two bytes", "Zm9=")] - [InlineData("base64url alphabet", "-_8=")] - [InlineData("character outside ASCII whose low byte is 'v'", "Zm9\u0176")] - public void Base64RejectsNonCanonicalForm(string form, string encoded) + // CONTRACT.md: base64 is RFC 4648 §4 with padding, and base64url is RFC 4648 §5 without padding. + private static readonly Dictionary Codecs = new() { - Assert.False(StrictBase64.TryDecodeBase64(encoded, out _), form); - } + ["base64"] = new(StrictBase64.TryDecodeBase64, Base64Alphabet, "==", "=", base64 => base64), + ["base64url"] = new(StrictBase64.TryDecodeBase64Url, Base64UrlAlphabet, "", "", ToBase64Url), + }; - // RFC 4648 §10 test vectors without padding, which RFC 4648 §3.2 lets an encoding omit. - [Theory] - [InlineData("", "")] - [InlineData("Zg", "f")] - [InlineData("Zm8", "fo")] - [InlineData("Zm9v", "foo")] - [InlineData("Zm9vYg", "foob")] - [InlineData("Zm9vYmE", "fooba")] - [InlineData("Zm9vYmFy", "foobar")] - public void Base64UrlDecodesRfc4648Vector(string encoded, string expected) - { - Assert.True(StrictBase64.TryDecodeBase64Url(encoded, out byte[]? bytes)); - Assert.Equal(Encoding.ASCII.GetBytes(expected), bytes); - } + private delegate bool Decoder(ReadOnlySpan text, [NotNullWhen(true)] out byte[]? bytes, int? exactLength); - // The sextets 62, 63 and 60 carry the bytes FB FF and two zero pad bits; + // RFC 4648 §10 test vectors, which base64url writes without padding, as RFC 4648 §3.2 lets an encoding do. + // In the last row of each encoding, the sextets 62, 63 and 60 carry the bytes FB FF and two zero pad bits; // RFC 4648 Table 1 writes them "+/8" and Table 2 writes them "-_8". - [Fact] - public void Base64DecodesTheLastTwoAlphabetCharacters() - { - Assert.True(StrictBase64.TryDecodeBase64("+/8=", out byte[]? bytes)); - Assert.Equal([0xFB, 0xFF], bytes); - } + public static TheoryData CanonicalForms => new() + { + { "base64", "", Ascii("") }, + { "base64", "Zg==", Ascii("f") }, + { "base64", "Zm8=", Ascii("fo") }, + { "base64", "Zm9v", Ascii("foo") }, + { "base64", "Zm9vYg==", Ascii("foob") }, + { "base64", "Zm9vYmE=", Ascii("fooba") }, + { "base64", "Zm9vYmFy", Ascii("foobar") }, + { "base64", "+/8=", [0xFB, 0xFF] }, + { "base64url", "", Ascii("") }, + { "base64url", "Zg", Ascii("f") }, + { "base64url", "Zm8", Ascii("fo") }, + { "base64url", "Zm9v", Ascii("foo") }, + { "base64url", "Zm9vYg", Ascii("foob") }, + { "base64url", "Zm9vYmE", Ascii("fooba") }, + { "base64url", "Zm9vYmFy", Ascii("foobar") }, + { "base64url", "-_8", [0xFB, 0xFF] }, + }; + + // The base64url rows of CanonicalForms, which the encoder writes from their bytes. + public static TheoryData Base64UrlForms => new() + { + { "", Ascii("") }, + { "Zg", Ascii("f") }, + { "Zm8", Ascii("fo") }, + { "Zm9v", Ascii("foo") }, + { "Zm9vYg", Ascii("foob") }, + { "Zm9vYmE", Ascii("fooba") }, + { "Zm9vYmFy", Ascii("foobar") }, + { "-_8", [0xFB, 0xFF] }, + }; - [Fact] - public void Base64UrlDecodesTheLastTwoAlphabetCharacters() - { - Assert.True(StrictBase64.TryDecodeBase64Url("-_8", out byte[]? bytes)); - Assert.Equal([0xFB, 0xFF], bytes); - } - - // Each row is a form that RFC 4648 §5 without padding does not produce for any byte string. [Theory] - [InlineData("padding after one byte", "Zg==")] - [InlineData("padding after two bytes", "Zm8=")] - [InlineData("impossible length", "Zm9vY")] - [InlineData("line break between quanta", "Zm9v\r\nYmFy")] - [InlineData("trailing space", "Zm9vYmFy ")] - [InlineData("non-zero pad bits after one byte", "Zh")] - [InlineData("non-zero pad bits after two bytes", "Zm9")] - [InlineData("base64 alphabet", "+/8")] - [InlineData("character outside ASCII whose low byte is 'v'", "Zm9\u0176")] - public void Base64UrlRejectsNonCanonicalForm(string form, string encoded) + [MemberData(nameof(CanonicalForms))] + public void DecodesTheCanonicalForm(string encoding, string encoded, byte[] expected) { - Assert.False(StrictBase64.TryDecodeBase64Url(encoded, out _), form); - } - - // RFC 4648 §10 vectors with the number of bytes they encode; padding characters encode none. - [Theory] - [InlineData("", 0)] - [InlineData("Zg==", 1)] - [InlineData("Zm8=", 2)] - [InlineData("Zm9v", 3)] - public void Base64AcceptsTheExactDecodedLength(string encoded, int length) - { - Assert.True(StrictBase64.TryDecodeBase64(encoded, out byte[]? bytes, exactLength: length)); - Assert.Equal(length, bytes.Length); + Assert.True(Codecs[encoding].TryDecode(encoded, out byte[]? bytes, null)); + Assert.Equal(expected, bytes); } + // Each row is a form that RFC 4648 §4, or §5 without padding, does not produce for any byte string, + // so accepting it would give one value two encodings. [Theory] - [InlineData("", 1)] - [InlineData("Zg==", 0)] - [InlineData("Zg==", 3)] - [InlineData("Zm8=", 3)] - [InlineData("Zm9v", 2)] - [InlineData("Zm9v", 4)] - public void Base64RejectsAnotherDecodedLength(string encoded, int length) - { - Assert.False(StrictBase64.TryDecodeBase64(encoded, out _, exactLength: length)); + [InlineData("base64", "missing padding", "Zg")] + [InlineData("base64", "missing one padding character", "Zm9vYg=")] + [InlineData("base64", "padding after a complete quantum", "Zm9v====")] + [InlineData("base64", "padding inside the text", "Zg==Zm9v")] + [InlineData("base64", "padding only", "====")] + [InlineData("base64", "line break between quanta", "Zm9v\r\nYmFy")] + [InlineData("base64", "trailing space", "Zm9vYmFy ")] + [InlineData("base64", "non-zero pad bits after one byte", "Zh==")] + [InlineData("base64", "non-zero pad bits after two bytes", "Zm9=")] + [InlineData("base64", "base64url alphabet", "-_8=")] + [InlineData("base64", "character outside ASCII whose low byte is 'v'", "Zm9\u0176")] + [InlineData("base64url", "padding after one byte", "Zg==")] + [InlineData("base64url", "padding after two bytes", "Zm8=")] + [InlineData("base64url", "impossible length", "Zm9vY")] + [InlineData("base64url", "line break between quanta", "Zm9v\r\nYmFy")] + [InlineData("base64url", "trailing space", "Zm9vYmFy ")] + [InlineData("base64url", "non-zero pad bits after one byte", "Zh")] + [InlineData("base64url", "non-zero pad bits after two bytes", "Zm9")] + [InlineData("base64url", "base64 alphabet", "+/8")] + [InlineData("base64url", "character outside ASCII whose low byte is 'v'", "Zm9\u0176")] + public void RejectsANonCanonicalForm(string encoding, string form, string encoded) + { + Assert.False(Codecs[encoding].TryDecode(encoded, out _, null), form); } + // RFC 4648 §10 vectors with the number of bytes they encode; padding characters encode none. [Theory] - [InlineData("", 0)] - [InlineData("Zg", 1)] - [InlineData("Zm8", 2)] - [InlineData("Zm9v", 3)] - public void Base64UrlAcceptsTheExactDecodedLength(string encoded, int length) - { - Assert.True(StrictBase64.TryDecodeBase64Url(encoded, out byte[]? bytes, exactLength: length)); + [InlineData("base64", "", 0)] + [InlineData("base64", "Zg==", 1)] + [InlineData("base64", "Zm8=", 2)] + [InlineData("base64", "Zm9v", 3)] + [InlineData("base64url", "", 0)] + [InlineData("base64url", "Zg", 1)] + [InlineData("base64url", "Zm8", 2)] + [InlineData("base64url", "Zm9v", 3)] + public void AcceptsTheExactDecodedLength(string encoding, string encoded, int length) + { + Assert.True(Codecs[encoding].TryDecode(encoded, out byte[]? bytes, length)); Assert.Equal(length, bytes.Length); } [Theory] - [InlineData("", 1)] - [InlineData("Zg", 0)] - [InlineData("Zg", 2)] - [InlineData("Zm8", 3)] - [InlineData("Zm9v", 2)] - [InlineData("Zm9v", 4)] - public void Base64UrlRejectsAnotherDecodedLength(string encoded, int length) - { - Assert.False(StrictBase64.TryDecodeBase64Url(encoded, out _, exactLength: length)); + [InlineData("base64", "", 1)] + [InlineData("base64", "Zg==", 0)] + [InlineData("base64", "Zg==", 3)] + [InlineData("base64", "Zm8=", 3)] + [InlineData("base64", "Zm9v", 2)] + [InlineData("base64", "Zm9v", 4)] + [InlineData("base64url", "", 1)] + [InlineData("base64url", "Zg", 0)] + [InlineData("base64url", "Zg", 2)] + [InlineData("base64url", "Zm8", 3)] + [InlineData("base64url", "Zm9v", 2)] + [InlineData("base64url", "Zm9v", 4)] + public void RejectsAnotherDecodedLength(string encoding, string encoded, int length) + { + Assert.False(Codecs[encoding].TryDecode(encoded, out _, length)); } [Theory] - [InlineData("", "")] - [InlineData("f", "Zg")] - [InlineData("fo", "Zm8")] - [InlineData("foo", "Zm9v")] - [InlineData("foob", "Zm9vYg")] - [InlineData("fooba", "Zm9vYmE")] - [InlineData("foobar", "Zm9vYmFy")] - public void Base64UrlEncodesRfc4648VectorWithoutPadding(string decoded, string expected) - { - Assert.Equal(expected, StrictBase64.EncodeBase64Url(Encoding.ASCII.GetBytes(decoded))); - } - - [Fact] - public void Base64UrlEncodesTheLastTwoAlphabetCharacters() + [MemberData(nameof(Base64UrlForms))] + public void Base64UrlEncodesWithoutPadding(string expected, byte[] value) { - Assert.Equal("-_8", StrictBase64.EncodeBase64Url([0xFB, 0xFF])); + Assert.Equal(expected, StrictBase64.EncodeBase64Url(value)); } // The BCL encoder writes canonical base64, @@ -176,33 +156,27 @@ public void DecodesAndEncodesEveryLengthLikeTheBcl() // Only the final quantum has pad bits, // so trying every final quantum of one or two bytes checks that each value has exactly one accepted encoding. - [Fact] - public void Base64AcceptsOneEncodingOfEachOneOrTwoByteValue() + [Theory] + [InlineData("base64")] + [InlineData("base64url")] + public void AcceptsOneEncodingOfEachOneOrTwoByteValue(string encoding) { - IEnumerable finalQuanta = Strings(Base64Alphabet, 2).Select(s => s + "==") - .Concat(Strings(Base64Alphabet, 3).Select(s => s + "=")); - IEnumerable canonical = OneOrTwoByteValues().Select(Convert.ToBase64String); + Codec codec = Codecs[encoding]; + IEnumerable finalQuanta = Strings(codec.Alphabet, 2).Select(s => s + codec.OneBytePadding) + .Concat(Strings(codec.Alphabet, 3).Select(s => s + codec.TwoBytePadding)); + IEnumerable canonical = OneOrTwoByteValues() + .Select(value => codec.FromBase64(Convert.ToBase64String(value))); - IEnumerable accepted = finalQuanta.Where(s => StrictBase64.TryDecodeBase64(s, out _)); + IEnumerable accepted = finalQuanta.Where(s => codec.TryDecode(s, out _, null)); Assert.Equal(canonical.Order(StringComparer.Ordinal), accepted.Order(StringComparer.Ordinal)); } - [Fact] - public void Base64UrlAcceptsOneEncodingOfEachOneOrTwoByteValue() + private static byte[] Ascii(string text) { - IEnumerable finalQuanta = Strings(Base64UrlAlphabet, 2).Concat(Strings(Base64UrlAlphabet, 3)); - IEnumerable canonical = OneOrTwoByteValues().Select(value => ToBase64Url(Convert.ToBase64String(value))); - - IEnumerable accepted = finalQuanta.Where(s => StrictBase64.TryDecodeBase64Url(s, out _)); - - Assert.Equal(canonical.Order(StringComparer.Ordinal), accepted.Order(StringComparer.Ordinal)); + return Encoding.ASCII.GetBytes(text); } - // RFC 4648 Table 1 and Table 2. - private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - private static string ToBase64Url(string base64) { return base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); @@ -220,4 +194,15 @@ private static IEnumerable OneOrTwoByteValues() return Enumerable.Range(0, 1 << 8).Select(v => new[] { (byte)v }) .Concat(Enumerable.Range(0, 1 << 16).Select(v => new[] { (byte)(v >> 8), (byte)v })); } -} + + /// + /// An encoding with its decoder under test, its alphabet, and the padding of a final quantum. + /// + /// Converts canonical base64 to the canonical form of this encoding. + private sealed record Codec( + Decoder TryDecode, + string Alphabet, + string OneBytePadding, + string TwoBytePadding, + Func FromBase64); +} \ No newline at end of file From 45eb76dc4089b18f1d4df7b115434eaa3b59437c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 06:30:45 +0900 Subject: [PATCH 47/93] test(dotnet-agent-identity): strengthen tests and cover untested rules Add tests for behaviour that no test pinned, each expected value taken from CONTRACT.md, RFC 8941, SEC 1 or the documented API rather than from the library's output: - malformed Signature-Input syntax (a truncated parameter, an unclosed string, a non-list member) returns signature_invalid rather than throwing or looping; - a covered component carrying a parameter fails step 1 even when the signature covers the stripped list; - a signature lifetime of exactly 300 s is accepted; - the RFC 8941 parser and serializer on their own; - a CSR point with a coordinate outside the field is invalid_request rather than a platform import exception; - snake_case boundaries, metadata membership and enumeration, and a deeply nested config in a response body; - every error carries a non-empty message. Replace the one expected value that came from the code under test: the enrollment token's secret hash is now the SHA-256 of the secret's 43 ASCII characters, computed in the test. Cite the source of each test's expected value where none was given, and mark as self-derived the assertions that no independent source can fix but that alone catch a fault. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/AuthenticationTypeTests.cs | 3 + .../RegisteredCertificateTests.cs | 2 + .../RequestAuthenticatorProfileTests.cs | 95 ++++++++++++ .../RequestAuthenticatorTests.cs | 83 +++++++++++ .../RequestAuthenticatorVectorTests.cs | 2 + .../Authentication/StructuredFieldTests.cs | 135 ++++++++++++++++++ .../Bodies/AgentMetadataTests.cs | 35 +++++ .../Bodies/IdentityConfigTests.cs | 5 + .../Bodies/RequestBodyTests.cs | 8 ++ .../Bodies/ResponseBodiesTests.cs | 20 +++ .../CertificateSigningRequestTests.cs | 77 +++++++++- .../IdentityCertificateIssuerTests.cs | 27 +++- .../Channel/ChannelHandshakeTests.cs | 25 ++++ .../Channel/ChannelMessagesTests.cs | 1 + .../Channel/ChannelOpeningTests.cs | 2 + .../ConstructionTests.cs | 2 + .../EnrollmentAuthorizationTests.cs | 2 + .../Enrollment/EnrollmentRulesTests.cs | 4 + .../Enrollment/EnrollmentTokenTests.cs | 10 +- .../Enrollment/FriendlyNameFormatTests.cs | 5 + .../Errors/AgentIdentityErrorCodeTests.cs | 2 + .../Errors/AgentIdentityErrorTests.cs | 38 ++++- .../Protocol/AgentIdentityProtocolTests.cs | 1 + .../Protocol/StrictBase64Tests.cs | 6 +- .../Certificates/CertificateSigningRequest.cs | 5 - 25 files changed, 578 insertions(+), 17 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs index e2b2dcda5b..f2d7e1f45c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs @@ -22,8 +22,11 @@ public class AuthenticationTypeTests public static TheoryData AuthenticationTypeData => new(AuthenticationTypes); + // INTENT.md: code that acts for an identity takes it as a type only the signature check can create. // A public or protected constructor would let a caller build an identity from any values, // or a subclass of one with its own constructor. + // The other types carry an identity, or the opening that becomes one, out of the check. + // oracle: self-derived beyond AuthenticatedIdentity; sole guard for a public AuthenticationResult constructor. [Theory] [MemberData(nameof(AuthenticationTypeData))] public void TypeHasNoPublicOrProtectedConstructor(Type type) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs index b5d7ba529d..8394b5562d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs @@ -5,6 +5,8 @@ public class RegisteredCertificateTests // An undefined status from the host's storage must fail as a host bug, // rather than draw identity_unknown, which ends the identity for the agent. // The default is the value of an uninitialized status, and 4 follows the last of the three statuses. + // CONTRACT.md Definitions: a certificate is pending, current or retired; §5.4: identity_unknown is terminal. + // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. [Theory] [InlineData(default(CertificateStatus))] [InlineData((CertificateStatus)4)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs index c34cd91f2d..72982fbf03 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs @@ -17,6 +17,9 @@ public class RequestAuthenticatorProfileTests // The 16 bytes "0123456789:;<=>?", whose base64url holds a '-'. private const string NonceWithDash = "MDEyMzQ1Njc4OTo7PD0-Pw"; + // The tag of a renew request, as Signature-Input writes it. + private const string TagParameter = ";tag=\"renew\""; + private static readonly DateTimeOffset Now = TestAuthority.DefaultNow; private static readonly Dictionary> StepOneViolations = new() @@ -33,6 +36,13 @@ public class RequestAuthenticatorProfileTests RenewWithSignatureInput(signed => [$"sig={signed}", $"sig={signed}"]), ["Signature-Input with a second label in a second field line"] = () => RenewWithSignatureInput(signed => [$"sig={signed}", $"sig2={signed}"]), + + // RFC 8941 §4.2 joins field lines with commas, and §4.2.2 fails a dictionary that ends in one. + ["Signature-Input with an empty second field line"] = () => + RenewWithSignatureInput(signed => [$"sig={signed}", ""]), + ["Signature-Input ending in a comma"] = () => + RenewWithSignatureInput(signed => [$"sig={signed},"]), + ["Signature repeating sig in one field line"] = () => RenewWithSignature(signature => [$"sig=:{signature}:, sig=:{signature}:"]), ["Signature repeating sig across two field lines"] = () => @@ -79,6 +89,12 @@ public class RequestAuthenticatorProfileTests ["created as a string"] = () => RenewWith("created", Quoted(Created)), ["created as a decimal"] = () => RenewWith("created", $"{Created}.0"), + + // RFC 8941 §3.3.1 allows at most 15 digits, leading zeros included, + // and RFC 9421 §2.3 signs the integer as RFC 8941 §4.1.4 serializes it, without them. + ["created of 16 digits"] = () => + RenewWithSignatureInput(signed => [$"sig={signed.Replace(CreatedParameter, $";created=000000{Created}")}"]), + ["expires as a string"] = () => RenewWith("expires", Quoted(Expires)), ["expires as a decimal"] = () => RenewWith("expires", $"{Expires}.0"), ["alg as a token"] = () => RenewWith("alg", "ecdsa-p256-sha256"), @@ -91,6 +107,11 @@ public class RequestAuthenticatorProfileTests ["nonce as a token"] = () => RenewWith("nonce", TestRequest.DefaultNonce), ["tag as a token"] = () => RenewWith("tag", "renew"), + // RFC 8941 §4.2.5 escapes only a quote and a backslash, + // so a lenient parser reads \n as n, and the tag as renew. + ["tag with an escape RFC 8941 does not define"] = () => + RenewWithSignatureInput(signed => [$"sig={signed.Replace(TagParameter, @";tag=""re\new""")}"]), + // No integer is a valid alg, keyid, nonce or tag, so these also break a rule on the value. ["alg as an integer"] = () => RenewWith("alg", "256"), ["keyid as an integer"] = () => RenewWith("keyid", "1"), @@ -100,6 +121,10 @@ public class RequestAuthenticatorProfileTests // RFC 9421 §2.1.1 defines sf, which leaves this Content-Digest value unchanged in the base. ["a parameter on a covered component"] = () => (Renew() with { Components = ["\"@method\"", "\"content-digest\";sf"] }).ToHttpRequest(), + + // A verifier that drops component parameters builds the base without them, which is the one signed here. + ["a parameter on a covered component, not signed"] = () => + RenewWithSignatureInput(signed => [$"sig={signed.Replace("\"@method\"", "\"@method\";x")}"]), }; private static readonly Dictionary> ContentDigestViolations = new() @@ -149,6 +174,30 @@ public class RequestAuthenticatorProfileTests signature: [$"\t sig=:{request.Sign()}: \t"], contentDigest: [$" {request.ContentDigest}\t "]); }, + + // RFC 8941 §3.3.1 allows 15 digits, leading zeros included, + // and RFC 9421 §2.3 signs the integer as RFC 8941 §4.1.4 serializes it, without them. + ["created with leading zeros, in 15 digits"] = () => + RenewWithSignatureInput(signed => [$"sig={signed.Replace(CreatedParameter, $";created=00000{Created}")}"]), + }; + + // Signature-Input values that hold no inner list with its parameters; + // most break RFC 8941 at their very end, where a parser that read on would leave the input. + private static readonly Dictionary> MalformedSignatureInputs = new() + { + // RFC 9421 §3.2: the member is an inner list. + ["an integer member"] = () => RenewWithSignatureInput(_ => ["sig=1"]), + + // RFC 8941 §4.2.3.2: a parameter has a key, and a key follows its equals sign with a bare item. + ["a semicolon at the end"] = () => RenewWithSignatureInput(signed => [$"sig={signed};"]), + ["created last, without a value"] = () => RenewWithLast("created", ""), + + // RFC 8941 §4.2.4: a minus sign is followed by a digit. + ["created last, a minus sign alone"] = () => RenewWithLast("created", "-"), + ["created last, a minus sign before a letter"] = () => RenewWithLast("created", "-x"), + + // RFC 8941 §4.2.5: a backslash is followed by the character it escapes, and a closing quote ends a string. + ["tag last, unclosed, ending in a backslash"] = () => RenewWithLast("tag", "\"renew\\"), }; public static TheoryData StepOneViolationNames => new(StepOneViolations.Keys); @@ -173,6 +222,8 @@ public static TheoryData ContentDigestViolationCases public static TheoryData AcceptedFormNames => new(AcceptedForms.Keys); + public static TheoryData MalformedSignatureInputNames => new(MalformedSignatureInputs.Keys); + private static KeyVector DeviceA => TestVectors.Instance.Key("device-a"); private static KeyVector DeviceC => TestVectors.Instance.Key("device-c"); @@ -181,6 +232,9 @@ public static TheoryData ContentDigestViolationCases private static string Expires => (Now.ToUnixTimeSeconds() + 60).ToString(CultureInfo.InvariantCulture); + // The created parameter of a renew request, as Signature-Input writes it. + private static string CreatedParameter => $";created={Created}"; + // A verifier that keeps the last of repeated keys, decodes leniently, or reads a parameter of another type // accepts a request whose signature data a proxy, a log or another parser can read differently. [Theory] @@ -223,6 +277,36 @@ public async Task RequestInAFormThatRfc8941AllowsIsAccepted(string form) Assert.Equal("ok", outcome); } + // CONTRACT.md §6 step 1: a malformed Signature-Input draws signature_invalid, + // as a result rather than an exception, and records no nonce. + [Theory] + [MemberData(nameof(MalformedSignatureInputNames))] + public async Task MalformedSignatureInputIsSignatureInvalid(string form) + { + TestAuthority authority = new(Now); + + string outcome = await authority.OutcomeAsync(AgentOperation.Renew, MalformedSignatureInputs[form]()); + + Assert.Equal("signature_invalid", outcome); + Assert.Empty(authority.Nonces.Calls); + } + + // RFC 8941 §4.2.5: an escaped backslash decodes to one backslash, so the received tag is re\new, + // which CONTRACT.md §6 step 2 rejects for renew. + // The signature covers the tag renew, which a parser that dropped the escaped character would read. + [Fact] + public async Task TagWithAnEscapedBackslashIsAnotherTag() + { + TestAuthority authority = new(Now); + SignedHttpRequest request = + RenewWithSignatureInput(signed => [$"sig={signed.Replace(TagParameter, @";tag=""re\\new""")}"]); + + string outcome = await authority.OutcomeAsync(AgentOperation.Renew, request); + + Assert.Equal("signature_invalid", outcome); + Assert.Empty(authority.Nonces.Calls); + } + private static TestRequest Renew() { return TestRequest.Valid(AgentOperation.Renew, Now); @@ -233,6 +317,17 @@ private static SignedHttpRequest RenewWith(string parameter, string value) return Renew().WithParameter(parameter, value).ToHttpRequest(); } + // The parameter moves to the end of Signature-Input, with the given serialized value. + private static SignedHttpRequest RenewWithLast(string parameter, string value) + { + TestRequest request = Renew(); + TestRequest reordered = request with + { + Parameters = [.. request.Parameters.Where(p => p.Key != parameter), (parameter, value)], + }; + return reordered.ToHttpRequest(); + } + // The lines receive the signed value of @signature-params. private static SignedHttpRequest RenewWithSignatureInput(Func> lines) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs index 5e932f7c6a..68d9dcb317 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -179,6 +180,7 @@ public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); } + // CONTRACT.md §6 step 4: a certificate that is neither current nor pending draws identity_unknown. [Theory] [MemberData(nameof(Operations))] public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) @@ -240,6 +242,7 @@ public async Task RenewGraceIsTheCertificateLifetimeAfterItsNotAfter( Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); } + // CONTRACT.md §6 states the window of step 3 and the certificate rule of step 4 against the same `now`. // A second reading of the clock between the window and the certificate check // would judge the certificate a day later, after it expired. [Fact] @@ -251,6 +254,50 @@ public async Task WindowAndCertificateAreJudgedAtOneReadingOfTheClock() Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm))); } + // CONTRACT.md §6 step 3: 0 < expires − created ≤ 300. + [Fact] + public async Task SignatureLifetimeOf300SecondsIsAccepted() + { + TestAuthority authority = new(Now); + string expires = (Now.ToUnixTimeSeconds() + 300).ToString(CultureInfo.InvariantCulture); + + string outcome = await authority.OutcomeAsync( + authority.Valid(AgentOperation.Confirm).WithParameter("expires", expires)); + + Assert.Equal("ok", outcome); + } + + // RFC 8941 §3.3.1: an integer may be negative, and RFC 9421 §2.3 serializes it with its sign. + // At the Unix epoch, all three inequalities of CONTRACT.md §6 step 3 hold, + // and the certificate of device-a, whose notBefore is later, is accepted because §6 does not check notBefore. + [Fact] + public async Task CreatedBeforeTheUnixEpochIsAccepted() + { + TestAuthority authority = new(DateTimeOffset.UnixEpoch); + TestRequest request = authority.Valid(AgentOperation.Confirm) + .WithParameter("created", "-1") + .WithParameter("expires", "59"); + + Assert.Equal("ok", await authority.OutcomeAsync(request)); + } + + // CONTRACT.md §6: the earliest failing step decides. + // RFC 8941 §3.3.5 allows a byte sequence of no bytes, in its canonical base64 of no characters, + // so the signature passes step 1 and its length fails only step 5, after the window of step 3. + [Fact] + public async Task SignatureOfNoBytesIsJudgedAfterTheWindow() + { + TestAuthority authority = new(Now); + string expires = (Now.ToUnixTimeSeconds() + 301).ToString(CultureInfo.InvariantCulture); + TestRequest request = authority.Valid(AgentOperation.Confirm).WithParameter("expires", expires); + + string outcome = await authority.OutcomeAsync( + AgentOperation.Confirm, + request.ToHttpRequest(signature: ["sig=::"])); + + Assert.Equal("clock_skew", outcome); + } + // CONTRACT.md §6 step 6: a pair the store already holds fails, whichever request recorded it. [Theory] [MemberData(nameof(Operations))] @@ -289,8 +336,42 @@ public async Task MethodThatIsNotATokenIsSignatureInvalid(string method) Assert.Equal("signature_invalid", outcome); } + // A method that is not a token gives @method no value, so no signature base exists to verify, + // not even an empty one: RFC 9421 §2.5 ends every base with its @signature-params line. + [Fact] + public async Task MethodThatIsNotATokenCannotVerifyASignatureOverNoBytes() + { + TestAuthority authority = new(Now); + TestRequest request = authority.Valid(AgentOperation.Confirm) with { Method = "" }; + using ECDsa key = ECDsa.Create(); + key.ImportPkcs8PrivateKey(request.Key.PrivateKeyPkcs8, out _); + byte[] signature = key.SignData( + [], + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + string outcome = await authority.OutcomeAsync( + AgentOperation.Confirm, + request.ToHttpRequest(signature: [$"sig=:{Convert.ToBase64String(signature)}:"])); + + Assert.Equal("signature_invalid", outcome); + } + + // RFC 9110 §5.6.2: a token may hold any of these punctuation characters, besides letters and digits. + [Fact] + public async Task MethodWithEveryTokenPunctuationCharacterIsAccepted() + { + TestAuthority authority = new(Now); + + string outcome = await authority.OutcomeAsync( + authority.Valid(AgentOperation.Confirm) with { Method = "A!#$%&'*+-.^_`|~" }); + + Assert.Equal("ok", outcome); + } + // The shared intent, security invariant 7: signed opening headers never authenticate a stream on their own. // An uninitialized operation names no endpoint, so it authenticates nothing either. + // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. [Theory] [InlineData(AgentOperation.Connect)] [InlineData((AgentOperation)0)] @@ -307,6 +388,7 @@ await Assert.ThrowsAsync( // The registry answers by thumbprint, so another certificate is a host bug, // which must fail as a server error rather than draw an outcome the agent acts on. + // CONTRACT.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. [Fact] public async Task RegistryReturningACertificateWithAnotherThumbprintThrows() { @@ -321,6 +403,7 @@ await Assert.ThrowsAsync( } // CONTRACT.md §4: the authority issues P-256 leaves only, so any other registered bytes are a host bug. + // CONTRACT.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. // The keyid is the thumbprint of those bytes, so the registry answers with what it was asked for. [Theory] [InlineData("a P-384 certificate")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs index d07062a741..f8d4e05336 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs @@ -35,6 +35,7 @@ public async Task CaseReturnsItsExpectedOutcome(string name) Assert.Equal(vector.Expected, outcome.Outcome); } + // test-vectors.json http_signature.sequences, with CONTRACT.md §6 step 6. // A replayed nonce must fail, a nonce must survive a request that failed verification, // and a nonce accepted for one operation must not authenticate another. [Theory] @@ -53,6 +54,7 @@ public async Task SequenceReturnsItsExpectedOutcomes(string name) Assert.Equal(sequence.Expected, outcomes); } + // test-vectors.json signature_base of each case, as RFC 9421 §2.5 builds it. // A signature base that differs from the signed one in any byte fails verification for every agent, // and an accepted request does not show which byte the verifier would get wrong in another request. [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs new file mode 100644 index 0000000000..1f99292684 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs @@ -0,0 +1,135 @@ +using System.Globalization; + +using static Devolutions.AgentIdentity.StructuredField; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The RFC 8941 parser and serializer behind the signature fields. +/// +/// +/// The signature profile rejects most of these forms whatever they parse to, +/// so a request cannot show that the parser reads them as RFC 8941 does. +/// +public class StructuredFieldTests +{ + // Each expected value names the RFC 8941 type of each value, + // so a value of another type, or a success without a value, fails. + [Theory] + // RFC 8941 §4.2 step 2: leading spaces are discarded. + [InlineData(" sig=1", "sig=Integer(1)")] + // RFC 8941 §4.2.2: a comma with optional white space around it separates members. + [InlineData("sig=1, other=2", "sig=Integer(1), other=Integer(2)")] + [InlineData("sig=1 , other=2", "sig=Integer(1), other=Integer(2)")] + [InlineData("sig=1\t,\tother=2", "sig=Integer(1), other=Integer(2)")] + // RFC 8941 §4.2.2 and §4.2.3.2: a member or a parameter without a value is the Boolean true. + [InlineData("sig", "sig=Boolean(True)")] + [InlineData("sig=1;x", "sig=Integer(1);x=Boolean(True)")] + // RFC 8941 §4.2.3.3: a key may start with an asterisk. + [InlineData("*=1", "*=Integer(1)")] + // RFC 8941 §4.2.4: a decimal has at most 12 integer digits and one to three fraction digits. + [InlineData("sig=1.23", "sig=Decimal(1.23)")] + [InlineData("sig=-1.23", "sig=Decimal(-1.23)")] + [InlineData("sig=1.234", "sig=Decimal(1.234)")] + [InlineData("abcdef=1.0", "abcdef=Decimal(1.0)")] + [InlineData("sig=123456789012.123", "sig=Decimal(123456789012.123)")] + // RFC 8941 §4.2.5: a string holds printable ASCII, and a backslash escapes a quote or a backslash. + [InlineData("sig=\"x y\"", "sig=String(x y)")] + [InlineData("sig=\"x~y\"", "sig=String(x~y)")] + [InlineData("sig=\"x\\\"y\"", "sig=String(x\"y)")] + [InlineData("sig=\"x\\\\y\"", "sig=String(x\\y)")] + // RFC 8941 §4.2.6: a token starts with a letter or an asterisk, and may hold a colon or a slash. + [InlineData("sig=abc", "sig=Token(abc)")] + [InlineData("sig=*abc", "sig=Token(*abc)")] + [InlineData("sig=ab:/c", "sig=Token(ab:/c)")] + // RFC 8941 §4.2.8. + [InlineData("sig=?1", "sig=Boolean(True)")] + [InlineData("sig=?0", "sig=Boolean(False)")] + public void ParsesADictionary(string input, string expected) + { + Assert.True(TryParseDictionary(input, out IReadOnlyList? dictionary)); + Assert.Equal(expected, Describe(dictionary)); + } + + [Theory] + // RFC 8941 §4.2 step 1: a field value is ASCII. + [InlineData("sig\u0080=1")] + // RFC 8941 §4.2.3.3: a key starts with a lowercase letter or an asterisk. + [InlineData("Sig=1")] + // RFC 8941 §4.2.3.1: an item starts with the first character of a bare item. + [InlineData("sig=")] + [InlineData("sig=@")] + [InlineData("sig=,x=1")] + // RFC 8941 §4.2.3.2: a parameter has a key. + [InlineData("sig;")] + [InlineData("sig=1;")] + [InlineData("sig=(\"@method\");")] + // CONTRACT.md §6 step 1 rejects a repeated key, of which RFC 8941 §4.2.2 would keep the last. + [InlineData("sig=1, sig=2")] + // RFC 8941 §4.2.2: a comma separates members, and none follows the last. + [InlineData("sig=1 x")] + [InlineData("sig=1,")] + // RFC 8941 §4.2.1.2: an inner list ends with a closing parenthesis. + [InlineData("sig=(")] + [InlineData("sig=( ")] + [InlineData("sig=(\"@method\"")] + // RFC 8941 §4.2.4: a digit follows the sign, an integer has at most 15 digits, + // and a decimal has one to three fraction digits. + [InlineData("sig=-")] + [InlineData("sig=1234567890123456")] + [InlineData("sig=1.2345")] + [InlineData("sig=1.")] + // RFC 8941 §4.2.5: a backslash escapes a quote or a backslash, DEL is not printable, and a quote ends a string. + // Each string reaches the end of the input, so the parser cannot fail later on a character after it. + [InlineData("sig=\"x\\")] + [InlineData("sig=\"x\\q")] + [InlineData("sig=\"x\u007F")] + [InlineData("sig=\"x")] + // RFC 8941 §4.2.7: a byte sequence ends with a colon and holds only base64 characters. + [InlineData("sig=:")] + [InlineData("sig=:A!==:")] + // RFC 8941 §4.2.8: a Boolean is ?1 or ?0. + [InlineData("sig=?")] + public void RejectsAMalformedDictionary(string input) + { + Assert.False(TryParseDictionary(input, out IReadOnlyList? dictionary)); + Assert.Null(dictionary); + } + + // RFC 8941 §4.1.6: a string is quoted, and a backslash precedes each quote or backslash in it. + [Theory] + [InlineData("", "\"\"")] + [InlineData("x", "\"x\"")] + [InlineData("\"", "\"\\\"\"")] + [InlineData("\\", "\"\\\\\"")] + public void SerializesAString(string value, string expected) + { + Assert.Equal(expected, SerializeString(value)); + } + + // Each member as its key, its item and its parameters, such as sig=Integer(1);x=Boolean(True). + private static string Describe(IReadOnlyList dictionary) + { + return string.Join(", ", dictionary.Select(member => $"{member.Key}={Describe((Item)member.Value)}")); + } + + private static string Describe(Item item) + { + return Describe(item.Value) + + string.Concat(item.Parameters.Select(parameter => $";{parameter.Key}={Describe(parameter.Value)}")); + } + + private static string Describe(BareItem value) + { + return value switch + { + IntegerItem integer => $"Integer({integer.Value.ToString(CultureInfo.InvariantCulture)})", + DecimalItem number => $"Decimal({number.Value.ToString("0.0##", CultureInfo.InvariantCulture)})", + StringItem text => $"String({text.Value})", + TokenItem token => $"Token({token.Value})", + ByteSequenceItem bytes => $"ByteSequence({bytes.Base64})", + BooleanItem boolean => $"Boolean({boolean.Value})", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Not a bare item."), + }; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs index 7b283b3442..b6b481f259 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs @@ -1,3 +1,5 @@ +using System.Collections; + namespace Devolutions.AgentIdentity.Tests; public class AgentMetadataTests @@ -11,6 +13,7 @@ public void AcceptsNoEntries() Assert.Empty(metadata); } + // AgentMetadata documents that enumeration follows the order in which the agent sent the entries. [Fact] public void KeepsEveryEntryInTheOrderReceived() { @@ -27,6 +30,33 @@ public void KeepsEveryEntryInTheOrderReceived() Assert.Equal("host", metadata["hostname"]); } + // A host that enumerates the metadata through the non-generic IEnumerable sees the same entries in the same order. + // Enumerable.Cast would take the generic enumerator, so the loop calls the non-generic one. + [Fact] + public void NonGenericEnumerationKeepsEveryEntryInTheOrderReceived() + { + KeyValuePair[] entries = [new("os_name", "Windows"), new("hostname", "host")]; + AgentMetadata metadata = AssertValid(AgentMetadata.Validate(entries)); + + List> enumerated = []; + foreach (KeyValuePair entry in (IEnumerable)metadata) + { + enumerated.Add(entry); + } + + Assert.Equal(entries, enumerated); + } + + // IReadOnlyDictionary.ContainsKey is true for a key received and false for any other. + [Fact] + public void ContainsOnlyTheKeysReceived() + { + AgentMetadata metadata = AssertValid(AgentMetadata.Validate([new("hostname", "host")])); + + Assert.True(metadata.ContainsKey("hostname")); + Assert.False(metadata.ContainsKey("absent")); + } + // CONTRACT.md §3: at most 32 keys. [Fact] public void Accepts32Keys() @@ -107,6 +137,7 @@ public void RejectsValueOf1025Utf8Bytes(string unit, int count, string suffix) AssertInvalid(AgentMetadata.Validate([new("hostname", Repeat(unit, count) + suffix)])); } + // CONTRACT.md §3 gives a value no minimum length. [Fact] public void AcceptsEmptyValue() { @@ -134,6 +165,7 @@ public void AcceptsValueWithCharacterNextToAControlRange(int codePoint) AssertValid(AgentMetadata.Validate([new("hostname", $"host{(char)codePoint}name")])); } + // CONTRACT.md §3 measures a value in UTF-8 bytes. // A string holding an unpaired surrogate is not Unicode text, so it has no UTF-8 encoding to measure or store. [Theory] [InlineData("lone high surrogate", 0x61, 0xD800, 0x62)] @@ -163,6 +195,7 @@ public void RejectsObjectOf16KiBAndOneByte() AssertInvalid(AgentMetadata.Validate(EntriesOf1024Bytes(lastKey: "k150"))); } + // CONTRACT.md §1 makes one metadata key sent twice invalid_request. // A later entry with the same key would silently replace the earlier one. [Fact] public void RejectsRepeatedKey() @@ -170,6 +203,7 @@ public void RejectsRepeatedKey() AssertInvalid(AgentMetadata.Validate([new("hostname", "a"), new("hostname", "b")])); } + // MetadataValidationResult.FailureDetail documents that the detail quotes a key only when the key is valid. // The failure detail goes to host logs, so it must not copy an invalid key, which could hold a line break. [Fact] public void FailureDetailDoesNotQuoteAnInvalidKey() @@ -180,6 +214,7 @@ public void FailureDetailDoesNotQuoteAnInvalidKey() Assert.DoesNotContain("forged log line", result.FailureDetail); } + // AgentMetadata.Validate documents an ArgumentException for an entry with a null key or value. // A null key or value can only come from the host, since neither JSON nor protobuf metadata can hold one. [Theory] [InlineData(null, "value")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index 36db61ae39..61fe8f7c5a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -110,11 +110,13 @@ public void JsonHasTheProductFieldsBesideTheContractMembers() } // CONTRACT.md §1: JSON keys are snake_case, lowercase words of letters and digits joined by single underscores. + // zz09 starts and goes on with the last letter, and holds the first and last digits. [Theory] [InlineData("a")] [InlineData("psu_channel_url")] [InlineData("ipv4_address")] [InlineData("tier_2")] + [InlineData("zz09")] public void CreateAcceptsASnakeCaseProductFieldName(string name) { IdentityConfig config = IdentityConfig.Create(1, null, [new(name, TestJson.Parse("true"))]); @@ -149,6 +151,7 @@ public void CreateRejectsAProductFieldNamedAfterAContractMember(string name) AssertRejectsProductFields([new(name, TestJson.Parse("2"))]); } + // RFC 8259 §4: an object member is a name and a value. // The default JsonElement holds no JSON value at all. [Fact] public void CreateRejectsAProductFieldWithoutAValue() @@ -156,6 +159,7 @@ public void CreateRejectsAProductFieldWithoutAValue() AssertRejectsProductFields([new("psu_channel_url", default)]); } + // RFC 8259 §4: a member name is a string, which a null name is not. [Fact] public void CreateRejectsAProductFieldWithoutAName() { @@ -217,6 +221,7 @@ public void CreateRejectsANullFieldOrMember(string name, string value) AssertRejectsProductFields([new(name, TestJson.Parse(value))]); } + // RFC 8259 §4 asks for unique names within an object, and §5 makes array elements values rather than members. // A name repeats only within one object, and an array element is no field, so it may be null. [Fact] public void CreateAcceptsAValueWhoseMembersFollowTheConventions() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index 0b4b457118..e9e7baccdd 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -29,6 +29,7 @@ public abstract class RequestBodyTests protected abstract RequestBodyOutcome Parse(byte[] utf8Json); + // CONTRACT.md §5.2, §5.3 and §5.6 define the bodies, and §3 their metadata. [Fact] public void AcceptsValidBody() { @@ -51,6 +52,7 @@ public void AcceptsUnknownMembers(string extraMembers) AssertValid(ParseText(Body(ValidMetadata, extraMembers))); } + // RFC 8259 §7: an escape stands for its character; AgentMetadata keeps the order in which the entries were sent. [Fact] public void ReturnsTheMetadataUnescapedInTheOrderSent() { @@ -168,6 +170,7 @@ public void RejectsTruncatedBody() AssertInvalid(ParseText(Body(ValidMetadata, "")[..^1])); } + // CONTRACT.md §5.2, §5.3 and §5.6: each body is a JSON object; RFC 8259 §2: a JSON text holds one value. [Theory] [InlineData("[]")] [InlineData(""" "body" """)] @@ -208,6 +211,7 @@ public void RejectsStringThatIsNotUtf8(byte[] bytes) AssertInvalid(Parse([.. body[..index], .. bytes, .. body[(index + 1)..]])); } + // RFC 8259 §9 lets a parser limit nesting, and the Parse methods document a limit of 64 levels. // The body object is the first level, so 63 nested arrays reach 64 levels. [Fact] public void AcceptsNestingOf64Levels() @@ -277,6 +281,7 @@ protected sealed override string Body(string? metadata, string extraMembers) return BodyWithCsr(Quote(ValidCsr), metadata, extraMembers); } + // test-vectors.json csr.valid gives the public key, which RFC 5480 §2 places in this SubjectPublicKeyInfo. [Fact] public void ReturnsTheValidatedCsr() { @@ -360,6 +365,7 @@ public void RejectsCsrInTheBase64UrlAlphabet() AssertOnlyCanonicalCsrIsValid(canonical, canonical.Replace('+', '-').Replace('/', '_')); } + // test-vectors.json csr.bad_self_signature expects invalid_request. [Fact] public void RejectsCsrThatFailsValidation() { @@ -368,12 +374,14 @@ public void RejectsCsrThatFailsValidation() AssertInvalid(ParseText(BodyWithCsr(Quote(csr), ValidMetadata))); } + // RFC 2986 §4: a CSR is a DER SEQUENCE, which no empty byte string encodes. [Fact] public void RejectsEmptyCsr() { AssertInvalid(ParseText(BodyWithCsr(Quote(""), ValidMetadata))); } + // CONTRACT.md §1 makes csr sent twice invalid_request. [Fact] public void RejectsRepeatedCsr() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs index 444f1adc97..3de2a6c55c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs @@ -113,6 +113,7 @@ public void TrustAnchorRejectsNoRoots() Assert.Equal("roots", exception.ParamName); } + // CONTRACT.md §5.1: each root has a certificate, which a null root lacks. [Fact] public void TrustAnchorRejectsANullRoot() { @@ -226,6 +227,7 @@ public void ChainWithACertificateThatDidNotIssueThePreviousOneIsRejected(string Assert.Equal("certificateChain", exception.ParamName); } + // CONTRACT.md §4 and §5.2: a chain starts with the leaf, so it holds at least one certificate. [Theory] [MemberData(nameof(ChainBodyNames))] public void EmptyChainIsRejected(string endpoint) @@ -235,6 +237,7 @@ public void EmptyChainIsRejected(string endpoint) Assert.Equal("certificateChain", exception.ParamName); } + // CONTRACT.md §1: each certificate of a chain is written as its DER, which a null certificate lacks. [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithANullCertificateIsRejected(string endpoint) @@ -264,6 +267,7 @@ IEnumerable ReadChain() Assert.Equal(1, reads); } + // As with a chain, a writer that read the roots twice could check one list and write another. [Fact] public void TrustAnchorReadsTheRootsOnce() { @@ -295,6 +299,22 @@ public void BodyCarriesTheConfig(string endpoint) Assert.Equal("https://psu/channel", config["psu_channel_url"].GetString()); } + // CONTRACT.md §5.2 and §5.6: `config` is the identity's config, and IdentityConfig.Create sets no depth limit. + // The value nests 64 levels, so the body nests 66, beyond the 64 that a System.Text.Json reader allows by default. + [Theory] + [MemberData(nameof(ConfigBodyNames))] + public void BodyCarriesADeeplyNestedConfig(string endpoint) + { + string nested = new string('[', 64) + "0" + new string(']', 64); + IdentityConfig config = IdentityConfig.Create(1, null, [new("extra", TestJson.Parse(nested))]); + + byte[] body = ConfigBodies[endpoint](config); + + using JsonDocument document = JsonDocument.Parse(body, new JsonDocumentOptions { MaxDepth = 128 }); + Dictionary written = TestJson.Members(TestJson.Members(document.RootElement)["config"]); + Assert.True(JsonElement.DeepEquals(TestJson.Parse(nested), written["extra"])); + } + private static JsonElement[] Roots(byte[] body) { return TestJson.Elements(TestJson.Members(body)["roots"]); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index d674038595..89d5f19017 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -1,4 +1,5 @@ using System.Formats.Asn1; +using System.Numerics; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -11,6 +12,13 @@ public class CertificateSigningRequestTests internal static readonly byte[] P256SubjectPublicKeyInfoPrefix = Convert.FromHexString("3059301306072A8648CE3D020106082A8648CE3D030107034200"); + // SEC 2 §2.4.2: P-256 is y² = x³ - 3x + b over the prime field of order p. + private static readonly BigInteger P256Prime = Unsigned( + Convert.FromHexString("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF")); + + private static readonly BigInteger P256B = Unsigned( + Convert.FromHexString("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B")); + // CONTRACT.md §4: the server ignores the CSR's subject and attributes, including requested extensions, // and security invariant 2 lets a CSR supply only a verified public key. private static readonly Dictionary> RequestsWithIgnoredContents = new() @@ -63,7 +71,8 @@ public class CertificateSigningRequestTests }, }; - // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker. + // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker, + // and RFC 5480 §2 those of SubjectPublicKeyInfo and of its AlgorithmIdentifier. private static readonly Dictionary> RequestsWithAnExtraElement = new() { ["CertificationRequestInfo"] = key => @@ -161,6 +170,8 @@ public class CertificateSigningRequestTests point[0] = (byte)(0x06 | (point[^1] & 1)); return point; }, + + // SEC 1 §2.3.3: the uncompressed form is 04, then x and y in 32 octets each, 65 octets in all. ["without its form octet"] = key => TestCsr.UncompressedPoint(key)[1..], ["with an extra octet"] = key => [.. TestCsr.UncompressedPoint(key), 0x00], @@ -168,7 +179,7 @@ public class CertificateSigningRequestTests ["point at infinity"] = _ => [0x00], }; - // DER requires an unused bit to be zero, so each BIT STRING is one whose last octet is even. + // X.690 §11.2.1: DER requires an unused bit to be zero, so each BIT STRING is one whose last octet is even. private static readonly Dictionary> RequestsWithUnusedBits = new() { // RFC 5480 §2.2: the octets of the point become the bits of the BIT STRING, so none is unused. @@ -181,6 +192,8 @@ public class CertificateSigningRequestTests unusedBits: 1); return TestCsr.Request(TestCsr.RequestInfo(spki), key); }, + + // RFC 3279 §2.2 and §2.2.3: the BIT STRING holds the octets of the DER Ecdsa-Sig-Value, so none is unused. ["signature"] = () => { using ECDsa key = NewKey(); @@ -192,6 +205,21 @@ public class CertificateSigningRequestTests }, }; + // Uncompressed points with a coordinate of p or more, each another encoding of a point on P-256, + // which would give its key a second SubjectPublicKeyInfo SHA-256. + private static readonly Dictionary PointsWithACoordinateOutOfTheField = new() + { + // x = p, which is 0 modulo p, and y a square root of b modulo p. + ["x"] = ( + "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", + "66485C780E2F83D72433BD5D84A06BB6541C2AF31DAE871728BF856A174F93F4"), + + // y = p + 1, which is 1 modulo p, and x a root of x³ - 3x + b - 1 modulo p. + ["y"] = ( + "09E78D4EF60D05F750F6636209092BC43CBDD6B47E11A9DE20A9FEB2A50BB96C", + "FFFFFFFF00000001000000000000000000000001000000000000000000000000"), + }; + public static TheoryData RequestWithIgnoredContentsNames => new(RequestsWithIgnoredContents.Keys); public static TheoryData RequestWithALongerLengthNames => new(RequestsWithALongerLength.Keys); @@ -206,6 +234,10 @@ public class CertificateSigningRequestTests public static TheoryData RequestWithUnusedBitsNames => new(RequestsWithUnusedBits.Keys); + public static TheoryData PointWithACoordinateOutOfTheFieldNames => + new(PointsWithACoordinateOutOfTheField.Keys); + + // test-vectors.json csr..expected, the outcome CONTRACT.md §4 gives each request. [Theory] [MemberData(nameof(VectorData.CsrNames), MemberType = typeof(VectorData))] public void CsrVectorHasItsExpectedOutcome(string name) @@ -217,6 +249,7 @@ public void CsrVectorHasItsExpectedOutcome(string name) Assert.Equal(vector.Expected, result.Succeeded ? "ok" : result.Error.Code.ToWireValue()); } + // test-vectors.json csr.valid.public_key_sec1, after the RFC 5480 §2 prefix of a P-256 SubjectPublicKeyInfo. [Fact] public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() { @@ -231,6 +264,7 @@ public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() } // A request written by .NET rather than by the test builder. + // CONTRACT.md §4: a P-256 key and ecdsa-with-SHA256, the profile .NET writes for this key. [Fact] public void AcceptsRequestWrittenByDotNet() { @@ -254,6 +288,7 @@ public void AcceptsAnySubjectAndAttributes(string request) Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } + // RFC 2986 §4: none of these is a CertificationRequest. [Theory] [InlineData("")] [InlineData("00")] @@ -264,6 +299,7 @@ public void RejectsBytesThatAreNotARequest(string hex) AssertInvalid(CertificateSigningRequest.Validate(Convert.FromHexString(hex))); } + // CONTRACT.md §1: a CSR is DER, the encoding of one request. // Bytes after the request would give one request many encodings. [Fact] public void RejectsTrailingBytes() @@ -376,6 +412,7 @@ public void RejectsPointNotUncompressed(string point) AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, PointsNotUncompressed[point](key)))); } + // CONTRACT.md §4: the key is a point on P-256. // Flipping the low bit of y leaves the point off the curve, // since the only other point with the same x has y' = p - y, which is y + 1 or y - 1 only for y = (p ± 1) / 2. // No private key exists for such a point, so the key whose point was altered signs the request. @@ -390,6 +427,27 @@ public void RejectsPointOffTheCurve() AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); } + // SEC 1 §3.2.2.1: the coordinates of a public key are integers below p, + // and CONTRACT.md §4 gives a key that is not a P-256 point invalid_request, not an exception. + // Each point is on the curve modulo p, so only the range of its coordinates rejects it; + // a platform's key import throws an exception of a platform-specific type for such a point. + // The private key of either point is unknown, and the key is invalid whatever the signature, + // so r = s = 1 stands in for the signature. + [Theory] + [MemberData(nameof(PointWithACoordinateOutOfTheFieldNames))] + public void RejectsPointWithACoordinateOutOfTheField(string coordinate) + { + (string x, string y) = PointsWithACoordinateOutOfTheField[coordinate]; + byte[] point = [0x04, .. Convert.FromHexString(x), .. Convert.FromHexString(y)]; + Assert.True(IsOnP256ModuloP(point)); + byte[] der = TestCsr.Sequence( + TestCsr.RequestInfo(TestCsr.SubjectPublicKeyInfo(TestCsr.P256Algorithm(), point)), + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(TestCsr.Sequence(TestCsr.Integer(1), TestCsr.Integer(1)))); + + AssertInvalid(CertificateSigningRequest.Validate(der)); + } + [Theory] [MemberData(nameof(RequestWithUnusedBitsNames))] public void RejectsBitStringWithUnusedBits(string bitString) @@ -478,6 +536,7 @@ private static CertificateSigningRequest AssertValid(CsrValidationResult result) return result.Csr; } + // CONTRACT.md §4: a CSR that breaks a rule gets invalid_request, which §5.4 maps to 400. private static void AssertInvalid(CsrValidationResult result) { Assert.False(result.Succeeded); @@ -564,4 +623,18 @@ private static (byte[] R, byte[] S) ReadSignature(byte[] signature) return (sequence.ReadIntegerBytes().ToArray(), sequence.ReadIntegerBytes().ToArray()); } + + // SEC 2 §2.4.2: whether the coordinates of an uncompressed point, reduced modulo p, satisfy y² = x³ - 3x + b. + private static bool IsOnP256ModuloP(byte[] point) + { + BigInteger x = Unsigned(point[1..33]) % P256Prime; + BigInteger y = Unsigned(point[33..]) % P256Prime; + + return BigInteger.ModPow(y, 2, P256Prime) == ((x * x * x) - (3 * x) + P256B) % P256Prime; + } + + private static BigInteger Unsigned(byte[] bigEndian) + { + return new BigInteger(bigEndian, isUnsigned: true, isBigEndian: true); + } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 24c86c8859..74ea32bb64 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -183,11 +183,16 @@ public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(draw)); byte[] serialNumber = SerialNumber(leaf); + // oracle: self-derived; sole guard, with SerialNumberIsDrawnAgainWhenItWouldBeZero, + // for a serial number whose first octet keeps its top bit while its other bits are kept, set or flipped. + // RFC 5280 allows any positive encoding, and .NET writes a set top bit with a leading zero octet, + // so only the library chooses to clear the top bit of the 16 octets drawn. Assert.Equal([0x7F, .. draw[1..]], serialNumber); Assert.True(new BigInteger(serialNumber, isUnsigned: false, isBigEndian: true) > 0); Assert.InRange(serialNumber.Length, 1, 20); } + // RFC 5280 §4.1.2.2: the serial number is positive, so never zero. // Clearing the top bit of 80 00 … 00 leaves zero, which is not a positive serial number. [Fact] public void SerialNumberIsDrawnAgainWhenItWouldBeZero() @@ -198,9 +203,15 @@ public void SerialNumberIsDrawnAgainWhenItWouldBeZero() using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(topBitOnly, second)); + // oracle: self-derived; sole guard, with SerialNumberIsTheRandomOctetsWithTheTopBitCleared, + // for a serial number whose first octet keeps its top bit while its other bits are kept, set or flipped. + // That 80 00 … 00 becomes zero, and that the next draw becomes the serial number as drawn, + // follow from the library's own way of making the 16 octets positive. Assert.Equal(second, SerialNumber(leaf)); } + // RFC 5280 §4.1.2.5.1 and §4.1.2.5.2: certificate times are whole seconds, + // and a notBefore rounded up would issue a certificate that is not yet valid. [Fact] public void NotBeforeIsTheClockTruncatedToTheSecond() { @@ -209,7 +220,9 @@ public void NotBeforeIsTheClockTruncatedToTheSecond() Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); } - // A certificate holds whole seconds, so a fraction of a second in the lifetime is dropped. + // CONTRACT.md §4: the lifetime is a server setting. + // RFC 5280 §4.1.2.5: a certificate holds whole seconds, so a fraction of a second in the lifetime is dropped, + // since rounding it up would exceed the lifetime. [Theory] [InlineData(0)] [InlineData(500)] @@ -240,7 +253,8 @@ public void DefaultLifetimeIs90Days() Assert.Equal(TimeSpan.FromDays(90), IdentityCertificateIssuer.DefaultLifetime); } - // Reading a moving clock twice would give the certificate a validity other than its lifetime. + // CONTRACT.md §4: the lifetime is a server setting, + // and reading a moving clock twice would give the certificate a validity other than its lifetime. [Fact] public void ReadsTheClockOnce() { @@ -251,6 +265,7 @@ public void ReadsTheClockOnce() } // The certificate chains to the root that issued it, for client authentication. + // RFC 5280 §6 path validation, as X509Chain runs it, with the CONTRACT.md §4 EKU clientAuth. [Fact] public void ChainsToTheIssuer() { @@ -287,7 +302,8 @@ public void IgnoresTheSubjectAndExtensionsOfTheCsr() Assert.Equal([ClientAuth], usage.EnhancedKeyUsages.Cast().Select(oid => oid.Value)); } - // A certificate holds whole seconds, so a lifetime under one second would end in the second it begins. + // RFC 5280 §4.1.2.5: a certificate holds whole seconds, + // so a lifetime under one second would end in the second it begins. [Theory] [InlineData(0)] [InlineData(-1_000)] @@ -297,7 +313,8 @@ public void ThrowsForLifetimeShorterThanOneSecond(int milliseconds) Assert.Throws(() => Issue(lifetime: TimeSpan.FromMilliseconds(milliseconds))); } - // notAfter is at most the issuer's notAfter, so an issuer that expires by notBefore leaves no validity. + // CONTRACT.md §4: notAfter is at most the issuer's notAfter, + // so an issuer that expires by notBefore leaves no validity. [Theory] [InlineData(0)] [InlineData(-1)] @@ -352,6 +369,8 @@ public async Task RenewalThrowsForAnIdentityAuthenticatedForAnotherOperation( Assert.Equal("renewing", exception.ParamName); } + // INTENT.md: a renewal acts for the identity that a renew request proved, and null is no identity; + // the .NET Framework Design Guidelines throw ArgumentNullException for a null argument that is not allowed. [Theory] [InlineData(nameof(IdentityCertificateIssuer))] [InlineData(nameof(RenewalRules))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index a92f703844..cf7f38369f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -299,6 +299,8 @@ public async Task HelloWithInvalidMetadataIsInvalidRequest() // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. // CONTRACT.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, // so an agent that has not proved the key never learns whether its metadata would pass. + // oracle: the code and the status come from these sections; the failure details are self-derived, + // the handshake's own output, and only they tell two signature_invalid failures apart. [Theory] [MemberData(nameof(FailurePairNames))] public async Task EarlierFailureWins(string earlier, string later) @@ -366,6 +368,8 @@ public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake( } // A retired certificate passes the handshake, but the revocation of its identity still blocks it. + // CONTRACT.md §7.4: revocation closes a stream still awaiting its Hello, with PERMISSION_DENIED and + // identity_revoked. [Fact] public async Task RevocationWinsOverARetiredCertificate() { @@ -376,6 +380,8 @@ public async Task RevocationWinsOverARetiredCertificate() } // Whatever the outcome, the handshake reads the registry once, for the certificate that opened the stream. + // CONTRACT.md §7.4 applies a deletion or a revocation since the opening to a stream awaiting its Hello, + // so the handshake reads the registry again; that it reads it exactly once is the library's API. [Theory] [MemberData(nameof(ScenarioNames))] public async Task HandshakeReadsTheRegistryOnce(string scenario) @@ -390,6 +396,8 @@ public async Task HandshakeReadsTheRegistryOnce(string scenario) // The registry answers a thumbprint with the certificate issued under it, for its one identity, // so another answer is a host bug, which must fail as a server error rather than draw an outcome the agent acts on. + // CONTRACT.md §5.4: an unexpected server failure is the transient `500 unavailable`, + // whereas identity_unknown and identity_revoked are terminal; the exception type is the library's API. [Theory] [MemberData(nameof(WrongRegistryAnswersAndCalls))] public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string answer, string call) @@ -403,6 +411,10 @@ public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string // The handshake ends when VerifyHelloAsync is called, not when the registry answers, // so a Hello timer that fires during the lookup cannot end the handshake a second time. + // CONTRACT.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. + // Only the library's API decides that the call, not the answer, ends the handshake, + // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call + // that the object's state does not allow. [Fact] public async Task HandshakeEndsBeforeTheRegistryAnswers() { @@ -423,6 +435,11 @@ public async Task HandshakeEndsBeforeTheRegistryAnswers() // The timeout ends the handshake when HelloTimedOutAsync is called too, // so a Hello that arrives during its lookup cannot authenticate a stream that is being closed. + // CONTRACT.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both, + // and closes it for the timeout with signature_invalid. + // Only the library's API decides that the call, not the answer, ends the handshake, + // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call + // that the object's state does not allow. [Fact] public async Task TimeoutEndsTheHandshakeBeforeTheRegistryAnswers() { @@ -535,6 +552,7 @@ public async Task WelcomeAnswersTheHello() Assert.Equal(hello.Id, welcome.CorrelationId); } + // channel.proto: Welcome carries server_time. // The clock reads the time of the opening twice, to judge the request and to start the Hello timeout, // then the time of the handshake, then a later time. // The Welcome carries the time at which the server answers, from that one reading. @@ -554,6 +572,7 @@ public async Task WelcomeCarriesTheServerTimeOfTheHandshake() Assert.Equal(250_000_000, serverTime.Nanos); } + // CONTRACT.md §7.3 step 3: the Hello answers the Challenge id, with a proof over the challenge bytes. // The agent answers the Challenge it received, so the handshake holds the Hello to the id and bytes as sent, // even if the host later edits the message object it sent. [Fact] @@ -640,6 +659,7 @@ public async Task HelloExactlyAtTheTimeoutSucceeds() Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime); } + // CONTRACT.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. // A Hello that the host passes on after the timeout fails, so a late host timer cannot let it through. [Fact] public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() @@ -653,6 +673,8 @@ public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } + // CONTRACT.md §7.3 steps 1 and 2: the agent sends nothing until it receives the Challenge, + // which the server sends once it has verified the opening request. // The agent can answer only once the opening is verified and its challenge sent, // so the 10 s run from the end of verification, not from the earlier reading that judged the request. // Here the registry and the nonce store take 5 s, and the Hello comes 10 s after them. @@ -671,6 +693,9 @@ public async Task HelloTimeoutRunsFromTheEndOfTheOpening() // The first agent message decides the stream: a second one could retry a failed proof, // and a late Hello could authenticate a stream that already timed out. + // CONTRACT.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. + // Only the library's API decides that a later call throws InvalidOperationException, + // which the Framework Design Guidelines give a call that the object's state does not allow. [Theory] [MemberData(nameof(HandshakeEndsThenAnotherCall))] public async Task HandshakeEndsAtTheFirstCall(string first, string second) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs index 7d4c6fbe8c..81bd1d8753 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs @@ -89,6 +89,7 @@ public void ReconnectCarriesItsReason() // Reasons start at 1, so a reason that the host never set is not a valid one, // and a value after the last reason is not one either. + // Framework Design Guidelines: validate enum arguments, and throw the most derived ArgumentException naming them. [Theory] [InlineData("RenewRequested", 0)] [InlineData("RenewRequested", 3)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index b7e832736e..efef0444a4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -32,6 +32,7 @@ public async Task ChallengeHolds32BytesFromTheInjectedGenerator() Assert.False(challenge.HasCorrelationId); } + // CONTRACT.md §7.3 step 2: the challenge holds 32 random bytes fresh for this stream. // A challenge reused across streams would let a proof captured on one stream authenticate another. [Fact] public async Task EachOpeningHasItsOwnChallenge() @@ -78,6 +79,7 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() Assert.Equal(opening.Challenge.Challenge.Challenge_.ToByteArray(), opening.ChallengeBytes.ToArray()); } + // CONTRACT.md §7.3 steps 3 and 4: the agent signs the challenge it received, and the server verifies that proof. // The handshake checks the proof against the challenge as sent, // so a host that edits the message object after sending it must not change what the agent has to sign. [Fact] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs index 706fa658e7..19c827928b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs @@ -20,6 +20,7 @@ public class ConstructionTests // IdentityConfig.Create checks the host's values and returns the config itself rather than a result. public static TheoryData CheckedTypes => new([.. Validated, typeof(IdentityConfig)]); + // oracle: self-derived; sole guard for a public IdentityConfig constructor that takes already-serialized JSON. [Theory] [MemberData(nameof(CheckedTypes))] public void HasNoPublicOrProtectedConstructor(Type type) @@ -34,6 +35,7 @@ public void HasNoPublicOrProtectedConstructor(Type type) // Validation returns a result that holds the instance, // so a public static member returning the type itself would be another factory. + // oracle: self-derived; sole guard for a public AgentMetadata factory that skips validation. [Theory] [MemberData(nameof(ValidatedTypes))] public void NoPublicStaticMemberReturnsTheType(Type type) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs index 5ba7ca6318..818df2fafe 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs @@ -136,6 +136,7 @@ public void AcceptsATokenOf4096Bytes() Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); } + // CONTRACT.md §2: a token is at most 4096 bytes; §2.1 (1): a field that holds no token gets `token_invalid`. [Fact] public void RejectsATokenLongerThan4096Bytes() { @@ -190,6 +191,7 @@ public void FailureRevealsNoPartOfTheToken(string[] fieldLines) EnrollmentTokenAssert.RevealsNoPart($"{result}", Bag, Secret); } + // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. [Fact] public void RejectsNullFieldLines() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index 0d6948a0bf..6bb6c64250 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -159,6 +159,8 @@ public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(strin AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); } + // Framework Design Guidelines: a bad argument throws the most derived ArgumentException, whose ParamName names it, + // here ArgumentOutOfRangeException for a count below zero. [Theory] [InlineData(-1, 0, "maxUses")] [InlineData(1, -1, "identitiesCreated")] @@ -172,6 +174,7 @@ public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, // Statuses start at 1, so a status that the host never set is not a valid one, // and a value after the last status is not one either. + // Framework Design Guidelines: validate enum arguments, and throw the most derived ArgumentException naming them. [Theory] [InlineData(0)] [InlineData(4)] @@ -183,6 +186,7 @@ public void KeyMatchRejectsAnUndefinedCertificateStatus(int status) Assert.Equal("status", exception.ParamName); } + // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. [Fact] public void DecideRejectsANullToken() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index f8b532afb9..78f03c7a1a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -55,6 +55,8 @@ public void CreateWritesEverySecretInTheContractPattern() // The authority stores SecretHash when it creates a token, // and finds the token of an enroll request by the hash that ReadBearerToken returns. + // CONTRACT.md §2: the authority matches the `secret` text exactly, + // so both hashes are the SHA-256 of the 43 ASCII characters of the token's secret. [Fact] public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() { @@ -67,11 +69,14 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() byte[] bytes = new byte[32]; random.NextBytes(bytes); EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(bytes)); + byte[] expected = SHA256.HashData(Encoding.ASCII.GetBytes(token.Value.Split('.')[2])); - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + token.Value]); + EnrollmentAuthorizationResult result = + EnrollmentAuthorization.ReadBearerToken(["Bearer " + token.Value]); Assert.True(result.Succeeded); - Assert.Equal(token.SecretHash.ToArray(), result.SecretHash.ToArray()); + Assert.Equal(expected, token.SecretHash.ToArray()); + Assert.Equal(expected, result.SecretHash.ToArray()); } } } @@ -161,6 +166,7 @@ public void CreateFailureRevealsNoPartOfTheSecret(string productBaseUrl) EnrollmentTokenAssert.RevealsNoPart(exception.Message, SecretText); } + // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. [Theory] [InlineData("productBaseUrl")] [InlineData("randomNumberGenerator")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs index 1c7fbefe0f..be90b8cbc5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs @@ -133,6 +133,7 @@ public void TryParseErrorRevealsNoPartOfTheFormat(string name, string format) EnrollmentTokenAssert.RevealsNoPart(error, Bag, Secret); } + // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially. // A log statement may format the object of a valid format that holds a pasted token as literal text. [Fact] public void ToStringRevealsNoPartOfTheFormat() @@ -164,6 +165,8 @@ public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue() Assert.Equal("Fleet", Evaluate("{token_name}", metadata)); } + // INTENT.agent-identity.md, "Enrollment": the format holds the placeholders and the escaped braces, + // and a placeholder evaluates to its value. // A value is substituted once, so braces that a device reports are text, not placeholders. [Fact] public void EvaluatesBracesInAValueAsText() @@ -286,6 +289,7 @@ public void DefaultFormatIsTheHostname() FriendlyNameFormat.Default.Evaluate(withoutHostname, TokenName, IdentityId)); } + // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. [Fact] public void TryParseRejectsANullFormat() { @@ -295,6 +299,7 @@ public void TryParseRejectsANullFormat() Assert.Equal("format", exception.ParamName); } + // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. [Theory] [InlineData("metadata")] [InlineData("tokenName")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index 4f93d65901..f6860cd919 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -74,6 +74,7 @@ public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, Statu Assert.Equal(expected, code.ToGrpcStatusCode()); } + // CONTRACT.md §5.4 lists exactly these ten codes. // A code added without a contract row would otherwise go untested in every table above. [Fact] public void EveryCodeHasAContractRow() @@ -86,6 +87,7 @@ public void EveryCodeHasAContractRow() } // The default value of the enum is not a code, so a mapping must not turn it into one. + // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. [Fact] public void UndefinedCodeHasNoMapping() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs index a47495c45f..881b073b3a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs @@ -11,6 +11,25 @@ public class AgentIdentityErrorTests private const string ServerTimeText = "2026-09-24T12:00:00Z"; + private static readonly string[] FrameworkLevelErrorNames = + [ + nameof(AgentIdentityError.MalformedBody), + nameof(AgentIdentityError.UnknownRoute), + nameof(AgentIdentityError.MethodNotAllowed), + nameof(AgentIdentityError.BodyTooLarge), + nameof(AgentIdentityError.UnexpectedFailure), + nameof(AgentIdentityError.ServiceUnavailable), + ]; + + public static TheoryData PublicErrorNames => new(PublicErrors.Select(error => error.Name)); + + // Every error that the library hands out: the error of each code, and each framework-level error. + private static IEnumerable<(string Name, AgentIdentityError Error)> PublicErrors => + [ + .. Enum.GetValues().Select(code => ($"From({code})", AgentIdentityError.From(code))), + .. FrameworkLevelErrorNames.Select(name => (name, FrameworkLevelError(name))), + ]; + // CONTRACT.md §5.4, column "HTTP". [Theory] [MemberData(nameof(AgentIdentityErrorCodeTests.HttpStatusCodes), MemberType = typeof(AgentIdentityErrorCodeTests))] @@ -22,6 +41,8 @@ public void FromCarriesTheCodeWithItsContractStatus(AgentIdentityErrorCode code, Assert.Equal(expectedStatus, error.HttpStatusCode); } + // CONTRACT.md §5.4 has no code for the default value of the enum. + // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. [Fact] public void FromRejectsAnUndefinedCode() { @@ -49,6 +70,17 @@ public void FrameworkLevelErrorHasItsContractStatusAndCode( Assert.Equal(expectedCode, error.Code); } + // CONTRACT.md §5.4: every error body, framework-level failures included, carries a `message` of human text. + // The contract leaves its wording free, so only its presence is checked. + [Theory] + [MemberData(nameof(PublicErrorNames))] + public void ErrorHasAHumanMessage(string name) + { + AgentIdentityError error = PublicErrors.Single(entry => entry.Name == name).Error; + + Assert.False(string.IsNullOrWhiteSpace(error.Message)); + } + // CONTRACT.md §5.4 body, with the snake_case keys and the timestamp format of §1. // Parsing the raw bytes also rejects a byte order mark, which RFC 8259 §8.1 forbids in a JSON text. [Fact] @@ -80,7 +112,7 @@ public void RpcExceptionHasTheChannelStatus(AgentIdentityErrorCode code, StatusC Assert.Equal(expected, exception.StatusCode); } - // CONTRACT.md §7.4: trailers `error-code: <§5.4 code>` and `server-time: `. + // CONTRACT.md §7.4: trailers `error-code: <§5.4 code>` and `server-time: `, in no stated order. [Fact] public void RpcExceptionHasExactlyTheContractTrailers() { @@ -88,7 +120,9 @@ public void RpcExceptionHasExactlyTheContractTrailers() Assert.Equal( [("error-code", "clock_skew"), ("server-time", ServerTimeText)], - exception.Trailers.Select(entry => (entry.Key, entry.Value))); + exception.Trailers + .Select(entry => (entry.Key, entry.Value)) + .OrderBy(entry => entry.Key, StringComparer.Ordinal)); } private static AgentIdentityError FrameworkLevelError(string name) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs index 67bf80bb03..99e1898a75 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs @@ -29,6 +29,7 @@ public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected Assert.Equal(expected, AgentIdentityProtocol.FormatTimestamp(value)); } + // RFC 3339 dates are Gregorian, and §5.6 writes them in ASCII digits with fixed separators, whatever the culture. // A culture-sensitive format would write the Thai Buddhist year 2569 and the time separator of this culture. [Fact] public void FormatTimestampIgnoresTheCurrentCulture() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs index 254ea49b02..d9277c81f3 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs @@ -9,7 +9,7 @@ public class StrictBase64Tests private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - // CONTRACT.md: base64 is RFC 4648 §4 with padding, and base64url is RFC 4648 §5 without padding. + // CONTRACT.md Definitions: base64 is RFC 4648 §4 with padding, and base64url is RFC 4648 §5 without padding. private static readonly Dictionary Codecs = new() { ["base64"] = new(StrictBase64.TryDecodeBase64, Base64Alphabet, "==", "=", base64 => base64), @@ -106,6 +106,7 @@ public void AcceptsTheExactDecodedLength(string encoding, string encoded, int le Assert.Equal(length, bytes.Length); } + // RFC 4648 §10 gives the length of each vector, which differs from the length each row requires. [Theory] [InlineData("base64", "", 1)] [InlineData("base64", "Zg==", 0)] @@ -124,6 +125,7 @@ public void RejectsAnotherDecodedLength(string encoding, string encoded, int len Assert.False(Codecs[encoding].TryDecode(encoded, out _, length)); } + // CONTRACT.md Definitions: base64url is RFC 4648 §5 without padding. [Theory] [MemberData(nameof(Base64UrlForms))] public void Base64UrlEncodesWithoutPadding(string expected, byte[] value) @@ -154,7 +156,7 @@ public void DecodesAndEncodesEveryLengthLikeTheBcl() } } - // Only the final quantum has pad bits, + // RFC 4648 §3.5: only the final quantum has pad bits, which a canonical encoding sets to zero, // so trying every final quantum of one or two bytes checks that each value has exactly one accepted encoding. [Theory] [InlineData("base64")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs index 0d9b0f9d95..839c7c655f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs @@ -183,11 +183,6 @@ private static bool IsOnP256(byte[] x, byte[] y) BigInteger bx = new(x, isUnsigned: true, isBigEndian: true); BigInteger by = new(y, isUnsigned: true, isBigEndian: true); - // UNTESTED: a coordinate of p or more is rejected. - // Such a coordinate encodes a point a second way, - // which would give one key a second SubjectPublicKeyInfo SHA-256. - // A test would need the private key of a point with a coordinate below 2^256 - p, about one key in 2^31. - // When changing this check, keep both comparisons with p before the curve equation. if (bx >= P256Prime || by >= P256Prime) { return false; From cfb17f2ca61d19bb7882c1c6f885f891176cfcea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 06:30:51 +0900 Subject: [PATCH 48/93] test(dotnet-agent-identity): delete self-derived assertions Delete assertions whose expected value came from the code under test and whose faults other tests already catch: - the request body tests compared the error message with the one the library builds for invalid_request; - the handshake precedence tests compared failure details obtained from the same handshake where the contract's error codes already tell which failure won; - an opening test repeated another test's comparison of the kept challenge with the sent one. The handshake pairs whose failures share one error code keep their detail comparison, marked self-derived, since only it tells them apart. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Bodies/RequestBodyTests.cs | 1 - .../Channel/ChannelHandshakeTests.cs | 58 +++++++++++++------ .../Channel/ChannelOpeningTests.cs | 4 +- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index e9e7baccdd..fc609e5c31 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -257,7 +257,6 @@ protected static void AssertInvalid(RequestBodyOutcome outcome, string? form = n Assert.NotNull(outcome.Error); Assert.Equal(AgentIdentityErrorCode.InvalidRequest, outcome.Error.Code); Assert.Equal(400, outcome.Error.HttpStatusCode); - Assert.Equal(AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest).Message, outcome.Error.Message); Assert.False(string.IsNullOrEmpty(outcome.FailureDetail)); Assert.Null(outcome.Metadata); Assert.Null(outcome.Csr); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index cf7f38369f..1ffc238ceb 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -122,21 +122,15 @@ public static TheoryData WrongRegistryAnswersAndCalls } } - // Each pair of failures that the handshake checks one after the other. - public static TheoryData FailurePairNames - { - get - { - TheoryData cases = new(); + // Each pair of failures that the handshake checks one after the other, and that close the stream with different + // codes. + public static TheoryData PairsOfFailuresWithDifferentCodes => + FailurePairNames((earlier, later) => earlier.Code != later.Code); - foreach ((string earlier, string later) in FailurePairs.Keys) - { - cases.Add(earlier, later); - } - - return cases; - } - } + // Each pair of failures that the handshake checks one after the other, and that close the stream with the same + // code, signature_invalid. + public static TheoryData PairsOfFailuresWithTheSameCode => + FailurePairNames((earlier, later) => earlier.Code == later.Code); // Every way to end a handshake, followed by each of the two calls a host makes. public static TheoryData HandshakeEndsThenAnotherCall @@ -299,11 +293,24 @@ public async Task HelloWithInvalidMetadataIsInvalidRequest() // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. // CONTRACT.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, // so an agent that has not proved the key never learns whether its metadata would pass. - // oracle: the code and the status come from these sections; the failure details are self-derived, - // the handshake's own output, and only they tell two signature_invalid failures apart. [Theory] - [MemberData(nameof(FailurePairNames))] + [MemberData(nameof(PairsOfFailuresWithDifferentCodes))] public async Task EarlierFailureWins(string earlier, string later) + { + (_, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[earlier]; + + ChannelHandshakeResult result = await HandshakeAsync(FailurePairs[(earlier, later)]); + + AssertFailed(result, expectedCode, expectedStatus); + } + + // Two failures that both close the stream with signature_invalid differ only in their failure detail, + // so the detail shows that the earlier one closed it. + // oracle: self-derived; CONTRACT.md §7.3 step 4 fixes no failure detail, + // so the expected one is the handshake's own for the earlier failure alone. + [Theory] + [MemberData(nameof(PairsOfFailuresWithTheSameCode))] + public async Task EarlierOfTwoSignatureFailuresWins(string earlier, string later) { (_, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[earlier]; ChannelHandshakeResult earlierAlone = await HandshakeAsync(Scenarios[earlier]); @@ -774,6 +781,23 @@ private static string WithoutHello(string closure) return $"no Hello in time, and {closure}"; } + // The names of the pairs of failures whose two failures the predicate accepts. + private static TheoryData FailurePairNames(Func include) + { + Dictionary failures = Failures; + TheoryData cases = new(); + + foreach ((string earlier, string later) in FailurePairs.Keys) + { + if (include(failures[earlier], failures[later])) + { + cases.Add(earlier, later); + } + } + + return cases; + } + // Opens the vector stream and verifies the first message built for it. private static Task VerifyAsync(Func firstMessage) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index efef0444a4..de3238f0c2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -50,7 +50,7 @@ public async Task EachOpeningHasItsOwnChallenge() // CONTRACT.md §7.3 steps 3 and 4: the proof covers the challenge as sent and the connect nonce, // and is verified with the key of the certificate that authenticated the opening, - // so the opening keeps each of them for the handshake. + // so the opening keeps the nonce and that certificate for the handshake. [Fact] public async Task OpeningKeepsWhatTheChannelProofCovers() { @@ -75,8 +75,6 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() Assert.Equal(registered.CertificateStatus, opening.Certificate.Status.ToString().ToLowerInvariant()); Assert.Equal(registered.NotBefore, opening.Certificate.NotBefore); Assert.Equal(registered.NotAfter, opening.Certificate.NotAfter); - Assert.Equal(opening.Challenge.Id, opening.ChallengeId); - Assert.Equal(opening.Challenge.Challenge.Challenge_.ToByteArray(), opening.ChallengeBytes.ToArray()); } // CONTRACT.md §7.3 steps 3 and 4: the agent signs the challenge it received, and the server verifies that proof. From 747b239175e25c6b767ad04621b9243e37b19c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 07:45:23 +0900 Subject: [PATCH 49/93] test(dotnet-agent-identity): accept CSR attributes out of DER order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACT.md §4 has the server ignore the CSR's subject and attributes without requiring their contents in canonical DER form, such as a sorted SET OF, since nothing it decides depends on them. No test pinned this, so validation could start enforcing the X.690 §11.6 order of the attribute set unnoticed. Add a case to the ignored-contents test: a correctly self-signed CSR whose two attributes, OIDs 1.2.4 then 1.2.3, are in descending order is accepted and yields its key's SubjectPublicKeyInfo. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Certificates/CertificateSigningRequestTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 89d5f19017..10e8af3209 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -53,6 +53,18 @@ public class CertificateSigningRequestTests attributes: TestCsr.Tlv(0xA0, extensionRequest)); return TestCsr.Request(info, key); }, + + // CONTRACT.md §4: attributes need no "canonical DER form, such as a sorted `SET OF`" (X.690 §11.6). + // Their encodings first differ in the last arc of the OIDs, 04 then 03, so the set descends. + ["attributes out of DER order"] = key => + { + static byte[] Attribute(string oid) => + TestCsr.Sequence(TestCsr.Oid(oid), TestCsr.Tlv(0x31, TestCsr.Tlv(0x0C, "a"u8.ToArray()))); + byte[] info = TestCsr.RequestInfo( + key.ExportSubjectPublicKeyInfo(), + attributes: TestCsr.Tlv(0xA0, Attribute("1.2.4"), Attribute("1.2.3"))); + return TestCsr.Request(info, key); + }, }; // X.690 §10.1: DER writes each length in the fewest octets. From 95af4daed5da21cad4afe3963a6df8df466af1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 08:54:57 +0900 Subject: [PATCH 50/93] test(dotnet-agent-identity): delete unspecified single-read checks Delete the response body tests that counted how often the writers enumerate their input, since no requirement specifies a single read and every fault they caught is caught by another test: - ChainIsReadOnce, for the enroll and renew certificate chains; - TrustAnchorReadsTheRootsOnce, for the trust anchor's roots. The caught set of a Stryker run over ResponseBodies.cs, IdentityConfig.cs and BaseUrl.cs is unchanged by the deletion. The only mutant that timed out before and survived after also survives the full suite before the deletion when applied by hand. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Bodies/ResponseBodiesTests.cs | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs index 3de2a6c55c..9597658d7b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs @@ -248,42 +248,6 @@ public void ChainWithANullCertificateIsRejected(string endpoint) Assert.Equal("certificateChain", exception.ParamName); } - // A writer that read the chain twice could check one chain and write another. - [Theory] - [MemberData(nameof(ChainBodyNames))] - public void ChainIsReadOnce(string endpoint) - { - int reads = 0; - - IEnumerable ReadChain() - { - reads++; - yield return DeviceA; - yield return VectorRoot; - } - - ChainBodies[endpoint](ReadChain()); - - Assert.Equal(1, reads); - } - - // As with a chain, a writer that read the roots twice could check one list and write another. - [Fact] - public void TrustAnchorReadsTheRootsOnce() - { - int reads = 0; - - IEnumerable ReadRoots() - { - reads++; - yield return VectorRoot; - } - - ResponseBodies.TrustAnchor(ReadRoots()); - - Assert.Equal(1, reads); - } - // CONTRACT.md §5.2 and §5.6: `config` is the identity's config, in the shape of §5.2. [Theory] [MemberData(nameof(ConfigBodyNames))] From 22820b1c57170ccf2f9f6a89fe564043ff111521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 09:07:00 +0900 Subject: [PATCH 51/93] test(dotnet-agent-identity): select the registry fixture by case data The test of a registry returning something other than a P-256 certificate chose its fixture with a switch on the case name, so its body branched on its parameter. Key the fixtures by case name in a dictionary that the body indexes, as the file's other case tables do. The three cases keep their names and their expected exception. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RequestAuthenticatorTests.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs index 68d9dcb317..562c8c217e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs @@ -80,6 +80,13 @@ public class RequestAuthenticatorTests ["when grace ends"] = () => DeviceACertificate.NotAfter + DeviceALifetime, }; + private static readonly Dictionary> RegisteredBytesOtherThanAP256Certificate = new() + { + ["a P-384 certificate"] = SelfSignedP384Certificate, + ["an RSA certificate"] = SelfSignedRsaCertificate, + ["bytes that are not a certificate"] = () => [0x30, 0x00], + }; + public static TheoryData Operations => new(Enum.GetValues()); public static TheoryData OperationsWithAcceptedStatuses @@ -100,6 +107,9 @@ public static TheoryData OperationsWithAccept public static TheoryData OversizedRequestNames => new(OversizedRequests.Keys); + public static TheoryData RegisteredBytesOtherThanAP256CertificateNames => + new(RegisteredBytesOtherThanAP256Certificate.Keys); + private static KeyVector DeviceA => TestVectors.Instance.Key("device-a"); private static KeyVector DeviceB => TestVectors.Instance.Key("device-b"); @@ -406,17 +416,10 @@ await Assert.ThrowsAsync( // CONTRACT.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. // The keyid is the thumbprint of those bytes, so the registry answers with what it was asked for. [Theory] - [InlineData("a P-384 certificate")] - [InlineData("an RSA certificate")] - [InlineData("bytes that are not a certificate")] + [MemberData(nameof(RegisteredBytesOtherThanAP256CertificateNames))] public async Task RegistryReturningSomethingOtherThanAP256CertificateThrows(string registered) { - byte[] der = registered switch - { - "a P-384 certificate" => SelfSignedP384Certificate(), - "an RSA certificate" => SelfSignedRsaCertificate(), - _ => [0x30, 0x00], - }; + byte[] der = RegisteredBytesOtherThanAP256Certificate[registered](); string thumbprint = Base64Forms.Base64Url(SHA256.HashData(der)); TestAuthority authority = new(Now); authority.Registry.Register( From 12aec06f5d52cab3939c92647ca5cad2d3751b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 13:49:43 +0900 Subject: [PATCH 52/93] docs(dotnet-agent-identity): cite the contract by its new name The agent-authority contract is now docs/agent-identity/CONTRACT.agent-identity.md, named in parallel with INTENT.agent-identity.md. Comments, tests, the README and the library intent cite it by that name. Comment lines that the longer name pushes past 120 characters are re-wrapped. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RegisteredCertificateTests.cs | 3 +- .../RequestAuthenticatorProfileTests.cs | 10 +-- .../RequestAuthenticatorTests.cs | 45 +++++----- .../RequestAuthenticatorVectorTests.cs | 9 +- .../Authentication/StructuredFieldTests.cs | 2 +- .../Bodies/AgentMetadataTests.cs | 20 +++-- .../Bodies/IdentityConfigTests.cs | 27 +++--- .../Bodies/RequestBodyTests.cs | 36 ++++---- .../Bodies/ResponseBodiesTests.cs | 44 +++++---- .../CertificateSigningRequestTests.cs | 32 +++---- .../IdentityCertificateIssuerTests.cs | 27 +++--- .../Certificates/RenewalRulesTests.cs | 10 +-- .../Channel/ChannelHandshakeTests.cs | 90 ++++++++++--------- .../Channel/ChannelHandshakeVectorTests.cs | 5 +- .../Channel/ChannelMessagesTests.cs | 15 ++-- .../Channel/ChannelOpeningTests.cs | 18 ++-- .../EnrollmentAuthorizationTests.cs | 25 +++--- .../Enrollment/EnrollmentRulesTests.cs | 31 +++---- .../Enrollment/EnrollmentTokenTests.cs | 21 +++-- .../Enrollment/FriendlyNameFormatTests.cs | 18 ++-- .../Errors/AgentIdentityErrorCodeTests.cs | 8 +- .../Errors/AgentIdentityErrorTests.cs | 21 +++-- .../Protocol/AgentIdentityProtocolTests.cs | 8 +- .../Protocol/StrictBase64Tests.cs | 5 +- .../Support/TestChannelProof.cs | 3 +- .../Support/TestRequest.cs | 5 +- .../Support/VectorAuthority.cs | 4 +- .../Support/VectorChannel.cs | 2 +- .../Authentication/AuthenticatedIdentity.cs | 2 +- .../Authentication/AuthenticationResult.cs | 2 +- .../Authentication/RequestAuthenticator.cs | 21 +++-- .../Authentication/SignatureFields.cs | 2 +- .../Authentication/SignedOperation.cs | 2 +- .../Bodies/IdentityConfig.cs | 9 +- .../Bodies/RequestBodyReader.cs | 4 +- .../Bodies/ResponseBodies.cs | 3 +- .../Channel/ChannelHandshakeResult.cs | 2 +- .../Channel/ChannelOpening.cs | 21 ++--- .../Channel/ChannelOpeningResult.cs | 3 +- .../Channel/ChannelProof.cs | 2 +- .../Enrollment/EnrollmentAuthorization.cs | 2 +- .../Enrollment/FriendlyNameFormat.cs | 2 +- .../Errors/AgentIdentityError.cs | 2 +- dotnet/AgentIdentity/INTENT.md | 2 +- dotnet/AgentIdentity/README.md | 74 +++++++-------- 45 files changed, 378 insertions(+), 321 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs index 8394b5562d..85b21dbdb0 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs @@ -5,7 +5,8 @@ public class RegisteredCertificateTests // An undefined status from the host's storage must fail as a host bug, // rather than draw identity_unknown, which ends the identity for the agent. // The default is the value of an uninitialized status, and 4 follows the last of the three statuses. - // CONTRACT.md Definitions: a certificate is pending, current or retired; §5.4: identity_unknown is terminal. + // CONTRACT.agent-identity.md Definitions: a certificate is pending, current or retired; + // §5.4: identity_unknown is terminal. // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. [Theory] [InlineData(default(CertificateStatus))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs index 72982fbf03..30abe3dce9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs @@ -4,7 +4,7 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The parsing rules of CONTRACT.md §6 step 1, beyond the vectors. +/// The parsing rules of CONTRACT.agent-identity.md §6 step 1, beyond the vectors. /// /// /// Each rejected request is signed over the base that a verifier without the rule under test would build, @@ -248,7 +248,7 @@ public async Task CorrectlySignedRequestBreakingAStepOneRuleIsSignatureInvalid(s Assert.Equal("signature_invalid", outcome); } - // CONTRACT.md §6 step 1: for renew and check-in, Content-Digest holds exactly one member, sha-256, + // CONTRACT.agent-identity.md §6 step 1: for renew and check-in, Content-Digest holds exactly one member, sha-256, // without parameters, in canonical base64. [Theory] [MemberData(nameof(ContentDigestViolationCases))] @@ -264,7 +264,7 @@ public async Task CorrectlySignedRequestBreakingAContentDigestRuleIsSignatureInv Assert.Equal("signature_invalid", outcome); } - // CONTRACT.md §6: parameters may appear in any order, and the verifier builds @signature-params + // CONTRACT.agent-identity.md §6: parameters may appear in any order, and the verifier builds @signature-params // from the received member, so a verifier that copied the received text would reject these. [Theory] [MemberData(nameof(AcceptedFormNames))] @@ -277,7 +277,7 @@ public async Task RequestInAFormThatRfc8941AllowsIsAccepted(string form) Assert.Equal("ok", outcome); } - // CONTRACT.md §6 step 1: a malformed Signature-Input draws signature_invalid, + // CONTRACT.agent-identity.md §6 step 1: a malformed Signature-Input draws signature_invalid, // as a result rather than an exception, and records no nonce. [Theory] [MemberData(nameof(MalformedSignatureInputNames))] @@ -292,7 +292,7 @@ public async Task MalformedSignatureInputIsSignatureInvalid(string form) } // RFC 8941 §4.2.5: an escaped backslash decodes to one backslash, so the received tag is re\new, - // which CONTRACT.md §6 step 2 rejects for renew. + // which CONTRACT.agent-identity.md §6 step 2 rejects for renew. // The signature covers the tag renew, which a parser that dropped the escaped character would read. [Fact] public async Task TagWithAnEscapedBackslashIsAnotherTag() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs index 562c8c217e..a6d3af9e32 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs @@ -5,11 +5,12 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The steps of CONTRACT.md §6 after parsing: their order, the certificate rules, the nonce, and the host's part. +/// The steps of CONTRACT.agent-identity.md §6 after parsing: their order, the certificate rules, the nonce, +/// and the host's part. /// public class RequestAuthenticatorTests { - // CONTRACT.md §5.4: servers accept request bodies up to 64 KiB. + // CONTRACT.agent-identity.md §5.4: servers accept request bodies up to 64 KiB. private const int OverTheLimit = (64 * 1024) + 1; private static readonly DateTimeOffset Now = TestAuthority.DefaultNow; @@ -120,7 +121,8 @@ public static TheoryData OperationsWithAccept private static TimeSpan DeviceALifetime => DeviceACertificate.NotAfter - DeviceACertificate.NotBefore; - // CONTRACT.md §5.4: a body larger than 64 KiB returns 413 invalid_request before any authentication outcome. + // CONTRACT.agent-identity.md §5.4: a body larger than 64 KiB returns 413 invalid_request + // before any authentication outcome. [Theory] [MemberData(nameof(OversizedRequestNames))] public async Task BodyOverTheLimitIsRejectedWith413First(string request) @@ -138,7 +140,7 @@ public async Task BodyOverTheLimitIsRejectedWith413First(string request) Assert.Empty(authority.Nonces.Calls); } - // CONTRACT.md §5.4: servers accept request bodies up to 64 KiB. + // CONTRACT.agent-identity.md §5.4: servers accept request bodies up to 64 KiB. [Fact] public async Task BodyOfExactlyTheLimitIsAccepted() { @@ -153,7 +155,7 @@ public async Task BodyOfExactlyTheLimitIsAccepted() Assert.Equal("ok", await authority.OutcomeAsync(request)); } - // CONTRACT.md §6: the earliest failing step decides, + // CONTRACT.agent-identity.md §6: the earliest failing step decides, // so the identity's state is reported even when the signature or the digest would not verify. [Theory] [InlineData("revoked identity, wrong key", "identity_revoked")] @@ -167,7 +169,7 @@ public async Task StepFourFailureWinsOverAStepFiveFailure(string request, string Assert.Equal(expected, await authority.OutcomeAsync(failing)); } - // CONTRACT.md §6 step 4 resolves the certificate before it reads the identity's revocation, + // CONTRACT.agent-identity.md §6 step 4 resolves the certificate before it reads the identity's revocation, // and reads revocation before validity, so a revoked identity always draws the terminal identity_revoked. [Theory] [InlineData("retired certificate, revoked identity", "identity_unknown")] @@ -179,7 +181,7 @@ public async Task StepFourReturnsItsEarliestFailingCheck(string request, string Assert.Equal(expected, await authority.OutcomeAsync(failing)); } - // CONTRACT.md §6 table: every operation accepts a current or pending certificate. + // CONTRACT.agent-identity.md §6 table: every operation accepts a current or pending certificate. [Theory] [MemberData(nameof(OperationsWithAcceptedStatuses))] public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation, CertificateStatus status) @@ -190,7 +192,7 @@ public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.md §6 step 4: a certificate that is neither current nor pending draws identity_unknown. + // CONTRACT.agent-identity.md §6 step 4: a certificate that is neither current nor pending draws identity_unknown. [Theory] [MemberData(nameof(Operations))] public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) @@ -201,7 +203,7 @@ public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) Assert.Equal("identity_unknown", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + // CONTRACT.agent-identity.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. // A clock truncated to the second would still accept the certificate one tick after its notAfter. // Renew has no row one tick after, because its grace still accepts the certificate then. [Theory] @@ -222,7 +224,7 @@ public async Task CertificateExpiresAfterTheExactInstantOfItsNotAfter( Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.md §6: notBefore is not checked, + // CONTRACT.agent-identity.md §6: notBefore is not checked, // so a certificate dated by a fast authority clock does not lock devices out. [Theory] [MemberData(nameof(Operations))] @@ -233,7 +235,7 @@ public async Task CertificateIsAcceptedBeforeItsNotBefore(AgentOperation operati Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.md §6 table: renew accepts a certificate while now < notAfter + (notAfter − notBefore). + // CONTRACT.agent-identity.md §6 table: renew accepts a certificate while now < notAfter + (notAfter − notBefore). [Theory] [InlineData(CertificateStatus.Pending, "one tick after notAfter", "ok")] [InlineData(CertificateStatus.Current, "one tick after notAfter", "ok")] @@ -252,7 +254,8 @@ public async Task RenewGraceIsTheCertificateLifetimeAfterItsNotAfter( Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew))); } - // CONTRACT.md §6 states the window of step 3 and the certificate rule of step 4 against the same `now`. + // CONTRACT.agent-identity.md §6 states the window of step 3 and the certificate rule of step 4 + // against the same `now`. // A second reading of the clock between the window and the certificate check // would judge the certificate a day later, after it expired. [Fact] @@ -264,7 +267,7 @@ public async Task WindowAndCertificateAreJudgedAtOneReadingOfTheClock() Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm))); } - // CONTRACT.md §6 step 3: 0 < expires − created ≤ 300. + // CONTRACT.agent-identity.md §6 step 3: 0 < expires − created ≤ 300. [Fact] public async Task SignatureLifetimeOf300SecondsIsAccepted() { @@ -278,7 +281,7 @@ public async Task SignatureLifetimeOf300SecondsIsAccepted() } // RFC 8941 §3.3.1: an integer may be negative, and RFC 9421 §2.3 serializes it with its sign. - // At the Unix epoch, all three inequalities of CONTRACT.md §6 step 3 hold, + // At the Unix epoch, all three inequalities of CONTRACT.agent-identity.md §6 step 3 hold, // and the certificate of device-a, whose notBefore is later, is accepted because §6 does not check notBefore. [Fact] public async Task CreatedBeforeTheUnixEpochIsAccepted() @@ -291,7 +294,7 @@ public async Task CreatedBeforeTheUnixEpochIsAccepted() Assert.Equal("ok", await authority.OutcomeAsync(request)); } - // CONTRACT.md §6: the earliest failing step decides. + // CONTRACT.agent-identity.md §6: the earliest failing step decides. // RFC 8941 §3.3.5 allows a byte sequence of no bytes, in its canonical base64 of no characters, // so the signature passes step 1 and its length fails only step 5, after the window of step 3. [Fact] @@ -308,7 +311,7 @@ public async Task SignatureOfNoBytesIsJudgedAfterTheWindow() Assert.Equal("clock_skew", outcome); } - // CONTRACT.md §6 step 6: a pair the store already holds fails, whichever request recorded it. + // CONTRACT.agent-identity.md §6 step 6: a pair the store already holds fails, whichever request recorded it. [Theory] [MemberData(nameof(Operations))] public async Task NonceAlreadyRecordedIsSignatureInvalid(AgentOperation operation) @@ -318,7 +321,8 @@ public async Task NonceAlreadyRecordedIsSignatureInvalid(AgentOperation operatio Assert.Equal("signature_invalid", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.md §6: a header the operation does not cover, such as Content-Digest on confirm, is ignored. + // CONTRACT.agent-identity.md §6: a header the operation does not cover, such as Content-Digest on confirm, + // is ignored. [Fact] public async Task ConfirmIgnoresContentDigest() { @@ -398,7 +402,7 @@ await Assert.ThrowsAsync( // The registry answers by thumbprint, so another certificate is a host bug, // which must fail as a server error rather than draw an outcome the agent acts on. - // CONTRACT.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. + // CONTRACT.agent-identity.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. [Fact] public async Task RegistryReturningACertificateWithAnotherThumbprintThrows() { @@ -412,8 +416,9 @@ await Assert.ThrowsAsync( () => authority.Authenticator.AuthenticateAsync(AgentOperation.Renew, request).AsTask()); } - // CONTRACT.md §4: the authority issues P-256 leaves only, so any other registered bytes are a host bug. - // CONTRACT.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. + // CONTRACT.agent-identity.md §4: the authority issues P-256 leaves only, + // so any other registered bytes are a host bug. + // CONTRACT.agent-identity.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. // The keyid is the thumbprint of those bytes, so the registry answers with what it was asked for. [Theory] [MemberData(nameof(RegisteredBytesOtherThanAP256CertificateNames))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs index f8d4e05336..7f3c25ba4f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs @@ -21,7 +21,7 @@ public class RequestAuthenticatorVectorTests public static TheoryData OkSignedRequestCaseNames => new(Vectors.Cases.Where(c => c.Expected == "ok" && c.Endpoint != "connect").Select(c => c.Name)); - // Each case isolates one rule of CONTRACT.md §6 or its precedence, + // Each case isolates one rule of CONTRACT.agent-identity.md §6 or its precedence, // so any outcome but the vector's breaks that rule. [Theory] [MemberData(nameof(VectorData.HttpSignatureCaseNames), MemberType = typeof(VectorData))] @@ -35,7 +35,7 @@ public async Task CaseReturnsItsExpectedOutcome(string name) Assert.Equal(vector.Expected, outcome.Outcome); } - // test-vectors.json http_signature.sequences, with CONTRACT.md §6 step 6. + // test-vectors.json http_signature.sequences, with CONTRACT.agent-identity.md §6 step 6. // A replayed nonce must fail, a nonce must survive a request that failed verification, // and a nonce accepted for one operation must not authenticate another. [Theory] @@ -92,7 +92,8 @@ public void Rfc9421B24VerifiesWithTheLibraryPrimitive() Assert.Equal(vector.ExpectedValid, valid); } - // CONTRACT.md §6: nonces are committed only after the signature verifies, so garbage requests cannot burn nonces. + // CONTRACT.agent-identity.md §6: nonces are committed only after the signature verifies, + // so garbage requests cannot burn nonces. // With an empty nonce store, every failing case fails before step 6. [Theory] [MemberData(nameof(FailingCaseNames))] @@ -128,7 +129,7 @@ public async Task FailingCaseDetailHoldsNoRequestData(string name) Assert.All(headers.Concat(values), value => Assert.DoesNotContain(value, outcome.FailureDetail)); } - // CONTRACT.md §6 step 6 and the shared intent: the authority keeps each accepted nonce + // CONTRACT.agent-identity.md §6 step 6 and the shared intent: the authority keeps each accepted nonce // until 60 s after its signature expires, the last moment the window could accept the signature again. [Theory] [MemberData(nameof(OkCaseNames))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs index 1f99292684..ab42972f12 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs @@ -64,7 +64,7 @@ public void ParsesADictionary(string input, string expected) [InlineData("sig;")] [InlineData("sig=1;")] [InlineData("sig=(\"@method\");")] - // CONTRACT.md §6 step 1 rejects a repeated key, of which RFC 8941 §4.2.2 would keep the last. + // CONTRACT.agent-identity.md §6 step 1 rejects a repeated key, of which RFC 8941 §4.2.2 would keep the last. [InlineData("sig=1, sig=2")] // RFC 8941 §4.2.2: a comma separates members, and none follows the last. [InlineData("sig=1 x")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs index b6b481f259..1a490e81c1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs @@ -4,7 +4,7 @@ namespace Devolutions.AgentIdentity.Tests; public class AgentMetadataTests { - // CONTRACT.md §3: metadata may be an empty object. + // CONTRACT.agent-identity.md §3: metadata may be an empty object. [Fact] public void AcceptsNoEntries() { @@ -57,7 +57,7 @@ public void ContainsOnlyTheKeysReceived() Assert.False(metadata.ContainsKey("absent")); } - // CONTRACT.md §3: at most 32 keys. + // CONTRACT.agent-identity.md §3: at most 32 keys. [Fact] public void Accepts32Keys() { @@ -72,7 +72,7 @@ public void Rejects33Keys() AssertInvalid(AgentMetadata.Validate(NumberedEntries(33))); } - // CONTRACT.md §3: a key matches ^[a-z][a-z0-9_]{0,63}$. + // CONTRACT.agent-identity.md §3: a key matches ^[a-z][a-z0-9_]{0,63}$. [Theory] [InlineData("a")] [InlineData("hostname")] @@ -115,7 +115,7 @@ public void RejectsKeyNotMatchingThePattern(string form, string key) AssertInvalid(AgentMetadata.Validate([new(key, "value")]), form); } - // CONTRACT.md §3: a value has at most 1024 UTF-8 bytes. + // CONTRACT.agent-identity.md §3: a value has at most 1024 UTF-8 bytes. // Characters of two, three and four bytes count by their bytes, not as one character or UTF-16 code unit. [Theory] [InlineData("a", 1024, "")] @@ -137,14 +137,15 @@ public void RejectsValueOf1025Utf8Bytes(string unit, int count, string suffix) AssertInvalid(AgentMetadata.Validate([new("hostname", Repeat(unit, count) + suffix)])); } - // CONTRACT.md §3 gives a value no minimum length. + // CONTRACT.agent-identity.md §3 gives a value no minimum length. [Fact] public void AcceptsEmptyValue() { AssertValid(AgentMetadata.Validate([new("domain", "")])); } - // CONTRACT.md §3: no C0 (U+0000 to U+001F), DEL (U+007F) or C1 (U+0080 to U+009F) control characters. + // CONTRACT.agent-identity.md §3: no C0 (U+0000 to U+001F), + // DEL (U+007F) or C1 (U+0080 to U+009F) control characters. public static TheoryData ControlCharacters => new(Enumerable.Range(0x00, 0x20).Append(0x7F).Concat(Enumerable.Range(0x80, 0x20))); @@ -165,7 +166,7 @@ public void AcceptsValueWithCharacterNextToAControlRange(int codePoint) AssertValid(AgentMetadata.Validate([new("hostname", $"host{(char)codePoint}name")])); } - // CONTRACT.md §3 measures a value in UTF-8 bytes. + // CONTRACT.agent-identity.md §3 measures a value in UTF-8 bytes. // A string holding an unpaired surrogate is not Unicode text, so it has no UTF-8 encoding to measure or store. [Theory] [InlineData("lone high surrogate", 0x61, 0xD800, 0x62)] @@ -179,7 +180,8 @@ public void RejectsValueWithUnpairedSurrogate(string form, params int[] codeUnit AssertInvalid(AgentMetadata.Validate([new("hostname", value)]), form); } - // CONTRACT.md §3: the whole object has at most 16 KiB, the sum of the UTF-8 byte lengths of all keys and values. + // CONTRACT.agent-identity.md §3: the whole object has at most 16 KiB, + // the sum of the UTF-8 byte lengths of all keys and values. // Each value has 1021 UTF-8 bytes but 511 characters, so a count of characters would stay far below the limit. [Fact] public void AcceptsObjectOf16KiB() @@ -195,7 +197,7 @@ public void RejectsObjectOf16KiBAndOneByte() AssertInvalid(AgentMetadata.Validate(EntriesOf1024Bytes(lastKey: "k150"))); } - // CONTRACT.md §1 makes one metadata key sent twice invalid_request. + // CONTRACT.agent-identity.md §1 makes one metadata key sent twice invalid_request. // A later entry with the same key would silently replace the earlier one. [Fact] public void RejectsRepeatedKey() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index 61fe8f7c5a..e5fb4316fb 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -4,7 +4,7 @@ namespace Devolutions.AgentIdentity.Tests; public class IdentityConfigTests { - // CONTRACT.md §5.2: `config` holds `version`, which is 1, `revision` and `agent_channel_url`. + // CONTRACT.agent-identity.md §5.2: `config` holds `version`, which is 1, `revision` and `agent_channel_url`. // An agent reads both numbers as integers, so 1.0 or 3e0 would not do. [Fact] public void JsonHasTheContractMembers() @@ -22,7 +22,7 @@ public void JsonHasTheContractMembers() Assert.Equal("https://host/dvls", members["agent_channel_url"].GetString()); } - // CONTRACT.md §5.2: `agent_channel_url` is absent when the server does not offer the channel, + // CONTRACT.agent-identity.md §5.2: `agent_channel_url` is absent when the server does not offer the channel, // and §1 omits an absent field rather than writing null. [Fact] public void JsonOmitsAnAbsentAgentChannelUrl() @@ -35,7 +35,8 @@ public void JsonOmitsAnAbsentAgentChannelUrl() Assert.Null(config.AgentChannelUrl); } - // CONTRACT.md §5.2: `revision` is an unsigned 64-bit integer, with no starting value the agent assumes. + // CONTRACT.agent-identity.md §5.2: `revision` is an unsigned 64-bit integer, + // with no starting value the agent assumes. // A double holds no more than 53 bits, so it would round the largest revisions. [Theory] [InlineData(0UL, "0")] @@ -51,7 +52,7 @@ public void JsonWritesEveryRevisionExactly(ulong revision, string expected) Assert.Equal(revision, config.Revision); } - // CONTRACT.md §5.2: `agent_channel_url` is an absolute `https` URL without a query or fragment. + // CONTRACT.agent-identity.md §5.2: `agent_channel_url` is an absolute `https` URL without a query or fragment. // RFC 9110 §4.2.4: a sender does not generate user information in an `https` URI. [Theory] [InlineData("")] @@ -74,7 +75,8 @@ public void CreateRejectsAnotherAgentChannelUrl(string agentChannelUrl) Assert.Equal("agentChannelUrl", exception.ParamName); } - // CONTRACT.md §7.2: the agent prepends the URL path to `/devolutions.agent.channel.v1.AgentChannel/Connect`, + // CONTRACT.agent-identity.md §7.2: the agent prepends the URL path to + // `/devolutions.agent.channel.v1.AgentChannel/Connect`, // so a URL ending in a slash would put two slashes in the gRPC path. [Theory] [InlineData("https://host/dvls", "https://host/dvls")] @@ -89,7 +91,8 @@ public void AgentChannelUrlEndsWithoutASlash(string agentChannelUrl, string expe Assert.Equal(expected, config.AgentChannelUrl); } - // CONTRACT.md §5.2: product-specific config fields, such as a PSU channel URL, may coexist with the others. + // CONTRACT.agent-identity.md §5.2: product-specific config fields, such as a PSU channel URL, + // may coexist with the others. [Fact] public void JsonHasTheProductFieldsBesideTheContractMembers() { @@ -109,7 +112,8 @@ public void JsonHasTheProductFieldsBesideTheContractMembers() Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), members["policy_limits"])); } - // CONTRACT.md §1: JSON keys are snake_case, lowercase words of letters and digits joined by single underscores. + // CONTRACT.agent-identity.md §1: JSON keys are snake_case, + // lowercase words of letters and digits joined by single underscores. // zz09 starts and goes on with the last letter, and holds the first and last digits. [Theory] [InlineData("a")] @@ -141,7 +145,7 @@ public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) AssertRejectsProductFields([new(name, TestJson.Parse("true"))]); } - // CONTRACT.md §5.2 defines these members, so a product field of one of these names would repeat it. + // CONTRACT.agent-identity.md §5.2 defines these members, so a product field of one of these names would repeat it. [Theory] [InlineData("version")] [InlineData("revision")] @@ -176,7 +180,7 @@ public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value) AssertRejectsProductFields([new("label", TestJson.Parse(value))]); } - // CONTRACT.md §1: JSON keys are snake_case, so the members inside a product field's value are too, + // CONTRACT.agent-identity.md §1: JSON keys are snake_case, so the members inside a product field's value are too, // whether an object or an array holds them. [Theory] [InlineData("""{"maxSessions": 4}""")] @@ -188,7 +192,8 @@ public void CreateRejectsAMemberNameThatIsNotSnakeCaseInAValue(string value) AssertRejectsProductFields([new("policy_limits", TestJson.Parse(value))]); } - // RFC 8259 §4: with a repeated name, receivers disagree on the value, as CONTRACT.md §1 notes of request bodies. + // RFC 8259 §4: with a repeated name, receivers disagree on the value, + // as CONTRACT.agent-identity.md §1 notes of request bodies. // A product field is a member of the config, and a name and its escaped spelling are the same name. private static readonly Dictionary[]> RepeatedMemberNames = new() { @@ -211,7 +216,7 @@ public void CreateRejectsARepeatedMemberName(string form) AssertRejectsProductFields(RepeatedMemberNames[form]); } - // CONTRACT.md §1: an absent field is omitted, never written as null, at any depth. + // CONTRACT.agent-identity.md §1: an absent field is omitted, never written as null, at any depth. [Theory] [InlineData("psu_channel_url", "null")] [InlineData("policy_limits", """{"proxy": null}""")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index fc609e5c31..a1abadde25 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -15,7 +15,8 @@ public sealed record RequestBodyOutcome( CertificateSigningRequest? Csr); /// -/// The rules that every request body shares (CONTRACT.md §1 and §3); each derived class runs them on one body type. +/// The rules that every request body shares (CONTRACT.agent-identity.md §1 and §3); +/// each derived class runs them on one body type. /// public abstract class RequestBodyTests { @@ -29,7 +30,7 @@ public abstract class RequestBodyTests protected abstract RequestBodyOutcome Parse(byte[] utf8Json); - // CONTRACT.md §5.2, §5.3 and §5.6 define the bodies, and §3 their metadata. + // CONTRACT.agent-identity.md §5.2, §5.3 and §5.6 define the bodies, and §3 their metadata. [Fact] public void AcceptsValidBody() { @@ -38,7 +39,7 @@ public void AcceptsValidBody() Assert.Equal([new KeyValuePair("hostname", "host")], metadata); } - // CONTRACT.md §1: receivers ignore unknown JSON fields. + // CONTRACT.agent-identity.md §1: receivers ignore unknown JSON fields. // A name repeated in different objects is not a repeated member. [Theory] [InlineData(""" "extra": 1""")] @@ -62,14 +63,14 @@ public void ReturnsTheMetadataUnescapedInTheOrderSent() Assert.Equal(expected, AssertValid(outcome).Metadata!); } - // CONTRACT.md §3: metadata may be an empty object. + // CONTRACT.agent-identity.md §3: metadata may be an empty object. [Fact] public void AcceptsEmptyMetadata() { Assert.Empty(AssertValid(ParseText(Body("{}", ""))).Metadata!); } - // CONTRACT.md §3: metadata is required, and §1 reads a null member as an absent one. + // CONTRACT.agent-identity.md §3: metadata is required, and §1 reads a null member as an absent one. [Theory] [InlineData(null)] [InlineData("null")] @@ -78,7 +79,7 @@ public void RejectsMissingMetadata(string? metadata) AssertInvalid(ParseText(Body(metadata, ""))); } - // CONTRACT.md §3: metadata is a JSON object of string to string. + // CONTRACT.agent-identity.md §3: metadata is a JSON object of string to string. [Theory] [InlineData(""" "host" """)] [InlineData("[]")] @@ -102,7 +103,7 @@ public void RejectsMetadataValueThatIsNotAString(string metadata) AssertInvalid(ParseText(Body(metadata, ""))); } - // The limits of CONTRACT.md §3 apply to metadata in a body as they do anywhere else. + // The limits of CONTRACT.agent-identity.md §3 apply to metadata in a body as they do anywhere else. public static TheoryData MetadataBeyondTheLimits => new() { { "33 keys", JsonObject([.. Enumerable.Range(0, 33).Select(i => $"\"key_{i}\": \"value\"")]) }, @@ -120,8 +121,8 @@ public void RejectsMetadataBeyondTheLimits(string form, string metadata) AssertInvalid(ParseText(Body(metadata, "")), form); } - // CONTRACT.md §1: a body that repeats a member name at any depth, such as one metadata key twice, is invalid, - // whether the names are written alike or only unescape alike. + // CONTRACT.agent-identity.md §1: a body that repeats a member name at any depth, such as one metadata key twice, + // is invalid, whether the names are written alike or only unescape alike. [Theory] [InlineData("metadata", ValidMetadata, """ "metadata": {}""")] [InlineData("metadata with an escaped letter", ValidMetadata, """ "m\u0065tadata": {}""")] @@ -170,7 +171,8 @@ public void RejectsTruncatedBody() AssertInvalid(ParseText(Body(ValidMetadata, "")[..^1])); } - // CONTRACT.md §5.2, §5.3 and §5.6: each body is a JSON object; RFC 8259 §2: a JSON text holds one value. + // CONTRACT.agent-identity.md §5.2, §5.3 and §5.6: each body is a JSON object; + // RFC 8259 §2: a JSON text holds one value. [Theory] [InlineData("[]")] [InlineData(""" "body" """)] @@ -196,7 +198,7 @@ public void RejectsEscapedStringThatIsNotUnicode(string form, string extraMember AssertInvalid(ParseText(Body(ValidMetadata, extraMembers)), form); } - // CONTRACT.md §1 and RFC 8259 §8.1: JSON bodies are UTF-8. + // CONTRACT.agent-identity.md §1 and RFC 8259 §8.1: JSON bodies are UTF-8. // The bytes replace the character of a string in an unknown member. [Theory] [InlineData(new byte[] { 0xFF })] @@ -269,7 +271,7 @@ private static string NestedArrays(int depth) } /// -/// The rules of a body that carries a CSR (CONTRACT.md §1, §4, §5.2 and §5.3). +/// The rules of a body that carries a CSR (CONTRACT.agent-identity.md §1, §4, §5.2 and §5.3). /// public abstract class CsrRequestBodyTests : RequestBodyTests { @@ -294,7 +296,7 @@ public void ReturnsTheValidatedCsr() Assert.Equal(SHA256.HashData(expected), csr.SubjectPublicKeyInfoSha256.ToArray()); } - // CONTRACT.md §5.2 and §5.3: csr is required, and §1 reads a null member as an absent one. + // CONTRACT.agent-identity.md §5.2 and §5.3: csr is required, and §1 reads a null member as an absent one. [Theory] [InlineData(null)] [InlineData("null")] @@ -303,7 +305,7 @@ public void RejectsMissingCsr(string? csr) AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata))); } - // CONTRACT.md §1: a CSR is DER encoded base64 in a JSON string. + // CONTRACT.agent-identity.md §1: a CSR is DER encoded base64 in a JSON string. [Theory] [InlineData("1")] [InlineData("{}")] @@ -314,7 +316,7 @@ public void RejectsCsrThatIsNotAString(string csr) AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata))); } - // CONTRACT.md §1: base64 in any form but the canonical one is invalid. + // CONTRACT.agent-identity.md §1: base64 in any form but the canonical one is invalid. // Each test alters the canonical base64 of a request that the body accepts, // into a form that a lenient decoder would still read as that request. // The requests are built here rather than read from the vector file, @@ -380,7 +382,7 @@ public void RejectsEmptyCsr() AssertInvalid(ParseText(BodyWithCsr(Quote(""), ValidMetadata))); } - // CONTRACT.md §1 makes csr sent twice invalid_request. + // CONTRACT.agent-identity.md §1 makes csr sent twice invalid_request. [Fact] public void RejectsRepeatedCsr() { @@ -461,7 +463,7 @@ protected override RequestBodyOutcome Parse(byte[] utf8Json) public class CheckInRequestBodyTests : RequestBodyTests { - // CONTRACT.md §5.6: a check-in body has no csr, so a csr member is an unknown member. + // CONTRACT.agent-identity.md §5.6: a check-in body has no csr, so a csr member is an unknown member. [Fact] public void IgnoresCsrMember() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs index 9597658d7b..c36c9f370e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs @@ -5,11 +5,13 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The bodies of the successful responses of CONTRACT.md §5, each parsed with the BCL and checked against the contract. +/// The bodies of the successful responses of CONTRACT.agent-identity.md §5, +/// each parsed with the BCL and checked against the contract. /// public class ResponseBodiesTests { - // CONTRACT.md §1: IDs are lowercase hyphenated UUIDs; these have hexadecimal letters, so their case shows. + // CONTRACT.agent-identity.md §1: IDs are lowercase hyphenated UUIDs; these have hexadecimal letters, + // so their case shows. // The identity is device-a of the vector file, whose certificate the vector root issued. private const string AuthorityIdText = "7c9e6679-7425-40de-944b-e07fc1f90ae7"; private const string IdentityIdText = "0b3c6a52-7a38-4f55-9a1c-2f1a8d3c9e01"; @@ -49,7 +51,8 @@ public class ResponseBodiesTests public static TheoryData ConfigBodyNames => new(ConfigBodies.Keys); - // CONTRACT.md §5.1: each root has `certificate`, `thumbprint`, `not_before` and `not_after`, all strings. + // CONTRACT.agent-identity.md §5.1: each root has `certificate`, `thumbprint`, `not_before` and `not_after`, + // all strings. [Fact] public void TrustAnchorRootHasExactlyTheContractMembers() { @@ -62,8 +65,8 @@ public void TrustAnchorRootHasExactlyTheContractMembers() Assert.All(root.Values, value => Assert.Equal(JsonValueKind.String, value.ValueKind)); } - // CONTRACT.md §1: a certificate is its DER in canonical base64, and its thumbprint is base64url(SHA-256(DER)), - // which the vector file gives for its root. + // CONTRACT.agent-identity.md §1: a certificate is its DER in canonical base64, + // and its thumbprint is base64url(SHA-256(DER)), which the vector file gives for its root. [Fact] public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() { @@ -74,7 +77,7 @@ public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() Assert.Equal(TestVectors.Instance.Root.Thumbprint, root["thumbprint"].GetString()); } - // CONTRACT.md §1: timestamps are RFC 3339 UTC strings ending in `Z`. + // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`. // The validity is given here in offsets other than UTC, and the test machine's own time zone may be another. [Fact] public void TrustAnchorGivesTheValidityOfTheRootInUtc() @@ -90,7 +93,7 @@ public void TrustAnchorGivesTheValidityOfTheRootInUtc() Assert.Equal("2036-07-08T13:10:11Z", entry["not_after"].GetString()); } - // CONTRACT.md §5.1: the response lists one root normally and two during a rotation. + // CONTRACT.agent-identity.md §5.1: the response lists one root normally and two during a rotation. [Fact] public void TrustAnchorListsEveryRootInTheOrderGiven() { @@ -104,7 +107,7 @@ public void TrustAnchorListsEveryRootInTheOrderGiven() roots.Select(root => TestJson.CanonicalBase64(TestJson.Members(root)["certificate"]))); } - // CONTRACT.md §5.1: the response always lists a root, and two during a rotation. + // CONTRACT.agent-identity.md §5.1: the response always lists a root, and two during a rotation. [Fact] public void TrustAnchorRejectsNoRoots() { @@ -113,7 +116,7 @@ public void TrustAnchorRejectsNoRoots() Assert.Equal("roots", exception.ParamName); } - // CONTRACT.md §5.1: each root has a certificate, which a null root lacks. + // CONTRACT.agent-identity.md §5.1: each root has a certificate, which a null root lacks. [Fact] public void TrustAnchorRejectsANullRoot() { @@ -123,7 +126,8 @@ public void TrustAnchorRejectsANullRoot() Assert.Equal("roots", exception.ParamName); } - // CONTRACT.md §5.2: `authority_id`, `identity_id`, `certificate_chain` and `config`, every one of them required. + // CONTRACT.agent-identity.md §5.2: `authority_id`, `identity_id`, `certificate_chain` and `config`, + // every one of them required. // §1: IDs are lowercase hyphenated UUIDs. [Fact] public void EnrollHasExactlyTheContractMembers() @@ -141,7 +145,7 @@ public void EnrollHasExactlyTheContractMembers() } // RFC 9562 §5.9: the nil UUID, with all 128 bits zero, stands for the absence of a UUID, - // and CONTRACT.md §5.2 requires both IDs. + // and CONTRACT.agent-identity.md §5.2 requires both IDs. [Theory] [InlineData(NilIdText, IdentityIdText, "authorityId")] [InlineData(AuthorityIdText, NilIdText, "identityId")] @@ -153,7 +157,7 @@ public void EnrollRejectsANilId(string authorityId, string identityId, string pa Assert.Equal(parameter, exception.ParamName); } - // CONTRACT.md §5.3: `200`: `{ "certificate_chain": [ ... ] }`. + // CONTRACT.agent-identity.md §5.3: `200`: `{ "certificate_chain": [ ... ] }`. [Fact] public void RenewHasExactlyTheContractMembers() { @@ -164,7 +168,8 @@ public void RenewHasExactlyTheContractMembers() Assert.Equal(JsonValueKind.Array, members["certificate_chain"].ValueKind); } - // CONTRACT.md §5.6: `config` and `renewal_requested`, the identity's request-renewal flag as a JSON boolean. + // CONTRACT.agent-identity.md §5.6: `config` and `renewal_requested`, + // the identity's request-renewal flag as a JSON boolean. [Theory] [InlineData(true, JsonValueKind.True)] [InlineData(false, JsonValueKind.False)] @@ -178,7 +183,7 @@ public void CheckInHasExactlyTheContractMembers(bool renewalRequested, JsonValue Assert.Equal(expected, members["renewal_requested"].ValueKind); } - // CONTRACT.md §4: chains are leaf-first; §1: each certificate is its DER in canonical base64. + // CONTRACT.agent-identity.md §4: chains are leaf-first; §1: each certificate is its DER in canonical base64. [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainIsWrittenLeafFirst(string endpoint) @@ -191,7 +196,7 @@ public void ChainIsWrittenLeafFirst(string endpoint) chain.Select(TestJson.CanonicalBase64)); } - // CONTRACT.md §4: a chain has any length, so the leaf alone is a chain too. + // CONTRACT.agent-identity.md §4: a chain has any length, so the leaf alone is a chain too. [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainOfTheLeafAloneIsWritten(string endpoint) @@ -227,7 +232,7 @@ public void ChainWithACertificateThatDidNotIssueThePreviousOneIsRejected(string Assert.Equal("certificateChain", exception.ParamName); } - // CONTRACT.md §4 and §5.2: a chain starts with the leaf, so it holds at least one certificate. + // CONTRACT.agent-identity.md §4 and §5.2: a chain starts with the leaf, so it holds at least one certificate. [Theory] [MemberData(nameof(ChainBodyNames))] public void EmptyChainIsRejected(string endpoint) @@ -237,7 +242,7 @@ public void EmptyChainIsRejected(string endpoint) Assert.Equal("certificateChain", exception.ParamName); } - // CONTRACT.md §1: each certificate of a chain is written as its DER, which a null certificate lacks. + // CONTRACT.agent-identity.md §1: each certificate of a chain is written as its DER, which a null certificate lacks. [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithANullCertificateIsRejected(string endpoint) @@ -248,7 +253,7 @@ public void ChainWithANullCertificateIsRejected(string endpoint) Assert.Equal("certificateChain", exception.ParamName); } - // CONTRACT.md §5.2 and §5.6: `config` is the identity's config, in the shape of §5.2. + // CONTRACT.agent-identity.md §5.2 and §5.6: `config` is the identity's config, in the shape of §5.2. [Theory] [MemberData(nameof(ConfigBodyNames))] public void BodyCarriesTheConfig(string endpoint) @@ -263,7 +268,8 @@ public void BodyCarriesTheConfig(string endpoint) Assert.Equal("https://psu/channel", config["psu_channel_url"].GetString()); } - // CONTRACT.md §5.2 and §5.6: `config` is the identity's config, and IdentityConfig.Create sets no depth limit. + // CONTRACT.agent-identity.md §5.2 and §5.6: `config` is the identity's config, + // and IdentityConfig.Create sets no depth limit. // The value nests 64 levels, so the body nests 66, beyond the 64 that a System.Text.Json reader allows by default. [Theory] [MemberData(nameof(ConfigBodyNames))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 10e8af3209..cbebf7f56f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -19,8 +19,8 @@ public class CertificateSigningRequestTests private static readonly BigInteger P256B = Unsigned( Convert.FromHexString("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B")); - // CONTRACT.md §4: the server ignores the CSR's subject and attributes, including requested extensions, - // and security invariant 2 lets a CSR supply only a verified public key. + // CONTRACT.agent-identity.md §4: the server ignores the CSR's subject and attributes, + // including requested extensions, and security invariant 2 lets a CSR supply only a verified public key. private static readonly Dictionary> RequestsWithIgnoredContents = new() { ["subject, attributes and extensions"] = key => @@ -36,11 +36,11 @@ public class CertificateSigningRequestTests return request.CreateSigningRequest(); }, - // CONTRACT.md §4: the agent's CSR carries an empty attribute set, A0 00. + // CONTRACT.agent-identity.md §4: the agent's CSR carries an empty attribute set, A0 00. ["empty attribute set"] = key => TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: [0xA0, 0x00]), key), - // CONTRACT.md §4: the server ignores an extensionRequest with an empty Extensions list, + // CONTRACT.agent-identity.md §4: the server ignores an extensionRequest with an empty Extensions list, // which common CSR builders emit although RFC 5280 §4.1 gives Extensions SIZE (1..MAX). // The attribute is the PKCS #9 extensionRequest (RFC 2985 §5.4.2) with one value, an empty SEQUENCE. ["extensionRequest with empty Extensions"] = key => @@ -54,7 +54,8 @@ public class CertificateSigningRequestTests return TestCsr.Request(info, key); }, - // CONTRACT.md §4: attributes need no "canonical DER form, such as a sorted `SET OF`" (X.690 §11.6). + // CONTRACT.agent-identity.md §4: attributes need no "canonical DER form, + // such as a sorted `SET OF`" (X.690 §11.6). // Their encodings first differ in the last arc of the OIDs, 04 then 03, so the set descends. ["attributes out of DER order"] = key => { @@ -129,7 +130,7 @@ static byte[] Attribute(string oid) => private static readonly Dictionary> RequestsForAKeyNotNamedP256 = new() { - // CONTRACT.md §4: the key is a P-256 key. + // CONTRACT.agent-identity.md §4: the key is a P-256 key. ["P-384 key"] = () => { using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP384); @@ -165,7 +166,7 @@ static byte[] Attribute(string oid) => }, }; - // CONTRACT.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. + // CONTRACT.agent-identity.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. private static readonly Dictionary> PointsNotUncompressed = new() { // SEC 1 §2.3.3: the compressed form is 02 for an even y or 03 for an odd y, then x. @@ -249,7 +250,7 @@ static byte[] Attribute(string oid) => public static TheoryData PointWithACoordinateOutOfTheFieldNames => new(PointsWithACoordinateOutOfTheField.Keys); - // test-vectors.json csr..expected, the outcome CONTRACT.md §4 gives each request. + // test-vectors.json csr..expected, the outcome CONTRACT.agent-identity.md §4 gives each request. [Theory] [MemberData(nameof(VectorData.CsrNames), MemberType = typeof(VectorData))] public void CsrVectorHasItsExpectedOutcome(string name) @@ -276,7 +277,7 @@ public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() } // A request written by .NET rather than by the test builder. - // CONTRACT.md §4: a P-256 key and ecdsa-with-SHA256, the profile .NET writes for this key. + // CONTRACT.agent-identity.md §4: a P-256 key and ecdsa-with-SHA256, the profile .NET writes for this key. [Fact] public void AcceptsRequestWrittenByDotNet() { @@ -311,7 +312,7 @@ public void RejectsBytesThatAreNotARequest(string hex) AssertInvalid(CertificateSigningRequest.Validate(Convert.FromHexString(hex))); } - // CONTRACT.md §1: a CSR is DER, the encoding of one request. + // CONTRACT.agent-identity.md §1: a CSR is DER, the encoding of one request. // Bytes after the request would give one request many encodings. [Fact] public void RejectsTrailingBytes() @@ -394,7 +395,7 @@ public void RejectsKeyNotNamedP256(string key) AssertInvalid(CertificateSigningRequest.Validate(RequestsForAKeyNotNamedP256[key]())); } - // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; CONTRACT.md §4 needs a signing key. + // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; CONTRACT.agent-identity.md §4 needs a signing key. [Fact] public void RejectsKeyRestrictedToKeyAgreement() { @@ -424,7 +425,7 @@ public void RejectsPointNotUncompressed(string point) AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, PointsNotUncompressed[point](key)))); } - // CONTRACT.md §4: the key is a point on P-256. + // CONTRACT.agent-identity.md §4: the key is a point on P-256. // Flipping the low bit of y leaves the point off the curve, // since the only other point with the same x has y' = p - y, which is y + 1 or y - 1 only for y = (p ± 1) / 2. // No private key exists for such a point, so the key whose point was altered signs the request. @@ -440,7 +441,7 @@ public void RejectsPointOffTheCurve() } // SEC 1 §3.2.2.1: the coordinates of a public key are integers below p, - // and CONTRACT.md §4 gives a key that is not a P-256 point invalid_request, not an exception. + // and CONTRACT.agent-identity.md §4 gives a key that is not a P-256 point invalid_request, not an exception. // Each point is on the curve modulo p, so only the range of its coordinates rejects it; // a platform's key import throws an exception of a platform-specific type for such a point. // The private key of either point is unknown, and the key is invalid whatever the signature, @@ -467,7 +468,8 @@ public void RejectsBitStringWithUnusedBits(string bitString) AssertInvalid(CertificateSigningRequest.Validate(RequestsWithUnusedBits[bitString]())); } - // CONTRACT.md §4: the signature algorithm is ecdsa-with-SHA256, even when the signature verifies under it. + // CONTRACT.agent-identity.md §4: the signature algorithm is ecdsa-with-SHA256, + // even when the signature verifies under it. [Fact] public void RejectsAnotherSignatureAlgorithmOverASha256Signature() { @@ -548,7 +550,7 @@ private static CertificateSigningRequest AssertValid(CsrValidationResult result) return result.Csr; } - // CONTRACT.md §4: a CSR that breaks a rule gets invalid_request, which §5.4 maps to 400. + // CONTRACT.agent-identity.md §4: a CSR that breaks a rule gets invalid_request, which §5.4 maps to 400. private static void AssertInvalid(CsrValidationResult result) { Assert.False(result.Succeeded); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 74ea32bb64..31734ebfc4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -6,10 +6,11 @@ namespace Devolutions.AgentIdentity.Tests; // Each test decodes the issued certificate with the BCL and System.Formats.Asn1, never with the library, -// and checks it against CONTRACT.md §4 and RFC 5280. +// and checks it against CONTRACT.agent-identity.md §4 and RFC 5280. public sealed class IdentityCertificateIssuerTests : IDisposable { - // CONTRACT.md §1: IDs are lowercase hyphenated UUIDs; this one has hexadecimal letters, so its case shows. + // CONTRACT.agent-identity.md §1: IDs are lowercase hyphenated UUIDs; this one has hexadecimal letters, + // so its case shows. private const string IdentityIdText = "0b3c6a52-7a38-4f55-9a1c-2f1a8d3c9e01"; // RFC 5280 §4.2.1.12: id-kp-clientAuth. @@ -52,7 +53,7 @@ public void Dispose() _random.Dispose(); } - // CONTRACT.md §4: the certificate is for the P-256 key of the CSR. + // CONTRACT.agent-identity.md §4: the certificate is for the P-256 key of the CSR. [Fact] public void CertifiesTheKeyOfTheCsr() { @@ -61,7 +62,7 @@ public void CertifiesTheKeyOfTheCsr() Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo()); } - // CONTRACT.md §4: CN=, and the root's subject as the issuer. + // CONTRACT.agent-identity.md §4: CN=, and the root's subject as the issuer. [Fact] public void SubjectIsTheCommonNameOfTheIdentity() { @@ -73,7 +74,7 @@ public void SubjectIsTheCommonNameOfTheIdentity() Assert.Equal(_root.SubjectName.RawData, leaf.IssuerName.RawData); } - // CONTRACT.md §4: SAN URI urn:uuid:. + // CONTRACT.agent-identity.md §4: SAN URI urn:uuid:. // RFC 5280 §4.2.1.6: GeneralNames is a SEQUENCE OF GeneralName, and a URI is [6] IA5String; // with a subject present, the extension is not critical. [Fact] @@ -91,7 +92,7 @@ public void AlternativeNameIsTheUrnUuidOfTheIdentity() Assert.False(extension.Critical); } - // CONTRACT.md §4: EKU clientAuth. + // CONTRACT.agent-identity.md §4: EKU clientAuth. [Fact] public void ExtendedKeyUsageIsClientAuthenticationOnly() { @@ -103,7 +104,7 @@ public void ExtendedKeyUsageIsClientAuthenticationOnly() Assert.False(extension.Critical); } - // CONTRACT.md §4: KeyUsage digitalSignature; RFC 5280 §4.2.1.3: marked critical. + // CONTRACT.agent-identity.md §4: KeyUsage digitalSignature; RFC 5280 §4.2.1.3: marked critical. [Fact] public void KeyUsageIsCriticalDigitalSignatureOnly() { @@ -220,7 +221,7 @@ public void NotBeforeIsTheClockTruncatedToTheSecond() Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); } - // CONTRACT.md §4: the lifetime is a server setting. + // CONTRACT.agent-identity.md §4: the lifetime is a server setting. // RFC 5280 §4.1.2.5: a certificate holds whole seconds, so a fraction of a second in the lifetime is dropped, // since rounding it up would exceed the lifetime. [Theory] @@ -235,7 +236,7 @@ public void NotAfterIsNotBeforePlusTheWholeSecondsOfTheLifetime(int lifetimeMill Assert.Equal(NowInSeconds.AddDays(30).UtcDateTime, leaf.NotAfter.ToUniversalTime()); } - // CONTRACT.md §4: the lifetime is capped at the issuing root's notAfter. + // CONTRACT.agent-identity.md §4: the lifetime is capped at the issuing root's notAfter. [Fact] public void NotAfterIsCappedAtTheNotAfterOfTheIssuer() { @@ -246,14 +247,14 @@ public void NotAfterIsCappedAtTheNotAfterOfTheIssuer() Assert.Equal(NowInSeconds.AddDays(10).UtcDateTime, leaf.NotAfter.ToUniversalTime()); } - // CONTRACT.md §4: the lifetime is a server setting with a default of 90 days. + // CONTRACT.agent-identity.md §4: the lifetime is a server setting with a default of 90 days. [Fact] public void DefaultLifetimeIs90Days() { Assert.Equal(TimeSpan.FromDays(90), IdentityCertificateIssuer.DefaultLifetime); } - // CONTRACT.md §4: the lifetime is a server setting, + // CONTRACT.agent-identity.md §4: the lifetime is a server setting, // and reading a moving clock twice would give the certificate a validity other than its lifetime. [Fact] public void ReadsTheClockOnce() @@ -265,7 +266,7 @@ public void ReadsTheClockOnce() } // The certificate chains to the root that issued it, for client authentication. - // RFC 5280 §6 path validation, as X509Chain runs it, with the CONTRACT.md §4 EKU clientAuth. + // RFC 5280 §6 path validation, as X509Chain runs it, with the CONTRACT.agent-identity.md §4 EKU clientAuth. [Fact] public void ChainsToTheIssuer() { @@ -313,7 +314,7 @@ public void ThrowsForLifetimeShorterThanOneSecond(int milliseconds) Assert.Throws(() => Issue(lifetime: TimeSpan.FromMilliseconds(milliseconds))); } - // CONTRACT.md §4: notAfter is at most the issuer's notAfter, + // CONTRACT.agent-identity.md §4: notAfter is at most the issuer's notAfter, // so an issuer that expires by notBefore leaves no validity. [Theory] [InlineData(0)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs index c94b446c55..1b77d0c32e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs @@ -1,7 +1,7 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The CSR key rules of a renew request, CONTRACT.md §5.3. +/// The CSR key rules of a renew request, CONTRACT.agent-identity.md §5.3. /// /// /// Every request is a renew request that device-a signed; device-b of the vector file is another identity. @@ -10,8 +10,8 @@ public class RenewalRulesTests { private static readonly Guid OtherIdentityId = TestVectors.Instance.Key("device-b").IdentityId; - // CONTRACT.md §5.3: the key of any other certificate that the server has issued, including this identity's - // current or retired certificates and every certificate of another identity, gets invalid_request. + // CONTRACT.agent-identity.md §5.3: the key of any other certificate that the server has issued, including this + // identity's current or retired certificates and every certificate of another identity, gets invalid_request. // A deleted identity's certificates are certificates that the server has issued. private static readonly Dictionary> OtherIssuedKeys = new() { @@ -32,7 +32,7 @@ public class RenewalRulesTests public static TheoryData OtherIssuedKeyNames => new(OtherIssuedKeys.Keys); - // CONTRACT.md §5.3: if the CSR key is the key of the identity's pending certificate, + // CONTRACT.agent-identity.md §5.3: if the CSR key is the key of the identity's pending certificate, // the server returns 200 with that pending chain, a retry after a lost response. // A renew request presents no enrollment token, so whatever the host says about one does not count. [Theory] @@ -68,7 +68,7 @@ public async Task RejectsAnyOtherIssuedKeyWithInvalidRequest(string key) Assert.False(string.IsNullOrEmpty(decision.FailureDetail)); } - // CONTRACT.md §5.3: if the CSR key was never issued, the server issues a new pending certificate + // CONTRACT.agent-identity.md §5.3: if the CSR key was never issued, the server issues a new pending certificate // and retires the identity's previous pending certificate, if any. [Fact] public async Task IssuesAPendingCertificateForAKeyNeverIssued() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index 1ffc238ceb..a4aa2dfbbf 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -8,7 +8,8 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The channel handshake of CONTRACT.md §7.3 steps 3 and 4: the first agent message and the Welcome that answers it. +/// The channel handshake of CONTRACT.agent-identity.md §7.3 steps 3 and 4: +/// the first agent message and the Welcome that answers it. /// public class ChannelHandshakeTests { @@ -151,8 +152,8 @@ public static TheoryData HandshakeEndsThenAnotherCall private static byte[] ValidProof => TestVectors.Instance.ChannelProof.Case("valid").Proof; - // CONTRACT.md §7.4: the closures that apply to a stream even while it awaits its Hello, in the order the - // handshake checks them, with the code and the gRPC status that close the stream. + // CONTRACT.agent-identity.md §7.4: the closures that apply to a stream even while it awaits its Hello, + // in the order the handshake checks them, with the code and the gRPC status that close the stream. // The certificate of device-a expires 70 days after the vector opening, so a reading after its notAfter is also // after the Hello timeout. private static OrderedDictionary Closures => new() @@ -181,7 +182,7 @@ public static TheoryData HandshakeEndsThenAnotherCall StatusCode.Unauthenticated), }; - // CONTRACT.md §7.3 step 4: every message that breaks one of its rules, + // CONTRACT.agent-identity.md §7.3 step 4: every message that breaks one of its rules, // each of SignatureFailures with signature_invalid, and invalid metadata in a valid Hello with invalid_request. private static Dictionary MessageFailures => SignatureFailures @@ -260,7 +261,7 @@ public static TheoryData HandshakeEndsThenAnotherCall private static DateTimeOffset DeviceANotAfter => TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter; - // CONTRACT.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. + // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. [Theory] [MemberData(nameof(SignatureFailureNames))] public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string failure) @@ -270,7 +271,7 @@ public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string fail AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } - // CONTRACT.md §7.3 step 4: a Hello that proves the key but carries metadata violating §3 is + // CONTRACT.agent-identity.md §7.3 step 4: a Hello that proves the key but carries metadata violating §3 is // INVALID_ARGUMENT with error-code invalid_request. [Fact] public async Task HelloWithInvalidMetadataIsInvalidRequest() @@ -286,12 +287,13 @@ public async Task HelloWithInvalidMetadataIsInvalidRequest() // The handshake checks its failures in an order, and a stream that fails two of them closes as it would for the // earlier one alone. - // CONTRACT.md §6 step 4 and §7.4: the registry and the clock decide before the message, in this order: + // CONTRACT.agent-identity.md §6 step 4 and §7.4: the registry and the clock decide before the message, + // in this order: // a certificate no longer registered, a revoked identity, an expired certificate, then a late Hello. // A stream that fails one of them is closed for that reason, whatever the message holds, and so is a stream // without Hello, which gets identity_unknown for an identity revoked and then deleted. // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. - // CONTRACT.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, + // CONTRACT.agent-identity.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, // so an agent that has not proved the key never learns whether its metadata would pass. [Theory] [MemberData(nameof(PairsOfFailuresWithDifferentCodes))] @@ -306,7 +308,7 @@ public async Task EarlierFailureWins(string earlier, string later) // Two failures that both close the stream with signature_invalid differ only in their failure detail, // so the detail shows that the earlier one closed it. - // oracle: self-derived; CONTRACT.md §7.3 step 4 fixes no failure detail, + // oracle: self-derived; CONTRACT.agent-identity.md §7.3 step 4 fixes no failure detail, // so the expected one is the handshake's own for the earlier failure alone. [Theory] [MemberData(nameof(PairsOfFailuresWithTheSameCode))] @@ -325,7 +327,8 @@ public async Task EarlierOfTwoSignatureFailuresWins(string earlier, string later // The shared intent, security invariant 5: revocation blocks authentication, including of a stream whose identity // the host cannot know yet. - // CONTRACT.md §7.4: PERMISSION_DENIED with the trailers `error-code: identity_revoked` and `server-time`. + // CONTRACT.agent-identity.md §7.4: PERMISSION_DENIED + // with the trailers `error-code: identity_revoked` and `server-time`. [Fact] public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() { @@ -340,7 +343,7 @@ public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() error.ToRpcException(serverTime).Trailers.Select(entry => (entry.Key, entry.Value))); } - // CONTRACT.md §6 step 4: a certificate that is no longer registered, or whose identity was deleted, + // CONTRACT.agent-identity.md §6 step 4: a certificate that is no longer registered, or whose identity was deleted, // is identity_unknown. [Fact] public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() @@ -351,10 +354,11 @@ public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() } // The identity reports the status that the registry holds for the opening certificate when the handshake ends. - // CONTRACT.md §6 table: a channel opening accepts a pending certificate as well as a current one. - // CONTRACT.md §5.5: a confirm during the handshake makes a pending certificate current. - // CONTRACT.md §7.4: a stream whose certificate is retired while it awaits its Hello still completes the handshake, - // then gets the treatment of every stream of a retired certificate, so the result reports the retirement. + // CONTRACT.agent-identity.md §6 table: a channel opening accepts a pending certificate as well as a current one. + // CONTRACT.agent-identity.md §5.5: a confirm during the handshake makes a pending certificate current. + // CONTRACT.agent-identity.md §7.4: a stream whose certificate is retired while it awaits its Hello still completes + // the handshake, then gets the treatment of every stream of a retired certificate, + // so the result reports the retirement. // Failing it would send a healthy agent the terminal identity_unknown. [Theory] [InlineData(CertificateStatus.Pending, CertificateStatus.Pending)] @@ -375,7 +379,7 @@ public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake( } // A retired certificate passes the handshake, but the revocation of its identity still blocks it. - // CONTRACT.md §7.4: revocation closes a stream still awaiting its Hello, with PERMISSION_DENIED and + // CONTRACT.agent-identity.md §7.4: revocation closes a stream still awaiting its Hello, with PERMISSION_DENIED and // identity_revoked. [Fact] public async Task RevocationWinsOverARetiredCertificate() @@ -387,8 +391,8 @@ public async Task RevocationWinsOverARetiredCertificate() } // Whatever the outcome, the handshake reads the registry once, for the certificate that opened the stream. - // CONTRACT.md §7.4 applies a deletion or a revocation since the opening to a stream awaiting its Hello, - // so the handshake reads the registry again; that it reads it exactly once is the library's API. + // CONTRACT.agent-identity.md §7.4 applies a deletion or a revocation since the opening to a stream awaiting its + // Hello, so the handshake reads the registry again; that it reads it exactly once is the library's API. [Theory] [MemberData(nameof(ScenarioNames))] public async Task HandshakeReadsTheRegistryOnce(string scenario) @@ -403,7 +407,7 @@ public async Task HandshakeReadsTheRegistryOnce(string scenario) // The registry answers a thumbprint with the certificate issued under it, for its one identity, // so another answer is a host bug, which must fail as a server error rather than draw an outcome the agent acts on. - // CONTRACT.md §5.4: an unexpected server failure is the transient `500 unavailable`, + // CONTRACT.agent-identity.md §5.4: an unexpected server failure is the transient `500 unavailable`, // whereas identity_unknown and identity_revoked are terminal; the exception type is the library's API. [Theory] [MemberData(nameof(WrongRegistryAnswersAndCalls))] @@ -418,7 +422,7 @@ public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string // The handshake ends when VerifyHelloAsync is called, not when the registry answers, // so a Hello timer that fires during the lookup cannot end the handshake a second time. - // CONTRACT.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. + // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. // Only the library's API decides that the call, not the answer, ends the handshake, // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call // that the object's state does not allow. @@ -442,7 +446,7 @@ public async Task HandshakeEndsBeforeTheRegistryAnswers() // The timeout ends the handshake when HelloTimedOutAsync is called too, // so a Hello that arrives during its lookup cannot authenticate a stream that is being closed. - // CONTRACT.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both, + // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both, // and closes it for the timeout with signature_invalid. // Only the library's API decides that the call, not the answer, ends the handshake, // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call @@ -465,7 +469,7 @@ public async Task TimeoutEndsTheHandshakeBeforeTheRegistryAnswers() AssertFailed(await timingOut, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } - // CONTRACT.md §6 reads the clock before the registry, and so does the handshake, + // CONTRACT.agent-identity.md §6 reads the clock before the registry, and so does the handshake, // so a Hello that arrives in time stays in time however long the registry takes to answer. [Fact] public async Task SlowRegistryDoesNotMakeAHelloLate() @@ -486,8 +490,8 @@ public async Task SlowRegistryDoesNotMakeAHelloLate() Assert.True(result.Succeeded, result.FailureDetail); } - // CONTRACT.md §7.3 step 4: the server verifies the proof with the key of the certificate that authenticated the - // opening request, so the stream is authenticated for that certificate and its identity. + // CONTRACT.agent-identity.md §7.3 step 4: the server verifies the proof with the key of the certificate that + // authenticated the opening request, so the stream is authenticated for that certificate and its identity. // The vector file states the facts of the certificate, in Unix seconds, independently of any time zone. [Fact] public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() @@ -510,7 +514,8 @@ public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() Assert.Null(result.FailureDetail); } - // CONTRACT.md §3: the server replaces the identity's metadata with the metadata of each authenticated Hello. + // CONTRACT.agent-identity.md §3: the server replaces the identity's metadata + // with the metadata of each authenticated Hello. [Fact] public async Task ValidHelloYieldsItsMetadata() { @@ -522,7 +527,7 @@ public async Task ValidHelloYieldsItsMetadata() Assert.Equal(VectorChannel.ValidMetadata, result.Metadata); } - // CONTRACT.md §3: metadata may be an empty object. + // CONTRACT.agent-identity.md §3: metadata may be an empty object. // A protobuf map without entries is also how a Hello without metadata arrives, // so the rule that rejects a JSON body without metadata cannot apply here. [Fact] @@ -538,8 +543,8 @@ public async Task HelloWithoutMetadataSucceeds() Assert.Empty(result.Metadata); } - // CONTRACT.md §7.3 step 4: Welcome has correlation_id = Hello id. - // CONTRACT.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. + // CONTRACT.agent-identity.md §7.3 step 4: Welcome has correlation_id = Hello id. + // CONTRACT.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. // The Hello id is in uppercase, so a Welcome that normalized it would no longer match what the agent sent. [Fact] public async Task WelcomeAnswersTheHello() @@ -579,7 +584,8 @@ public async Task WelcomeCarriesTheServerTimeOfTheHandshake() Assert.Equal(250_000_000, serverTime.Nanos); } - // CONTRACT.md §7.3 step 3: the Hello answers the Challenge id, with a proof over the challenge bytes. + // CONTRACT.agent-identity.md §7.3 step 3: the Hello answers the Challenge id, + // with a proof over the challenge bytes. // The agent answers the Challenge it received, so the handshake holds the Hello to the id and bytes as sent, // even if the host later edits the message object it sent. [Fact] @@ -595,7 +601,7 @@ public async Task HandshakeChecksTheChallengeAsSent() Assert.True(result.Succeeded, result.FailureDetail); } - // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + // CONTRACT.agent-identity.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. // The Welcome shows that the clock reading that judged the certificate is the one at notAfter. [Fact] public async Task HandshakeSucceedsAtTheCertificateNotAfter() @@ -608,9 +614,9 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() Assert.Equal(Timestamp.FromDateTimeOffset(DeviceANotAfter), result.Welcome.Welcome.ServerTime); } - // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. - // CONTRACT.md §7.4 and the shared intent, security invariant 8: a stream closes no later than the notAfter of the - // certificate that opened it, with UNAUTHENTICATED and error-code certificate_expired, + // CONTRACT.agent-identity.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + // CONTRACT.agent-identity.md §7.4 and the shared intent, security invariant 8: a stream closes no later than the + // notAfter of the certificate that opened it, with UNAUTHENTICATED and error-code certificate_expired, // even while it waits for its Hello, so the timeout fails as a timeout only until then. [Theory] [InlineData("a valid Hello", 1, AgentIdentityErrorCode.CertificateExpired)] @@ -629,9 +635,9 @@ public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( AssertFailed(result, expectedCode, StatusCode.Unauthenticated); } - // CONTRACT.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. - // CONTRACT.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the failures of §7.3 - // step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. + // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. + // CONTRACT.agent-identity.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the + // failures of §7.3 step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. [Theory] [MemberData(nameof(NoHelloNames))] public async Task NoHelloInTimeIsSignatureInvalidUnlessAClosureApplies(string noHello) @@ -643,14 +649,14 @@ public async Task NoHelloInTimeIsSignatureInvalidUnlessAClosureApplies(string no AssertFailed(result, expectedCode, expectedStatus); } - // CONTRACT.md §7.3 step 4: the server waits 10 s for the Hello. + // CONTRACT.agent-identity.md §7.3 step 4: the server waits 10 s for the Hello. [Fact] public void HelloTimeoutIs10Seconds() { Assert.Equal(TimeSpan.FromSeconds(10), ChannelHandshake.HelloTimeout); } - // CONTRACT.md §7.3 step 4: the Hello must arrive within 10 s of the opening, 10 s included. + // CONTRACT.agent-identity.md §7.3 step 4: the Hello must arrive within 10 s of the opening, 10 s included. // The Welcome shows that the clock reading that judged the timeout is the one at 10 s. [Fact] public async Task HelloExactlyAtTheTimeoutSucceeds() @@ -666,7 +672,7 @@ public async Task HelloExactlyAtTheTimeoutSucceeds() Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime); } - // CONTRACT.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. + // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. // A Hello that the host passes on after the timeout fails, so a late host timer cannot let it through. [Fact] public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() @@ -680,7 +686,7 @@ public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } - // CONTRACT.md §7.3 steps 1 and 2: the agent sends nothing until it receives the Challenge, + // CONTRACT.agent-identity.md §7.3 steps 1 and 2: the agent sends nothing until it receives the Challenge, // which the server sends once it has verified the opening request. // The agent can answer only once the opening is verified and its challenge sent, // so the 10 s run from the end of verification, not from the earlier reading that judged the request. @@ -700,7 +706,7 @@ public async Task HelloTimeoutRunsFromTheEndOfTheOpening() // The first agent message decides the stream: a second one could retry a failed proof, // and a late Hello could authenticate a stream that already timed out. - // CONTRACT.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. + // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. // Only the library's API decides that a later call throws InvalidOperationException, // which the Framework Design Guidelines give a call that the object's state does not allow. [Theory] @@ -713,7 +719,7 @@ public async Task HandshakeEndsAtTheFirstCall(string first, string second) await Assert.ThrowsAsync(async () => await HandshakeEnds[second](opening)); } - // CONTRACT.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure + // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure // and the trailers `error-code: <§5.4 code>` and `server-time: `, which the agent reads. [Theory] [InlineData("a Hello with the proof of another device", StatusCode.Unauthenticated, "signature_invalid")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs index fa80406f9c..5ea16830b7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs @@ -8,7 +8,7 @@ public class ChannelHandshakeVectorTests { private static ChannelProofVectors Vectors => TestVectors.Instance.ChannelProof; - // CONTRACT.md §7.3 step 4: a proof that does not verify closes the stream with signature_invalid. + // CONTRACT.agent-identity.md §7.3 step 4: a proof that does not verify closes the stream with signature_invalid. // Each failing case changes the key, one part of the signed bytes, or the encoding of the proof. [Theory] [MemberData(nameof(VectorData.ChannelProofCaseNames), MemberType = typeof(VectorData))] @@ -24,7 +24,8 @@ public async Task CaseMatchesItsExpectedValidity(string name) Assert.Equal(vector.ExpectedValid ? "ok" : "signature_invalid", outcome); } - // CONTRACT.md §7.3 step 3: the proof signs the domain, a zero byte, the challenge and the connect nonce. + // CONTRACT.agent-identity.md §7.3 step 3: the proof signs the domain, a zero byte, + // the challenge and the connect nonce. // A proof input that differs from the vector's in any byte fails every agent's proof. [Fact] public void ProofInputIsTheVectorMessage() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs index 81bd1d8753..c34be70169 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs @@ -6,7 +6,8 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The messages that the authority pushes on an authenticated agent channel stream, CONTRACT.md §7.3 and §7.4. +/// The messages that the authority pushes on an authenticated agent channel stream, +/// CONTRACT.agent-identity.md §7.3 and §7.4. /// public class ChannelMessagesTests { @@ -28,7 +29,7 @@ public class ChannelMessagesTests public static TheoryData MessageNames => new(Messages.Keys); - // CONTRACT.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. + // CONTRACT.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. // A push answers no agent message, so it has no correlation_id. [Theory] [MemberData(nameof(MessageNames))] @@ -40,7 +41,8 @@ public void MessageHasALowercaseUuidIdAndNoCorrelationId(string message) Assert.False(built.HasCorrelationId); } - // CONTRACT.md §7.3 steps 5 and 7: the agent acknowledges a message by its id, so no two messages share one. + // CONTRACT.agent-identity.md §7.3 steps 5 and 7: the agent acknowledges a message by its id, + // so no two messages share one. [Theory] [MemberData(nameof(MessageNames))] public void EachMessageHasItsOwnId(string message) @@ -48,7 +50,7 @@ public void EachMessageHasItsOwnId(string message) Assert.NotEqual(Messages[message]().Id, Messages[message]().Id); } - // CONTRACT.md §7.3 step 7 and channel.proto: ConfigUpdate carries the config, in the shape of §5.2, + // CONTRACT.agent-identity.md §7.3 step 7 and channel.proto: ConfigUpdate carries the config, in the shape of §5.2, // as a UTF-8 JSON object. [Fact] public void ConfigUpdateCarriesTheConfigJson() @@ -65,7 +67,7 @@ public void ConfigUpdateCarriesTheConfigJson() } // channel.proto: the reason of RenewRequested is "admin" or "rotation"; - // CONTRACT.md §8.3 sends "rotation" at the start of a root rotation. + // CONTRACT.agent-identity.md §8.3 sends "rotation" at the start of a root rotation. [Theory] [InlineData(RenewalRequestReason.Admin, "admin")] [InlineData(RenewalRequestReason.Rotation, "rotation")] @@ -77,7 +79,8 @@ public void RenewRequestedCarriesItsReason(RenewalRequestReason reason, string e Assert.Equal(expected, message.RenewRequested.Reason); } - // CONTRACT.md §7.4: the streams of a retired certificate get Reconnect{reason:"certificate_rotated"}. + // CONTRACT.agent-identity.md §7.4: the streams of a retired certificate get + // Reconnect{reason:"certificate_rotated"}. [Fact] public void ReconnectCarriesItsReason() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index de3238f0c2..30f69eb1c8 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -6,7 +6,7 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The channel opening of CONTRACT.md §7.3 steps 1 and 2: +/// The channel opening of CONTRACT.agent-identity.md §7.3 steps 1 and 2: /// the verified opening request and the challenge that follows it. /// public class ChannelOpeningTests @@ -15,8 +15,9 @@ public class ChannelOpeningTests private static ChannelProofVectors ChannelProof => TestVectors.Instance.ChannelProof; - // CONTRACT.md §7.3 step 2: the first message is Challenge, with 32 random bytes fresh for this stream. - // CONTRACT.md §1 and §7.3: ids are lowercase hyphenated UUIDs. + // CONTRACT.agent-identity.md §7.3 step 2: the first message is Challenge, + // with 32 random bytes fresh for this stream. + // CONTRACT.agent-identity.md §1 and §7.3: ids are lowercase hyphenated UUIDs. // The bytes come from the injected generator, the host's source of every secret byte. [Fact] public async Task ChallengeHolds32BytesFromTheInjectedGenerator() @@ -32,7 +33,7 @@ public async Task ChallengeHolds32BytesFromTheInjectedGenerator() Assert.False(challenge.HasCorrelationId); } - // CONTRACT.md §7.3 step 2: the challenge holds 32 random bytes fresh for this stream. + // CONTRACT.agent-identity.md §7.3 step 2: the challenge holds 32 random bytes fresh for this stream. // A challenge reused across streams would let a proof captured on one stream authenticate another. [Fact] public async Task EachOpeningHasItsOwnChallenge() @@ -48,7 +49,7 @@ public async Task EachOpeningHasItsOwnChallenge() second.Challenge.Challenge.Challenge_.ToByteArray()); } - // CONTRACT.md §7.3 steps 3 and 4: the proof covers the challenge as sent and the connect nonce, + // CONTRACT.agent-identity.md §7.3 steps 3 and 4: the proof covers the challenge as sent and the connect nonce, // and is verified with the key of the certificate that authenticated the opening, // so the opening keeps the nonce and that certificate for the handshake. [Fact] @@ -77,7 +78,8 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() Assert.Equal(registered.NotAfter, opening.Certificate.NotAfter); } - // CONTRACT.md §7.3 steps 3 and 4: the agent signs the challenge it received, and the server verifies that proof. + // CONTRACT.agent-identity.md §7.3 steps 3 and 4: the agent signs the challenge it received, + // and the server verifies that proof. // The handshake checks the proof against the challenge as sent, // so a host that edits the message object after sending it must not change what the agent has to sign. [Fact] @@ -95,8 +97,8 @@ public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() Assert.Equal(sentBytes, opening.ChallengeBytes.ToArray()); } - // CONTRACT.md §7.4: a stream closes no later than its certificate's notAfter, even while it waits for its Hello, - // so the opening gives the host that time. + // CONTRACT.agent-identity.md §7.4: a stream closes no later than its certificate's notAfter, + // even while it waits for its Hello, so the opening gives the host that time. // The vector file states it in Unix seconds, independently of any time zone. [Fact] public async Task OpeningGivesTheNotAfterOfItsCertificate() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs index 818df2fafe..246315bcc1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs @@ -5,7 +5,7 @@ namespace Devolutions.AgentIdentity.Tests; public class EnrollmentAuthorizationTests { - // A token for https://host/dvls whose secret encodes the bytes 00 to 1F (CONTRACT.md §2). + // A token for https://host/dvls whose secret encodes the bytes 00 to 1F (CONTRACT.agent-identity.md §2). private const string Bag = "eyJ1IjoiaHR0cHM6Ly9ob3N0L2R2bHMifQ"; private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; private const string Token = "dvaet1." + Bag + "." + Secret; @@ -19,16 +19,16 @@ private static Dictionary FieldLinesWithoutABearerToken { Dictionary fieldLines = new() { - // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is missing. + // CONTRACT.agent-identity.md §2.1 (1): `token_invalid` when the `Authorization` header is missing. ["no field"] = [], - // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is repeated, + // CONTRACT.agent-identity.md §2.1 (1): `token_invalid` when the `Authorization` header is repeated, // even when every field line holds the same valid token. ["the same token twice"] = ["Bearer " + Token, "Bearer " + Token], ["a token, then an empty field"] = ["Bearer " + Token, ""], ["an empty field, then a token"] = ["", "Bearer " + Token], - // CONTRACT.md §2.1 (1): `token_invalid` when the `Authorization` header is not `Bearer`. + // CONTRACT.agent-identity.md §2.1 (1): `token_invalid` when the `Authorization` header is not `Bearer`. ["Basic"] = ["Basic " + Token], ["DPoP"] = ["DPoP " + Token], ["Bearers"] = ["Bearers " + Token], @@ -43,7 +43,7 @@ private static Dictionary FieldLinesWithoutABearerToken ["a space and a tab"] = ["Bearer \t" + Token], }; - // CONTRACT.md §2.1 (1): a field that holds no token gets `token_invalid`. + // CONTRACT.agent-identity.md §2.1 (1): a field that holds no token gets `token_invalid`. foreach ((string name, string token) in MalformedTokens) { fieldLines.Add(name, ["Bearer " + token]); @@ -53,7 +53,7 @@ private static Dictionary FieldLinesWithoutABearerToken } } - // CONTRACT.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`. + // CONTRACT.agent-identity.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`. // The pattern covers the whole value, so a line terminator after the token is not part of a match. private static Dictionary MalformedTokens => new() { @@ -80,7 +80,7 @@ private static Dictionary FieldLinesWithoutABearerToken ["a second credential after the token"] = Token + ", Bearer " + Token, }; - // CONTRACT.md §2.1: the authority finds the token whose SHA-256(secret) matches, + // CONTRACT.agent-identity.md §2.1: the authority finds the token whose SHA-256(secret) matches, // and §2 makes it match the secret text exactly. [Theory] [InlineData("a bearer token", "Bearer " + Token)] @@ -91,7 +91,7 @@ private static Dictionary FieldLinesWithoutABearerToken // RFC 9110 §11.4: `credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]`. [InlineData("two spaces", "Bearer " + Token)] [InlineData("three spaces", "Bearer " + Token)] - // CONTRACT.md §2.1 (1): the authority finds a token by `SHA-256(secret)` alone, + // CONTRACT.agent-identity.md §2.1 (1): the authority finds a token by `SHA-256(secret)` alone, // and §2 makes the bag routing data that the authority never trusts, so it is not decoded. // The rows are a bag of an impossible base64url length, one that decodes to bytes that are not JSON, // and the bag of {"u":"https://other/psu"}. @@ -122,7 +122,7 @@ public void RejectsFieldLinesWithoutABearerToken(string fieldLines) AssertTokenInvalid(result); } - // CONTRACT.md §2: a token is at most 4096 bytes. + // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes. // The bag is not decoded, so a bag of any length in the alphabet sets the token length. [Fact] public void AcceptsATokenOf4096Bytes() @@ -136,7 +136,8 @@ public void AcceptsATokenOf4096Bytes() Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); } - // CONTRACT.md §2: a token is at most 4096 bytes; §2.1 (1): a field that holds no token gets `token_invalid`. + // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes; + // §2.1 (1): a field that holds no token gets `token_invalid`. [Fact] public void RejectsATokenLongerThan4096Bytes() { @@ -148,7 +149,7 @@ public void RejectsATokenLongerThan4096Bytes() AssertTokenInvalid(result); } - // CONTRACT.md §2: the authority matches the `secret` text exactly. + // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly. // Ending in '9' (sextet 61) instead of '8' (sextet 60) sets a pad bit, // so a lenient decoder reads the same 32 bytes, but the text, and so its hash, differs. [Fact] @@ -201,7 +202,7 @@ public void RejectsNullFieldLines() Assert.Equal("authorizationFieldLines", exception.ParamName); } - // CONTRACT.md §5.4: `token_invalid` is carried by HTTP 401. + // CONTRACT.agent-identity.md §5.4: `token_invalid` is carried by HTTP 401. private static void AssertTokenInvalid(EnrollmentAuthorizationResult result) { Assert.False(result.Succeeded); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index 6bb6c64250..18bc5560c7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -8,7 +8,7 @@ public class EnrollmentRulesTests public static TheoryData KeysButTheReplayNames => new(KeysButTheReplay.Keys); - // The issued keys that each key rule of CONTRACT.md §2.1 matches, except the replay, by name. + // The issued keys that each key rule of CONTRACT.agent-identity.md §2.1 matches, except the replay, by name. private static Dictionary KeysButTheReplay => new() { ["the key of another token's identity"] = @@ -21,8 +21,8 @@ public class EnrollmentRulesTests ["the key of a deleted identity"] = IssuedKeyMatch.ForDeletedIdentity(), }; - // CONTRACT.md §2.1: a token creates an identity when it exists, has not been deleted, has not expired, - // and fewer than `max_uses` identities have been created with it; (6) a new identity. + // CONTRACT.agent-identity.md §2.1: a token creates an identity when it exists, has not been deleted, + // has not expired, and fewer than `max_uses` identities have been created with it; (6) a new identity. // The rows are a usable token, one that expires one tick after now, and one that can create one more identity. [Theory] [InlineData(TimeSpan.TicksPerDay, 3)] @@ -39,23 +39,23 @@ public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int Assert.Null(decision.Error); } - // CONTRACT.md §2.1: when several outcomes apply, the earliest wins: + // CONTRACT.agent-identity.md §2.1: when several outcomes apply, the earliest wins: // (4) `token_invalid` for a deleted token, then `token_expired` for an expired one; (5) `token_exhausted`. [Theory] - // CONTRACT.md §5.4: `token_expired` when `now ≥ expires_at`. + // CONTRACT.agent-identity.md §5.4: `token_expired` when `now ≥ expires_at`. [InlineData(false, 0L, 10, 3, AgentIdentityErrorCode.TokenExpired)] - // CONTRACT.md §2.1: a token creates an identity only while fewer than `max_uses` identities have been created - // with it. + // CONTRACT.agent-identity.md §2.1: a token creates an identity only while fewer than `max_uses` identities + // have been created with it. [InlineData(false, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 10, 11, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 0, 0, AgentIdentityErrorCode.TokenExhausted)] - // CONTRACT.md §2.1: a deleted token authenticates only for an idempotent replay; + // CONTRACT.agent-identity.md §2.1: a deleted token authenticates only for an idempotent replay; // every other use returns `token_invalid`, whether or not the token has also expired or been exhausted. [InlineData(true, TimeSpan.TicksPerDay, 10, 3, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, 0L, 10, 3, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, 0L, 10, 10, AgentIdentityErrorCode.TokenInvalid)] - // CONTRACT.md §2.1: (4) `token_expired` comes before (5) `token_exhausted`. + // CONTRACT.agent-identity.md §2.1: (4) `token_expired` comes before (5) `token_exhausted`. [InlineData(false, 0L, 10, 10, AgentIdentityErrorCode.TokenExpired)] public void RejectsANewKeyWithTheEarliestTokenOutcome( bool deleted, @@ -71,8 +71,8 @@ public void RejectsANewKeyWithTheEarliestTokenOutcome( AssertRejected(expected, decision); } - // CONTRACT.md §2.1: the key of the current certificate of a non-revoked identity enrolled with the same token - // → 200 with that identity, without counting a use. + // CONTRACT.agent-identity.md §2.1: the key of the current certificate of a non-revoked identity enrolled with the + // same token → 200 with that identity, without counting a use. // The key lookup (3) comes before (4) and (5), and a deleted token authenticates for this replay, // so the replay wins whether the token is deleted, expired or exhausted. [Theory] @@ -92,7 +92,7 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, Assert.Null(decision.Error); } - // CONTRACT.md §2.1: the first matching key rule decides. + // CONTRACT.agent-identity.md §2.1: the first matching key rule decides. // The key of an identity enrolled with a different token → `invalid_request`, before the revocation rule; // any certificate key of a revoked identity → `identity_revoked`, before the pending-or-retired rule // and instead of the replay, which needs a non-revoked identity; @@ -108,7 +108,8 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, [InlineData(true, true, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.IdentityRevoked)] [InlineData(true, false, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(true, false, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.InvalidRequest)] - // CONTRACT.md §2.1: (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. + // CONTRACT.agent-identity.md §2.1: (3) the result of the key lookup comes + // before (4) `token_expired` and (5) `token_exhausted`. [InlineData(false, false, CertificateStatus.Current, true, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(false, false, CertificateStatus.Current, false, true, AgentIdentityErrorCode.InvalidRequest)] [InlineData(true, true, CertificateStatus.Current, true, false, AgentIdentityErrorCode.IdentityRevoked)] @@ -131,7 +132,7 @@ public void RejectsAnIssuedKeyByTheFirstMatchingRule( AssertRejected(expected, decision); } - // CONTRACT.md §2.1: the key of a certificate of a deleted identity → `identity_revoked`, + // CONTRACT.agent-identity.md §2.1: the key of a certificate of a deleted identity → `identity_revoked`, // and (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. // The authority keeps only the SPKI SHA-256 and issuance time of a deleted identity's keys, // so no other key rule applies to them. @@ -148,7 +149,7 @@ public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, boo AssertRejected(AgentIdentityErrorCode.IdentityRevoked, decision); } - // CONTRACT.md §2.1 (3): a deleted token gets `token_invalid` for anything but an idempotent replay, + // CONTRACT.agent-identity.md §2.1 (3): a deleted token gets `token_invalid` for anything but an idempotent replay, // whatever the CSR public key matches. [Theory] [MemberData(nameof(KeysButTheReplayNames))] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index 78f03c7a1a..e93c6f18d5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -11,7 +11,7 @@ public class EnrollmentTokenTests private static readonly byte[] SecretBytes = [.. Enumerable.Range(0, 32).Select(i => (byte)i)]; private const string SecretText = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; - // CONTRACT.md §2: `..`, where `secret` is base64url of 32 random bytes. + // CONTRACT.agent-identity.md §2: `..`, where `secret` is base64url of 32 random bytes. [Fact] public void CreateEncodesTheGeneratorBytesAsTheSecret() { @@ -21,7 +21,7 @@ public void CreateEncodesTheGeneratorBytesAsTheSecret() Assert.EndsWith("." + SecretText, token.Value, StringComparison.Ordinal); } - // CONTRACT.md §2: the authority matches the `secret` text exactly, + // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly, // so it hashes the 43 characters rather than the bytes they encode. [Fact] public void SecretHashIsTheSha256OfTheSecretText() @@ -31,7 +31,7 @@ public void SecretHashIsTheSha256OfTheSecretText() Assert.Equal(SHA256.HashData(Encoding.ASCII.GetBytes(SecretText)), token.SecretHash.ToArray()); } - // CONTRACT.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`, + // CONTRACT.agent-identity.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`, // and its secret is the canonical base64url encoding of the 32 random bytes. // The random values are seeded, so a failure reproduces. [Fact] @@ -55,7 +55,7 @@ public void CreateWritesEverySecretInTheContractPattern() // The authority stores SecretHash when it creates a token, // and finds the token of an enroll request by the hash that ReadBearerToken returns. - // CONTRACT.md §2: the authority matches the `secret` text exactly, + // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly, // so both hashes are the SHA-256 of the 43 ASCII characters of the token's secret. [Fact] public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() @@ -81,10 +81,12 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() } } - // CONTRACT.md §2: `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL. + // CONTRACT.agent-identity.md §2: `bag` is the base64url encoding of a UTF-8 JSON object + // whose `u` is the product base URL. [Theory] [InlineData("https://host/dvls", "https://host/dvls")] - // The agent appends `/api/agent-identity/v1/...` to `u` (CONTRACT.md §5), so `u` ends without a slash. + // The agent appends `/api/agent-identity/v1/...` to `u` (CONTRACT.agent-identity.md §5), + // so `u` ends without a slash. [InlineData("https://host/dvls/", "https://host/dvls")] [InlineData("https://host/dvls//", "https://host/dvls")] [InlineData("https://host/", "https://host")] @@ -102,7 +104,7 @@ public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl, string ex Assert.Equal(("u", expected), Assert.Single(BagMembers(token))); } - // CONTRACT.md §2: `u` is an absolute `https` URL without a query or fragment. + // CONTRACT.agent-identity.md §2: `u` is an absolute `https` URL without a query or fragment. // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. private static readonly string[] UnacceptableUrls = [ @@ -120,7 +122,8 @@ public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl, string ex "https://@host/dvls", ]; - // The unacceptable URLs, and a URL one byte longer than the longest that fits in a token (CONTRACT.md §2). + // The unacceptable URLs, and a URL one byte longer than the longest that fits in a token + // (CONTRACT.agent-identity.md §2). public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls, UrlOfLength(3026)]); [Theory] @@ -133,7 +136,7 @@ public void CreateRejectsAnotherUrl(string productBaseUrl) Assert.Equal("productBaseUrl", exception.ParamName); } - // CONTRACT.md §2: a token is at most 4096 bytes. + // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes. // A 3025-byte URL makes a 3033-byte bag object, a 4044-character bag and a 4095-byte token. // One more byte makes a 4046-character bag and a 4097-byte token, since no base64url text has 4045 characters. [Fact] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs index be90b8cbc5..15a0a78158 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs @@ -7,20 +7,21 @@ public class FriendlyNameFormatTests private const string TokenName = "Fleet"; - // A dvaet1 token for https://host/dvls (CONTRACT.md §2), which an administrator might paste into a format. + // A dvaet1 token for https://host/dvls (CONTRACT.agent-identity.md §2), + // which an administrator might paste into a format. private const string Bag = "eyJ1IjoiaHR0cHM6Ly9ob3N0L2R2bHMifQ"; private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; private const string Token = "dvaet1." + Bag + "." + Secret; - // The placeholders of INTENT.agent-identity.md, "Enrollment": the known metadata keys of CONTRACT.md §3, - // and the token's name. + // The placeholders of INTENT.agent-identity.md, "Enrollment": + // the known metadata keys of CONTRACT.agent-identity.md §3, and the token's name. private static readonly string[] Placeholders = [ "{hostname}", "{fqdn}", "{domain}", "{os_name}", "{os_version}", "{arch}", "{agent_version}", "{machine_id}", "{token_name}", ]; - // A value for each known metadata key of CONTRACT.md §3. + // A value for each known metadata key of CONTRACT.agent-identity.md §3. private static readonly AgentMetadata Metadata = Validated(new Dictionary { ["hostname"] = "pc-042", @@ -34,7 +35,7 @@ public class FriendlyNameFormatTests }); // INTENT.agent-identity.md, "Enrollment": the placeholders are `{}` and `{token_name}`, - // the enrollment token's name; CONTRACT.md §3 lists the known metadata keys. + // the enrollment token's name; CONTRACT.agent-identity.md §3 lists the known metadata keys. [Theory] [InlineData("{hostname}", "pc-042")] [InlineData("{fqdn}", "pc-042.corp.example")] @@ -59,7 +60,7 @@ public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expe } // INTENT.agent-identity.md, "Enrollment": a format with an unpaired brace or an unknown placeholder is rejected. - // A placeholder names a known metadata key of CONTRACT.md §3 or `token_name` exactly, + // A placeholder names a known metadata key of CONTRACT.agent-identity.md §3 or `token_name` exactly, // so another case, white space or another key, such as one that §3 allows but does not list, is unknown. [Theory] [InlineData("{")] @@ -156,7 +157,8 @@ public void EvaluatesAMissingMetadataValueToEmpty() } // `{token_name}` is the enrollment token's name, - // and CONTRACT.md §3 lets an agent report an unknown key such as `token_name`, which is never used for anything. + // and CONTRACT.agent-identity.md §3 lets an agent report an unknown key such as `token_name`, + // which is never used for anything. [Fact] public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue() { @@ -252,7 +254,7 @@ public void TruncatesTo255ScalarValuesBetweenTwoTrims(string tokenName) } // INTENT.agent-identity.md, "Enrollment": an empty result falls back to the identity ID, - // which CONTRACT.md §1 writes as a lowercase hyphenated UUID. + // which CONTRACT.agent-identity.md §1 writes as a lowercase hyphenated UUID. [Theory] [InlineData("{fqdn}", TokenName)] [InlineData("", TokenName)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index f6860cd919..4b64d1a475 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -4,7 +4,7 @@ namespace Devolutions.AgentIdentity.Tests; public class AgentIdentityErrorCodeTests { - // CONTRACT.md §5.4, column "Code". + // CONTRACT.agent-identity.md §5.4, column "Code". public static TheoryData WireValues => new() { { AgentIdentityErrorCode.TokenInvalid, "token_invalid" }, @@ -26,7 +26,7 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec Assert.Equal(expected, code.ToWireValue()); } - // CONTRACT.md §5.4, column "HTTP". + // CONTRACT.agent-identity.md §5.4, column "HTTP". // The table gives `unavailable` 5xx, and an unexpected failure gets 500. public static TheoryData HttpStatusCodes => new() { @@ -49,7 +49,7 @@ public void HttpStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, int e Assert.Equal(expected, code.ToHttpStatusCode()); } - // CONTRACT.md §7.4: an authentication failure closes the stream with UNAUTHENTICATED, + // CONTRACT.agent-identity.md §7.4: an authentication failure closes the stream with UNAUTHENTICATED, // or PERMISSION_DENIED for `identity_revoked`. // §7.3 step 4: invalid `Hello` metadata closes it with INVALID_ARGUMENT and `invalid_request`. // `unavailable` gets UNAVAILABLE, which gRPC defines as a transient condition. @@ -74,7 +74,7 @@ public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, Statu Assert.Equal(expected, code.ToGrpcStatusCode()); } - // CONTRACT.md §5.4 lists exactly these ten codes. + // CONTRACT.agent-identity.md §5.4 lists exactly these ten codes. // A code added without a contract row would otherwise go untested in every table above. [Fact] public void EveryCodeHasAContractRow() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs index 881b073b3a..55a844a071 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs @@ -5,7 +5,8 @@ namespace Devolutions.AgentIdentity.Tests; public class AgentIdentityErrorTests { - // 12:00:00.750 UTC given in a +02:00 offset, which CONTRACT.md §1 writes as a UTC timestamp in whole seconds. + // 12:00:00.750 UTC given in a +02:00 offset, + // which CONTRACT.agent-identity.md §1 writes as a UTC timestamp in whole seconds. private static readonly DateTimeOffset ServerTime = new DateTimeOffset(2026, 9, 24, 14, 0, 0, TimeSpan.FromHours(2)).AddMilliseconds(750); @@ -30,7 +31,7 @@ .. Enum.GetValues().Select(code => ($"From({code})", Age .. FrameworkLevelErrorNames.Select(name => (name, FrameworkLevelError(name))), ]; - // CONTRACT.md §5.4, column "HTTP". + // CONTRACT.agent-identity.md §5.4, column "HTTP". [Theory] [MemberData(nameof(AgentIdentityErrorCodeTests.HttpStatusCodes), MemberType = typeof(AgentIdentityErrorCodeTests))] public void FromCarriesTheCodeWithItsContractStatus(AgentIdentityErrorCode code, int expectedStatus) @@ -41,7 +42,7 @@ public void FromCarriesTheCodeWithItsContractStatus(AgentIdentityErrorCode code, Assert.Equal(expectedStatus, error.HttpStatusCode); } - // CONTRACT.md §5.4 has no code for the default value of the enum. + // CONTRACT.agent-identity.md §5.4 has no code for the default value of the enum. // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. [Fact] public void FromRejectsAnUndefinedCode() @@ -49,8 +50,8 @@ public void FromRejectsAnUndefinedCode() Assert.Throws(() => AgentIdentityError.From(default)); } - // CONTRACT.md §5.4: malformed JSON or body → 400 invalid_request; unknown route → 404 invalid_request; - // wrong method → 405 invalid_request; body too large → 413 invalid_request; + // CONTRACT.agent-identity.md §5.4: malformed JSON or body → 400 invalid_request; + // unknown route → 404 invalid_request; wrong method → 405 invalid_request; body too large → 413 invalid_request; // an unexpected server failure → 500 unavailable; overload or a missing dependency → 503 unavailable. [Theory] [InlineData(nameof(AgentIdentityError.MalformedBody), 400, AgentIdentityErrorCode.InvalidRequest)] @@ -70,7 +71,8 @@ public void FrameworkLevelErrorHasItsContractStatusAndCode( Assert.Equal(expectedCode, error.Code); } - // CONTRACT.md §5.4: every error body, framework-level failures included, carries a `message` of human text. + // CONTRACT.agent-identity.md §5.4: every error body, framework-level failures included, + // carries a `message` of human text. // The contract leaves its wording free, so only its presence is checked. [Theory] [MemberData(nameof(PublicErrorNames))] @@ -81,7 +83,7 @@ public void ErrorHasAHumanMessage(string name) Assert.False(string.IsNullOrWhiteSpace(error.Message)); } - // CONTRACT.md §5.4 body, with the snake_case keys and the timestamp format of §1. + // CONTRACT.agent-identity.md §5.4 body, with the snake_case keys and the timestamp format of §1. // Parsing the raw bytes also rejects a byte order mark, which RFC 8259 §8.1 forbids in a JSON text. [Fact] public void JsonBodyHasExactlyTheContractMembers() @@ -98,7 +100,7 @@ public void JsonBodyHasExactlyTheContractMembers() Assert.Equal(ServerTimeText, members["server_time"].GetString()); } - // CONTRACT.md §7.4: a refused channel opening carries the code's status. + // CONTRACT.agent-identity.md §7.4: a refused channel opening carries the code's status. // §7.3 step 4: invalid `Hello` metadata gets INVALID_ARGUMENT. [Theory] [InlineData(AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated)] @@ -112,7 +114,8 @@ public void RpcExceptionHasTheChannelStatus(AgentIdentityErrorCode code, StatusC Assert.Equal(expected, exception.StatusCode); } - // CONTRACT.md §7.4: trailers `error-code: <§5.4 code>` and `server-time: `, in no stated order. + // CONTRACT.agent-identity.md §7.4: trailers `error-code: <§5.4 code>` and `server-time: `, + // in no stated order. [Fact] public void RpcExceptionHasExactlyTheContractTrailers() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs index 99e1898a75..cfd05b86a3 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs @@ -15,7 +15,7 @@ .. TestVectors.Instance.HttpSignature.Registered.Select( registered => ($"http_signature.registered[{registered.Name}]", registered.Certificate, registered.KeyId)), ]; - // CONTRACT.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. + // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. // A timestamp names the whole second that contains the instant. [Theory] [InlineData("2026-09-24T12:00:00+00:00", "2026-09-24T12:00:00Z")] @@ -51,8 +51,8 @@ public void FormatTimestampIgnoresTheCurrentCulture() } } - // CONTRACT.md §1: a thumbprint is base64url(SHA-256(certificate DER)). - // CONTRACT.md §6: a request's `keyid` is the thumbprint of the certificate that signs it. + // CONTRACT.agent-identity.md §1: a thumbprint is base64url(SHA-256(certificate DER)). + // CONTRACT.agent-identity.md §6: a request's `keyid` is the thumbprint of the certificate that signs it. // The vector file gives each certificate with its thumbprint, or a registered certificate with its keyid. [Theory] [MemberData(nameof(VectorCertificateNames))] @@ -63,7 +63,7 @@ public void CertificateHasTheVectorThumbprint(string name) Assert.Equal(thumbprint, AgentIdentityProtocol.ComputeThumbprint(certificate)); } - // CONTRACT.md §5.4: servers accept request bodies up to 64 KiB. + // CONTRACT.agent-identity.md §5.4: servers accept request bodies up to 64 KiB. [Fact] public void MaxRequestBodyBytesIs64KiB() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs index d9277c81f3..efb9b2d716 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs @@ -9,7 +9,8 @@ public class StrictBase64Tests private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - // CONTRACT.md Definitions: base64 is RFC 4648 §4 with padding, and base64url is RFC 4648 §5 without padding. + // CONTRACT.agent-identity.md Definitions: base64 is RFC 4648 §4 with padding, + // and base64url is RFC 4648 §5 without padding. private static readonly Dictionary Codecs = new() { ["base64"] = new(StrictBase64.TryDecodeBase64, Base64Alphabet, "==", "=", base64 => base64), @@ -125,7 +126,7 @@ public void RejectsAnotherDecodedLength(string encoding, string encoded, int len Assert.False(Codecs[encoding].TryDecode(encoded, out _, length)); } - // CONTRACT.md Definitions: base64url is RFC 4648 §5 without padding. + // CONTRACT.agent-identity.md Definitions: base64url is RFC 4648 §5 without padding. [Theory] [MemberData(nameof(Base64UrlForms))] public void Base64UrlEncodesWithoutPadding(string expected, byte[] value) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs index a70a9afcfa..1547df0209 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs @@ -4,7 +4,8 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// Channel proofs that a test signs as an agent would, built from CONTRACT.md §7.3 step 3 rather than by the library. +/// Channel proofs that a test signs as an agent would, +/// built from CONTRACT.agent-identity.md §7.3 step 3 rather than by the library. /// internal static class TestChannelProof { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs index 0eb04a7690..5fbc60b3f7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs @@ -70,7 +70,8 @@ public string SignatureBase } /// - /// A request that every rule of CONTRACT.md §6 accepts at , when its key is registered. + /// A request that every rule of CONTRACT.agent-identity.md §6 accepts at , + /// when its key is registered. /// public static TestRequest Valid( AgentOperation operation, @@ -102,7 +103,7 @@ public static TestRequest Valid( }; } - // CONTRACT.md §6, column "tag". + // CONTRACT.agent-identity.md §6, column "tag". public static string Tag(AgentOperation operation) { return operation switch diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs index 998024a5f4..c2d28f6ae1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs @@ -72,7 +72,7 @@ public static string[] Lines(string? header) return header is null ? [] : [header]; } - // CONTRACT.md §5: each endpoint is one operation. + // CONTRACT.agent-identity.md §5: each endpoint is one operation. public static AgentOperation Operation(string endpoint) { return endpoint switch @@ -85,7 +85,7 @@ public static AgentOperation Operation(string endpoint) }; } - // CONTRACT.md "Definitions": the certificate statuses. + // CONTRACT.agent-identity.md "Definitions": the certificate statuses. private static CertificateStatus Status(string status) { return status switch diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs index c8a7ea1973..fffcb856b8 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs @@ -20,7 +20,7 @@ internal static class VectorChannel public static DateTimeOffset OpeningTime => ConnectCase.Now; /// - /// Metadata that CONTRACT.md §3 accepts, with the keys that §3.1 says the agent always sends. + /// Metadata that CONTRACT.agent-identity.md §3 accepts, with the keys that §3.1 says the agent always sends. /// public static IReadOnlyList> ValidMetadata { get; } = [ diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs index 0a232b5029..eeadd7dd89 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs @@ -43,7 +43,7 @@ internal AuthenticatedIdentity( /// It is or , /// or for an agent channel stream whose certificate was retired /// during the handshake. - /// CONTRACT.md §7.4 says how the host treats a stream of a retired certificate. + /// CONTRACT.agent-identity.md §7.4 says how the host treats a stream of a retired certificate. /// public CertificateStatus CertificateStatus { get; } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs index ff94917f53..7288687940 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs @@ -33,7 +33,7 @@ private AuthenticationResult(AuthenticatedIdentity? identity, AgentIdentityError public AgentIdentityError? Error { get; } /// - /// The rule that the request broke and the step of CONTRACT.md §6 that checks it, for host logs. + /// The rule that the request broke and the step of CONTRACT.agent-identity.md §6 that checks it, for host logs. /// /// /// It is a fixed sentence, so it never holds a header value, a token or a secret. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs index 68baa26171..4265e40f62 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs @@ -5,25 +5,27 @@ namespace Devolutions.AgentIdentity; /// -/// Authenticates the signed requests of agents, as CONTRACT.md §6 specifies. +/// Authenticates the signed requests of agents, as CONTRACT.agent-identity.md §6 specifies. /// /// -/// Each request goes through the six steps of CONTRACT.md §6 in order, and the first step that fails decides the error. +/// Each request goes through the six steps of CONTRACT.agent-identity.md §6 in order, +/// and the first step that fails decides the error. /// Exceptions from the certificate registry and the nonce store propagate to the caller. /// public sealed class RequestAuthenticator { - // CONTRACT.md §6 step 3. + // CONTRACT.agent-identity.md §6 step 3. private const long MaxSignatureLifetimeSeconds = 300; private const long ClockToleranceSeconds = 60; - // CONTRACT.md §7.3 step 2. + // CONTRACT.agent-identity.md §7.3 step 2. private const int ChallengeLength = 32; // RFC 5480 §2.1.1: id-ecPublicKey. private const string EcPublicKeyOid = "1.2.840.10045.2.1"; - // CONTRACT.md §6 step 6, and the shared intent: a nonce is kept until 60 s after its signature expires. + // CONTRACT.agent-identity.md §6 step 6, and the shared intent: + // a nonce is kept until 60 s after its signature expires. private static readonly TimeSpan NonceRetentionAfterExpiry = TimeSpan.FromSeconds(60); // RFC 5480 §2.1.1.1: the DER encoding of the namedCurve secp256r1, which is P-256. @@ -65,7 +67,7 @@ public RequestAuthenticator( /// /// A body larger than fails first, /// with . - /// The request then goes through the steps of CONTRACT.md §6, + /// The request then goes through the steps of CONTRACT.agent-identity.md §6, /// and its nonce is recorded only once every other step has passed. /// /// The operation of the endpoint that received the request. @@ -123,7 +125,7 @@ public async ValueTask AuthenticateAsync( /// Authenticates the signed opening request of an agent channel stream, and prepares the stream's challenge. /// /// - /// The opening request goes through the steps of CONTRACT.md §6 with tag connect, + /// The opening request goes through the steps of CONTRACT.agent-identity.md §6 with tag connect, /// and its nonce is recorded only once every other step has passed. /// A verified opening does not authenticate the stream: /// the agent must still sign , @@ -309,7 +311,7 @@ private async ValueTask VerifyAsync( return Verification.Accept(verified, fields.Nonce); } - // CONTRACT.md §6 step 3: 0 < expires − created ≤ 300, created ≤ now + 60 and expires ≥ now − 60. + // CONTRACT.agent-identity.md §6 step 3: 0 < expires − created ≤ 300, created ≤ now + 60 and expires ≥ now − 60. private static bool IsWithinWindow(long created, long expires, DateTimeOffset now) { // Both are integers of at most 15 digits, so their difference fits in a long. @@ -382,7 +384,8 @@ private static ECDsa LoadPublicKey(X509Certificate2 certificate) } /// - /// The outcome of the steps of CONTRACT.md §6: the verified certificate and nonce, or the error of the failed step. + /// The outcome of the steps of CONTRACT.agent-identity.md §6: the verified certificate and nonce, + /// or the error of the failed step. /// private sealed class Verification { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs index 53ced62a21..0c1af3f4c9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs @@ -8,7 +8,7 @@ namespace Devolutions.AgentIdentity; /// /// The Signature-Input, Signature and Content-Digest fields of a signed request, -/// parsed and checked against the profile of CONTRACT.md §6 step 1. +/// parsed and checked against the profile of CONTRACT.agent-identity.md §6 step 1. /// /// /// One parse drives both the profile checks and the signature base, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs index 973b7eb4ad..d5e5929c66 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs @@ -4,7 +4,7 @@ namespace Devolutions.AgentIdentity; /// The signature profile of one operation: its tag, its covered components and the certificate validity it accepts. /// /// -/// These are the rows of the table in CONTRACT.md §6. +/// These are the rows of the table in CONTRACT.agent-identity.md §6. /// internal sealed class SignedOperation { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs index cc969c9732..9155827edb 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs @@ -8,8 +8,8 @@ namespace Devolutions.AgentIdentity; /// check-in responses and in ConfigUpdate messages. /// /// -/// Only creates instances, so an instance always serializes to a config that CONTRACT.md §5.2 -/// accepts. +/// Only creates instances, +/// so an instance always serializes to a config that CONTRACT.agent-identity.md §5.2 accepts. /// The agent replaces its stored config only with a higher , /// so the host creates the config with a higher revision whenever the identity's config changes. /// @@ -171,7 +171,8 @@ private static void WriteProductFields( try { - // CONTRACT.md §1 governs every member of an agent-facing body, so it governs those inside the value. + // CONTRACT.agent-identity.md §1 governs every member of an agent-facing body, + // so it governs those inside the value. if (CheckMembers(value) is { } problem) { throw new ArgumentException( @@ -192,7 +193,7 @@ private static void WriteProductFields( } } - // Returns how a member inside the value breaks the conventions of CONTRACT.md §1, or null. + // Returns how a member inside the value breaks the conventions of CONTRACT.agent-identity.md §1, or null. // The walk keeps its own stack, so that a deeply nested value cannot exhaust the thread's stack. // Reading a name decodes its escapes, and throws InvalidOperationException for a lone surrogate. private static string? CheckMembers(JsonElement value) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs index 76eac569a1..fac097d17b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs @@ -76,7 +76,7 @@ private static bool TryRead( } // Returns why the bytes are not one JSON text that every parser reads the same way, or null. - // A repeated member name lets two parsers read two values (CONTRACT.md §1), + // A repeated member name lets two parsers read two values (CONTRACT.agent-identity.md §1), // and so does a string that is not Unicode text (RFC 8259 §8.2). private static string? CheckJson(ReadOnlySpan utf8Json) { @@ -146,7 +146,7 @@ private static bool TryReadCsr( return false; } - // CONTRACT.md §1: a base64 value in any form but its canonical one is invalid. + // CONTRACT.agent-identity.md §1: a base64 value in any form but its canonical one is invalid. if (!StrictBase64.TryDecodeBase64(value.GetString(), out byte[]? der)) { failure = "The csr is not canonical base64."; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs index 941a9ae6c7..d240ba6d56 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs @@ -8,7 +8,8 @@ namespace Devolutions.AgentIdentity; /// Writes the bodies of the successful responses of the agent-facing endpoints, as UTF-8 JSON objects. /// /// -/// Each body has exactly the members that CONTRACT.md §5 gives the response, and no member is ever null. +/// Each body has exactly the members that CONTRACT.agent-identity.md §5 gives the response, +/// and no member is ever null. /// A certificate is written as its DER in canonical base64, and a time as an RFC 3339 UTC timestamp. /// public static class ResponseBodies diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs index 4bc1b63a97..84d0f0d677 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs @@ -49,7 +49,7 @@ private ChannelHandshakeResult( /// so the host closes the stream no later than . /// When that certificate was retired during the handshake, /// is , - /// and CONTRACT.md §7.4 says how the host treats a stream of a retired certificate. + /// and CONTRACT.agent-identity.md §7.4 says how the host treats a stream of a retired certificate. /// public AuthenticatedIdentity? Identity { get; } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs index 2389a73ad5..68292588b4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs @@ -118,27 +118,28 @@ internal ChannelOpening( internal DateTimeOffset OpenedAt { get; } /// - /// Verifies the first message that the agent sent on the stream, as CONTRACT.md §7.3 step 4 specifies. + /// Verifies the first message that the agent sent on the stream, + /// as CONTRACT.agent-identity.md §7.3 step 4 specifies. /// /// /// The method reads the clock once, then the certificate registry once, /// because the host cannot close the stream for a change to its identity while that identity is still hidden. - /// In the order of CONTRACT.md §6 step 4, the handshake fails with + /// In the order of CONTRACT.agent-identity.md §6 step 4, the handshake fails with /// when the certificate that opened the stream is no longer /// registered or its identity was deleted, with when the /// identity is revoked, and with after /// . /// A certificate retired since the opening still passes, /// and then reports ; - /// CONTRACT.md §7.4 says how the host treats a stream of a retired certificate. + /// CONTRACT.agent-identity.md §7.4 says how the host treats a stream of a retired certificate. /// More than after the opening request was verified, /// the handshake fails with , /// even when the host's own timer let the message through. /// The message must then be a Hello whose correlation_id is the id of , /// and whose proof verifies with the key of the certificate that signed the opening request; /// otherwise the handshake fails with . - /// Only then is the metadata checked, and metadata that CONTRACT.md §3 rejects fails the handshake with - /// . + /// Only then is the metadata checked, and metadata that CONTRACT.agent-identity.md §3 rejects fails the handshake + /// with . /// The Welcome of a successful handshake carries the time of the clock reading. /// The first call to this method or to ends the handshake, whatever its outcome, /// including an exception from the registry, which propagates. @@ -223,7 +224,7 @@ public async ValueTask VerifyHelloAsync( /// A stream still awaiting its Hello closes for its identity and its certificate before it closes for the /// timeout, so the method reads the clock once, then the certificate registry once, as /// does. - /// In the order of CONTRACT.md §6 step 4, the handshake fails with + /// In the order of CONTRACT.agent-identity.md §6 step 4, the handshake fails with /// when the certificate that opened the stream is no longer /// registered or its identity was deleted, with when the /// identity is revoked, and with after @@ -262,14 +263,14 @@ private void EndHandshake() } } - // CONTRACT.md §6: a certificate is unexpired while now ≤ notAfter. + // CONTRACT.agent-identity.md §6: a certificate is unexpired while now ≤ notAfter. private bool HasExpired(DateTimeOffset now) { return now > Certificate.NotAfter; } // Reads the clock, then the registry, once each. - // The clock comes first, as in CONTRACT.md §6, so that a slow registry cannot make a Hello late. + // The clock comes first, as in CONTRACT.agent-identity.md §6, so that a slow registry cannot make a Hello late. // The host cannot tie the stream to its identity before the proof verifies, // so only this lookup sees a revocation, a deletion or a rotation since the opening. private async ValueTask<(DateTimeOffset Now, RegisteredCertificate? Registered)> ReadClockAndRegistryAsync( @@ -283,8 +284,8 @@ private bool HasExpired(DateTimeOffset now) return (now, registered); } - // CONTRACT.md §7.4: a deletion, a revocation and the certificate's notAfter close a stream even while it awaits - // its Hello, ahead of every failure of §7.3 step 4, in the order of §6 step 4. + // CONTRACT.agent-identity.md §7.4: a deletion, a revocation and the certificate's notAfter close a stream even + // while it awaits its Hello, ahead of every failure of §7.3 step 4, in the order of §6 step 4. // A certificate retired since the opening does not close the stream: // §7.4 lets that stream complete the handshake, whereas identity_unknown would end the identity for an agent // that did nothing wrong. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs index d9ad542f18..cc53b62ead 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs @@ -37,7 +37,8 @@ private ChannelOpeningResult(ChannelOpening? opening, AgentIdentityError? error, public AgentIdentityError? Error { get; } /// - /// The rule that the opening request broke and the step of CONTRACT.md §6 that checks it, for host logs. + /// The rule that the opening request broke and the step of CONTRACT.agent-identity.md §6 that checks it, + /// for host logs. /// /// /// It is a fixed sentence, so it never holds a header value, a token or a secret. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs index 478f564d20..6be2835d6a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs @@ -5,7 +5,7 @@ namespace Devolutions.AgentIdentity; /// /// The proof in Hello that the agent holds the key of the certificate that opened the stream, -/// as CONTRACT.md §7.3 step 3 defines it. +/// as CONTRACT.agent-identity.md §7.3 step 3 defines it. /// internal static class ChannelProof { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs index 3f35074c2f..cb1c36ccba 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs @@ -82,7 +82,7 @@ public static EnrollmentAuthorizationResult ReadBearerToken(IReadOnlyList token, out ReadOnlySpan secret) { secret = default; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs index 917c7b8cc7..b8e6c1a0f1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs @@ -24,7 +24,7 @@ namespace Devolutions.AgentIdentity; /// public sealed class FriendlyNameFormat { - // The known metadata keys of CONTRACT.md §3, and the token's name. + // The known metadata keys of CONTRACT.agent-identity.md §3, and the token's name. private static readonly string[] Placeholders = ["hostname", "fqdn", "domain", "os_name", "os_version", "arch", "agent_version", "machine_id", "token_name"]; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs index a332a6c671..63b66264d8 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs @@ -41,7 +41,7 @@ private AgentIdentityError(AgentIdentityErrorCode code, int httpStatusCode, stri /// /// The error for a request body that the host's framework cannot read or hand to the body parsers, - /// such as one that is not UTF-8 application/json, which CONTRACT.md §1 requires: + /// such as one that is not UTF-8 application/json, which CONTRACT.agent-identity.md §1 requires: /// 400 invalid_request. /// /// diff --git a/dotnet/AgentIdentity/INTENT.md b/dotnet/AgentIdentity/INTENT.md index 12ea229df2..051dd8825d 100644 --- a/dotnet/AgentIdentity/INTENT.md +++ b/dotnet/AgentIdentity/INTENT.md @@ -1,4 +1,4 @@ -This library holds everything every authority must do the same way under `docs/agent-identity/CONTRACT.md`, and nothing about storage, hosting or management, so that DVLS and PSU cannot drift apart on the agent-facing protocol. +This library holds everything every authority must do the same way under `docs/agent-identity/CONTRACT.agent-identity.md`, and nothing about storage, hosting or management, so that DVLS and PSU cannot drift apart on the agent-facing protocol. The feature's shared intent is `INTENT.agent-identity.md` beside this file. - An authority must use the identity produced by the signature check. diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index ae3c4feb25..41448d87e2 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -1,13 +1,13 @@ # Devolutions.AgentIdentity -`Devolutions.AgentIdentity` implements what every Agent Identity authority must do the same way under [CONTRACT.md][contract], so that its hosts, such as DVLS and PSU, cannot drift apart on the protocol that Devolutions Agent speaks. -The contract is `docs/agent-identity/CONTRACT.md` in the [devolutions-gateway repository][repo], which also holds this library. +`Devolutions.AgentIdentity` implements what every Agent Identity authority must do the same way under [CONTRACT.agent-identity.md][contract], so that its hosts, such as DVLS and PSU, cannot drift apart on the protocol that Devolutions Agent speaks. +The contract is `docs/agent-identity/CONTRACT.agent-identity.md` in the [devolutions-gateway repository][repo], which also holds this library. ## What the library covers - Enrollment tokens: `EnrollmentToken.Create` creates a token, `FriendlyNameFormat` parses and evaluates its friendly-name format, and `EnrollmentAuthorization.ReadBearerToken` reads it from an `enroll` request. The host stores the token's `SecretHash`, never its `Value`, which it shows once and never logs. -- Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.md [§6]). +- Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.agent-identity.md [§6]). - Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`). - Decisions: `EnrollmentRules.Decide` decides an enrollment and `RenewalRules.Decide` a renewal, given the issued certificate that holds the CSR key. - Certificates: `IdentityCertificateIssuer.Issue` issues the certificate of a new identity, or of the identity that a renew request proved. @@ -33,7 +33,7 @@ The host provides these dependencies, with the guarantees the library relies on: - `INonceStore.TryRecordAsync` records a `keyid` and `nonce` pair, or returns false when the pair is already recorded, in one atomic operation, and keeps the pair until `retainUntil`. - `IRootSigner` holds the root that issues new certificates and a `SignatureGenerator` over its private key. Its `Certificate` must have a Subject Key Identifier extension, which becomes the Authority Key Identifier of each issued certificate; without one, `IdentityCertificateIssuer.Issue` throws `ArgumentException`. - Once a root rotation starts, it holds the new root (CONTRACT.md [§8.3]). + Once a root rotation starts, it holds the new root (CONTRACT.agent-identity.md [§8.3]). - `TimeProvider` is the authority's clock, usually `TimeProvider.System`, which decides signature freshness, certificate validity and handshake deadlines. Take the `now` of `EnrollmentRules.Decide` and the `serverTime` of every error from the same clock, since the agent corrects its own clock from `server_time`. - `RandomNumberGenerator` must be cryptographically secure, such as `RandomNumberGenerator.Create()`, since it supplies channel challenges, token secrets and certificate serial numbers. @@ -43,12 +43,12 @@ Exceptions from the registry and the nonce store propagate to the caller. ## Call order -Each HTTP request goes through the body size check, then authentication, then body parsing, so that the error precedence of CONTRACT.md [§5.4] holds: a body over 64 KiB fails first, and an authentication error wins over an invalid body, CSR or metadata. +Each HTTP request goes through the body size check, then authentication, then body parsing, so that the error precedence of CONTRACT.agent-identity.md [§5.4] holds: a body over 64 KiB fails first, and an authentication error wins over an invalid body, CSR or metadata. When a step fails, answer with the `Error` of its result, as [Errors][errors] describes. ### `trust-anchor` -Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normally, the old and the new during a rotation (CONTRACT.md [§5.1]). +Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normally, the old and the new during a rotation (CONTRACT.agent-identity.md [§5.1]). ### `enroll` @@ -62,8 +62,8 @@ Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normall - `Reject`: answer with `Error`. - `ReturnExistingIdentity`: take identity `ExistingIdentityId`, and change nothing. - `CreateIdentity`: mint an identity ID, issue its certificate with `IdentityCertificateIssuer.Issue`, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata and the key's `SubjectPublicKeyInfoSha256`. - Create the identity and count the token use atomically, as CONTRACT.md [§2.1] requires of concurrent enrollments. -6. Answer with `ResponseBodies.Enroll`, given the authority ID and the identity's ID, current certificate chain, leaf first, and config (CONTRACT.md [§5.2]). + Create the identity and count the token use atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent enrollments. +6. Answer with `ResponseBodies.Enroll`, given the authority ID and the identity's ID, current certificate chain, leaf first, and config (CONTRACT.agent-identity.md [§5.2]). ### Signed requests @@ -83,18 +83,18 @@ Each then acts for the result's `Identity` only. - `Reject`: answer with `Error`. - `ReturnPendingCertificate`: keep the identity's pending certificate, which holds the key. - `IssuePendingCertificate`: issue a certificate with the `IdentityCertificateIssuer.Issue` overload that takes the `AuthenticatedIdentity`, and store it as the identity's pending certificate, retiring the previous one. - Store it atomically, as CONTRACT.md [§2.1] requires of concurrent renewals. -3. Replace the identity's metadata, and answer with `ResponseBodies.Renew`, given the pending certificate's chain (CONTRACT.md [§5.3]). + Store it atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent renewals. +3. Replace the identity's metadata, and answer with `ResponseBodies.Renew`, given the pending certificate's chain (CONTRACT.agent-identity.md [§5.3]). ### `confirm` -When `Identity.CertificateStatus` is `CertificateStatus.Pending`, make the certificate current, retire the previous current certificate, and clear the request-renewal flag if it was set before the certificate was issued (CONTRACT.md [§8.2]). -Then send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` on each stream that the retired certificate opened, and close those streams with `OK` 60 s later (CONTRACT.md [§7.4]). -Answer `204`, also when the certificate was already current (CONTRACT.md [§5.5]). +When `Identity.CertificateStatus` is `CertificateStatus.Pending`, make the certificate current, retire the previous current certificate, and clear the request-renewal flag if it was set before the certificate was issued (CONTRACT.agent-identity.md [§8.2]). +Then send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` on each stream that the retired certificate opened, and close those streams with `OK` 60 s later (CONTRACT.agent-identity.md [§7.4]). +Answer `204`, also when the certificate was already current (CONTRACT.agent-identity.md [§5.5]). ### `check-in` -Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given the identity's config and its request-renewal flag (CONTRACT.md [§5.6], [§8.2]). +Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given the identity's config and its request-renewal flag (CONTRACT.agent-identity.md [§5.6], [§8.2]). ### Agent channel @@ -104,19 +104,19 @@ Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given Call `Opening.HelloTimedOutAsync` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. 4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`. -5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.md [§7.3] steps 5 and 7). -6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.md [§7.4]). -7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.md [§7.4]). +5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.agent-identity.md [§7.3] steps 5 and 7). +6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.agent-identity.md [§7.4]). +7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.agent-identity.md [§7.4]). -`VerifyHelloAsync` and `HelloTimedOutAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.md [§7.4]). -A channel opening has no body: `VerifyHelloAsync` then checks the deadline, the message and its proof, and the metadata of the `Hello` last (CONTRACT.md [§7.3] step 4). +`VerifyHelloAsync` and `HelloTimedOutAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.agent-identity.md [§7.4]). +A channel opening has no body: `VerifyHelloAsync` then checks the deadline, the message and its proof, and the metadata of the `Hello` last (CONTRACT.agent-identity.md [§7.3] step 4). When an identity's config changes, create its `IdentityConfig` again with a higher `Revision`, and send `ChannelMessages.ConfigUpdate` on each authenticated stream of the identity. -`ChannelMessages.RenewRequested` takes `RenewalRequestReason.Rotation` for the notifications of a root rotation (CONTRACT.md [§8.3]), and `RenewalRequestReason.Admin` for an administrator's request. +`ChannelMessages.RenewRequested` takes `RenewalRequestReason.Rotation` for the notifications of a root rotation (CONTRACT.agent-identity.md [§8.3]), and `RenewalRequestReason.Admin` for an administrator's request. ## Errors -Over HTTP, every non-2xx response under `{u}/api/agent-identity/v1/` must carry an error body, including those of the web framework (CONTRACT.md [§5.4]). +Over HTTP, every non-2xx response under `{u}/api/agent-identity/v1/` must carry an error body, including those of the web framework (CONTRACT.agent-identity.md [§5.4]). Answer with status `HttpStatusCode`, header `Content-Type: application/json` and body `ToJsonBody(serverTime)`. For a failure that the library does not report, use `AgentIdentityError.From` with its code, or one of these errors: @@ -131,7 +131,7 @@ For a failure that the library does not report, use `AgentIdentityError.From` wi The library writes no headers, so the host adds `Retry-After` to `ServiceUnavailable` when it sheds load. -Over gRPC, end the call by throwing `ToRpcException(serverTime)`, which carries the status of `ToGrpcStatusCode` and the trailers `error-code` and `server-time` (CONTRACT.md [§7.4]). +Over gRPC, end the call by throwing `ToRpcException(serverTime)`, which carries the status of `ToGrpcStatusCode` and the trailers `error-code` and `server-time` (CONTRACT.agent-identity.md [§7.4]). For a closure that the host decides, such as a revocation, build the error with `AgentIdentityError.From`. Log `FailureDetail` beside the error: it names the rule that the request broke and never holds a token or a secret. @@ -156,23 +156,23 @@ dotnet test ./dotnet/AgentIdentity/AgentIdentity.sln `build.ps1` builds the library in Release and packs it, with its symbol package, into `dotnet/AgentIdentity/Devolutions.AgentIdentity/bin/Release/`. The tests also check the library against the shared vectors in `docs/agent-identity/test-vectors.json`. -The package depends on `Devolutions.Agent.Channel`, the gRPC types generated from the channel schema (CONTRACT.md [§7.1]): the handshake takes its `AgentMessage` and returns its `ServerMessage`. +The package depends on `Devolutions.Agent.Channel`, the gRPC types generated from the channel schema (CONTRACT.agent-identity.md [§7.1]): the handshake takes its `AgentMessage` and returns its `ServerMessage`. Implement the `AgentChannel` service with that package; a feed that serves this package must serve that one too. -[contract]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md +[contract]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md [repo]: https://github.com/Devolutions/devolutions-gateway -[§2.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#21-authority-behavior -[§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#51-get-trust-anchor -[§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#52-post-enroll -[§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#53-post-renew -[§5.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#agent-identity-errors -[§5.5]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#55-post-confirm -[§5.6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#56-post-check-in-http-fallback -[§6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#agent-identity-signatures -[§7.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#71-service -[§7.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#73-sequence -[§7.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#74-termination -[§8.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#82-server-certificate-state -[§8.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.md#83-root-rotation +[§2.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#21-authority-behavior +[§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#51-get-trust-anchor +[§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#52-post-enroll +[§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#53-post-renew +[§5.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#agent-identity-errors +[§5.5]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#55-post-confirm +[§5.6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#56-post-check-in-http-fallback +[§6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#agent-identity-signatures +[§7.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#71-service +[§7.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#73-sequence +[§7.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#74-termination +[§8.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#82-server-certificate-state +[§8.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#83-root-rotation [invariants]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/INTENT.agent-identity.md#security-invariants [errors]: #errors From 03686c32584b25927214982319b3df251b69867c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 18:13:14 +0900 Subject: [PATCH 53/93] docs(dotnet-agent-identity): give the intent a citable section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test marks the intent statement that decides its expected value as `intent §
`, and the section must name a heading. The library intent's one requirement now sits under a heading that tests can cite. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/INTENT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/AgentIdentity/INTENT.md b/dotnet/AgentIdentity/INTENT.md index 051dd8825d..313fa08ee9 100644 --- a/dotnet/AgentIdentity/INTENT.md +++ b/dotnet/AgentIdentity/INTENT.md @@ -1,5 +1,7 @@ This library holds everything every authority must do the same way under `docs/agent-identity/CONTRACT.agent-identity.md`, and nothing about storage, hosting or management, so that DVLS and PSU cannot drift apart on the agent-facing protocol. The feature's shared intent is `INTENT.agent-identity.md` beside this file. +## Authenticated identity + - An authority must use the identity produced by the signature check. Code that acts for an identity takes it as a type only that check can create; an identity is never taken from a value the agent or a caller sent. From 0a8965cc61a90473d380e415ca31ad2829c581a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 18:48:46 +0900 Subject: [PATCH 54/93] docs(dotnet-agent-identity): state the failure-detail and token rules A failure detail for host logs never carries a value from the request, so an agent can neither write into an authority's logs nor leak replayable material through them. A token the library creates carries a compact bag with `u` alone, which fixes the longest base URL that fits the 4096-byte token limit. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/INTENT.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dotnet/AgentIdentity/INTENT.md b/dotnet/AgentIdentity/INTENT.md index 313fa08ee9..0ea06295db 100644 --- a/dotnet/AgentIdentity/INTENT.md +++ b/dotnet/AgentIdentity/INTENT.md @@ -5,3 +5,11 @@ The feature's shared intent is `INTENT.agent-identity.md` beside this file. - An authority must use the identity produced by the signature check. Code that acts for an identity takes it as a type only that check can create; an identity is never taken from a value the agent or a caller sent. + +## Failure details + +- A failure detail meant for host logs never contains a value taken from the request, such as a metadata key or value, a header, signature data or a token, so that an agent can neither write into an authority's logs nor leak replayable material through them. + +## Enrollment tokens + +- A token the library creates carries a bag with `u` alone and no insignificant white space, so the longest base URL that fits the contract's 4096-byte limit is fixed. From a3bec94eb751059b45839541d32cdf38e4d18294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 19:36:24 +0900 Subject: [PATCH 55/93] test(dotnet-agent-identity): mark the source of every test Each test, theory row or assertion names the source that decides its expected value as `// oracle: `, so one search finds every test that rests on a source, and a change to that source shows which tests it still requires. A comment that opened with a contract, intent or RFC citation becomes the marker, and its explanation stays. An assertion that only a self-derived value decides, but that alone catches a fault, is marked `mutant` with that fault in words. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/AuthenticationTypeTests.cs | 11 +- .../RegisteredCertificateTests.cs | 2 + .../RequestAuthenticatorProfileTests.cs | 36 ++++-- .../RequestAuthenticatorTests.cs | 71 +++++++++--- .../RequestAuthenticatorVectorTests.cs | 17 ++- .../Authentication/StructuredFieldTests.cs | 84 ++++++++++---- .../Bodies/AgentMetadataTests.cs | 44 ++++++-- .../Bodies/IdentityConfigTests.cs | 76 ++++++++++--- .../Bodies/RequestBodyTests.cs | 104 +++++++++++++++--- .../Bodies/ResponseBodiesTests.cs | 58 ++++++++-- .../CertificateSigningRequestTests.cs | 97 ++++++++++++---- .../IdentityCertificateIssuerTests.cs | 95 ++++++++++++---- .../Certificates/RenewalRulesTests.cs | 12 +- .../Channel/ChannelHandshakeTests.cs | 90 ++++++++++++--- .../Channel/ChannelHandshakeVectorTests.cs | 9 +- .../Channel/ChannelMessagesTests.cs | 19 +++- .../Channel/ChannelOpeningTests.cs | 22 +++- .../ConstructionTests.cs | 4 +- .../EnrollmentAuthorizationTests.cs | 50 ++++++--- .../Enrollment/EnrollmentRulesTests.cs | 50 ++++++--- .../Enrollment/EnrollmentTokenTests.cs | 45 ++++++-- .../Enrollment/FriendlyNameFormatTests.cs | 75 +++++++++++-- .../Errors/AgentIdentityErrorCodeTests.cs | 25 +++++ .../Errors/AgentIdentityErrorTests.cs | 31 +++++- .../Protocol/AgentIdentityProtocolTests.cs | 13 ++- .../Protocol/StrictBase64Tests.cs | 24 +++- 26 files changed, 940 insertions(+), 224 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs index f2d7e1f45c..a863fc0db5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs @@ -13,20 +13,26 @@ public class AuthenticationTypeTests { private static readonly Type[] AuthenticationTypes = [ + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity typeof(AuthenticatedIdentity), + // The handshake turns an opening into an identity, + // so an opening built by a caller would yield an identity that no signature check produced. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity typeof(ChannelOpening), + // oracle: mutant a public AuthenticationResult constructor typeof(AuthenticationResult), + // oracle: scaffold typeof(ChannelOpeningResult), + // oracle: scaffold typeof(ChannelHandshakeResult), ]; public static TheoryData AuthenticationTypeData => new(AuthenticationTypes); - // INTENT.md: code that acts for an identity takes it as a type only the signature check can create. + // Code that acts for an identity takes it as a type only the signature check can create. // A public or protected constructor would let a caller build an identity from any values, // or a subclass of one with its own constructor. // The other types carry an identity, or the opening that becomes one, out of the check. - // oracle: self-derived beyond AuthenticatedIdentity; sole guard for a public AuthenticationResult constructor. [Theory] [MemberData(nameof(AuthenticationTypeData))] public void TypeHasNoPublicOrProtectedConstructor(Type type) @@ -39,6 +45,7 @@ public void TypeHasNoPublicOrProtectedConstructor(Type type) } // Any other public member producing one of these types would be a factory that bypasses the signature check. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity [Fact] public void OnlyAuthenticationProducesTheseTypes() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs index 85b21dbdb0..818124149e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs @@ -8,6 +8,8 @@ public class RegisteredCertificateTests // CONTRACT.agent-identity.md Definitions: a certificate is pending, current or retired; // §5.4: identity_unknown is terminal. // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [InlineData(default(CertificateStatus))] [InlineData((CertificateStatus)4)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs index 30abe3dce9..8aaf7e71f2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs @@ -38,8 +38,11 @@ public class RequestAuthenticatorProfileTests RenewWithSignatureInput(signed => [$"sig={signed}", $"sig2={signed}"]), // RFC 8941 §4.2 joins field lines with commas, and §4.2.2 fails a dictionary that ends in one. + // oracle: spec RFC 8941 §4.2 + // oracle: spec RFC 8941 §4.2.2 ["Signature-Input with an empty second field line"] = () => RenewWithSignatureInput(signed => [$"sig={signed}", ""]), + // oracle: spec RFC 8941 §4.2.2 ["Signature-Input ending in a comma"] = () => RenewWithSignatureInput(signed => [$"sig={signed},"]), @@ -92,6 +95,7 @@ public class RequestAuthenticatorProfileTests // RFC 8941 §3.3.1 allows at most 15 digits, leading zeros included, // and RFC 9421 §2.3 signs the integer as RFC 8941 §4.1.4 serializes it, without them. + // oracle: spec RFC 8941 §3.3.1 ["created of 16 digits"] = () => RenewWithSignatureInput(signed => [$"sig={signed.Replace(CreatedParameter, $";created=000000{Created}")}"]), @@ -109,6 +113,7 @@ public class RequestAuthenticatorProfileTests // RFC 8941 §4.2.5 escapes only a quote and a backslash, // so a lenient parser reads \n as n, and the tag as renew. + // oracle: spec RFC 8941 §4.2.5 ["tag with an escape RFC 8941 does not define"] = () => RenewWithSignatureInput(signed => [$"sig={signed.Replace(TagParameter, @";tag=""re\new""")}"]), @@ -156,8 +161,10 @@ public class RequestAuthenticatorProfileTests .ToHttpRequest(), // The signed base serializes the parsed member, so the white space RFC 8941 allows never reaches it. + // oracle: spec RFC 8941 §4.2.3.2 ["a space after each semicolon"] = () => RenewWithSignatureInput(signed => [$"sig={signed.Replace(";", "; ")}"]), + // oracle: spec RFC 8941 §4.2.1.2 ["spaces inside the inner list"] = () => { TestRequest request = Renew(); @@ -166,6 +173,7 @@ public class RequestAuthenticatorProfileTests }, // A proxy that combines field lines can leave white space around each one; RFC 9421 §2.1 removes it. + // oracle: spec RFC 9421 §2.1 ["white space around each field line"] = () => { TestRequest request = Renew(); @@ -177,6 +185,8 @@ public class RequestAuthenticatorProfileTests // RFC 8941 §3.3.1 allows 15 digits, leading zeros included, // and RFC 9421 §2.3 signs the integer as RFC 8941 §4.1.4 serializes it, without them. + // oracle: spec RFC 8941 §3.3.1 + // oracle: spec RFC 8941 §4.1.4 ["created with leading zeros, in 15 digits"] = () => RenewWithSignatureInput(signed => [$"sig={signed.Replace(CreatedParameter, $";created=00000{Created}")}"]), }; @@ -185,18 +195,24 @@ public class RequestAuthenticatorProfileTests // most break RFC 8941 at their very end, where a parser that read on would leave the input. private static readonly Dictionary> MalformedSignatureInputs = new() { - // RFC 9421 §3.2: the member is an inner list. + // The member is an inner list. + // oracle: spec RFC 9421 §3.2 ["an integer member"] = () => RenewWithSignatureInput(_ => ["sig=1"]), - // RFC 8941 §4.2.3.2: a parameter has a key, and a key follows its equals sign with a bare item. + // A parameter has a key, and a key follows its equals sign with a bare item. + // oracle: spec RFC 8941 §4.2.3.2 ["a semicolon at the end"] = () => RenewWithSignatureInput(signed => [$"sig={signed};"]), + // oracle: spec RFC 8941 §4.2.3.2 ["created last, without a value"] = () => RenewWithLast("created", ""), - // RFC 8941 §4.2.4: a minus sign is followed by a digit. + // A minus sign is followed by a digit. + // oracle: spec RFC 8941 §4.2.4 ["created last, a minus sign alone"] = () => RenewWithLast("created", "-"), + // oracle: spec RFC 8941 §4.2.4 ["created last, a minus sign before a letter"] = () => RenewWithLast("created", "-x"), - // RFC 8941 §4.2.5: a backslash is followed by the character it escapes, and a closing quote ends a string. + // A backslash is followed by the character it escapes, and a closing quote ends a string. + // oracle: spec RFC 8941 §4.2.5 ["tag last, unclosed, ending in a backslash"] = () => RenewWithLast("tag", "\"renew\\"), }; @@ -237,6 +253,7 @@ public static TheoryData ContentDigestViolationCases // A verifier that keeps the last of repeated keys, decodes leniently, or reads a parameter of another type // accepts a request whose signature data a proxy, a log or another parser can read differently. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(StepOneViolationNames))] public async Task CorrectlySignedRequestBreakingAStepOneRuleIsSignatureInvalid(string violation) @@ -248,8 +265,9 @@ public async Task CorrectlySignedRequestBreakingAStepOneRuleIsSignatureInvalid(s Assert.Equal("signature_invalid", outcome); } - // CONTRACT.agent-identity.md §6 step 1: for renew and check-in, Content-Digest holds exactly one member, sha-256, + // Step 1: for renew and check-in, Content-Digest holds exactly one member, sha-256, // without parameters, in canonical base64. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(ContentDigestViolationCases))] public async Task CorrectlySignedRequestBreakingAContentDigestRuleIsSignatureInvalid( @@ -264,8 +282,9 @@ public async Task CorrectlySignedRequestBreakingAContentDigestRuleIsSignatureInv Assert.Equal("signature_invalid", outcome); } - // CONTRACT.agent-identity.md §6: parameters may appear in any order, and the verifier builds @signature-params + // Parameters may appear in any order, and the verifier builds @signature-params // from the received member, so a verifier that copied the received text would reject these. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(AcceptedFormNames))] public async Task RequestInAFormThatRfc8941AllowsIsAccepted(string form) @@ -277,8 +296,9 @@ public async Task RequestInAFormThatRfc8941AllowsIsAccepted(string form) Assert.Equal("ok", outcome); } - // CONTRACT.agent-identity.md §6 step 1: a malformed Signature-Input draws signature_invalid, + // Step 1: a malformed Signature-Input draws signature_invalid, // as a result rather than an exception, and records no nonce. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(MalformedSignatureInputNames))] public async Task MalformedSignatureInputIsSignatureInvalid(string form) @@ -294,6 +314,8 @@ public async Task MalformedSignatureInputIsSignatureInvalid(string form) // RFC 8941 §4.2.5: an escaped backslash decodes to one backslash, so the received tag is re\new, // which CONTRACT.agent-identity.md §6 step 2 rejects for renew. // The signature covers the tag renew, which a parser that dropped the escaped character would read. + // oracle: spec RFC 8941 §4.2.5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Fact] public async Task TagWithAnEscapedBackslashIsAnotherTag() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs index a6d3af9e32..f5eeec854d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs @@ -121,8 +121,8 @@ public static TheoryData OperationsWithAccept private static TimeSpan DeviceALifetime => DeviceACertificate.NotAfter - DeviceACertificate.NotBefore; - // CONTRACT.agent-identity.md §5.4: a body larger than 64 KiB returns 413 invalid_request - // before any authentication outcome. + // A body larger than 64 KiB returns 413 invalid_request before any authentication outcome. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [Theory] [MemberData(nameof(OversizedRequestNames))] public async Task BodyOverTheLimitIsRejectedWith413First(string request) @@ -136,11 +136,14 @@ public async Task BodyOverTheLimitIsRejectedWith413First(string request) Assert.False(result.Succeeded); Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); Assert.Equal(413, result.Error.HttpStatusCode); + // oracle: scaffold Assert.Empty(authority.Registry.Lookups); + // oracle: scaffold Assert.Empty(authority.Nonces.Calls); } - // CONTRACT.agent-identity.md §5.4: servers accept request bodies up to 64 KiB. + // Servers accept request bodies up to 64 KiB. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [Fact] public async Task BodyOfExactlyTheLimitIsAccepted() { @@ -155,8 +158,9 @@ public async Task BodyOfExactlyTheLimitIsAccepted() Assert.Equal("ok", await authority.OutcomeAsync(request)); } - // CONTRACT.agent-identity.md §6: the earliest failing step decides, + // The earliest failing step decides, // so the identity's state is reported even when the signature or the digest would not verify. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [InlineData("revoked identity, wrong key", "identity_revoked")] [InlineData("retired certificate, wrong key", "identity_unknown")] @@ -170,7 +174,8 @@ public async Task StepFourFailureWinsOverAStepFiveFailure(string request, string } // CONTRACT.agent-identity.md §6 step 4 resolves the certificate before it reads the identity's revocation, - // and reads revocation before validity, so a revoked identity always draws the terminal identity_revoked. + // and reads revocation before validity, so an expired certificate of a revoked identity draws identity_revoked. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [InlineData("retired certificate, revoked identity", "identity_unknown")] [InlineData("expired certificate, revoked identity", "identity_revoked")] @@ -181,7 +186,8 @@ public async Task StepFourReturnsItsEarliestFailingCheck(string request, string Assert.Equal(expected, await authority.OutcomeAsync(failing)); } - // CONTRACT.agent-identity.md §6 table: every operation accepts a current or pending certificate. + // Table: every operation accepts a current or pending certificate. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(OperationsWithAcceptedStatuses))] public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation, CertificateStatus status) @@ -192,7 +198,8 @@ public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.agent-identity.md §6 step 4: a certificate that is neither current nor pending draws identity_unknown. + // Step 4: a certificate that is neither current nor pending draws identity_unknown. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(Operations))] public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) @@ -203,9 +210,10 @@ public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) Assert.Equal("identity_unknown", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.agent-identity.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + // A certificate is unexpired while now ≤ notAfter, compared to the exact instant. // A clock truncated to the second would still accept the certificate one tick after its notAfter. // Renew has no row one tick after, because its grace still accepts the certificate then. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [InlineData(AgentOperation.Renew, 0, "ok")] [InlineData(AgentOperation.Confirm, 0, "ok")] @@ -224,8 +232,8 @@ public async Task CertificateExpiresAfterTheExactInstantOfItsNotAfter( Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.agent-identity.md §6: notBefore is not checked, - // so a certificate dated by a fast authority clock does not lock devices out. + // notBefore is not checked, so a certificate dated by a fast authority clock does not lock devices out. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(Operations))] public async Task CertificateIsAcceptedBeforeItsNotBefore(AgentOperation operation) @@ -235,7 +243,8 @@ public async Task CertificateIsAcceptedBeforeItsNotBefore(AgentOperation operati Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.agent-identity.md §6 table: renew accepts a certificate while now < notAfter + (notAfter − notBefore). + // Table: renew accepts a certificate while now < notAfter + (notAfter − notBefore). + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [InlineData(CertificateStatus.Pending, "one tick after notAfter", "ok")] [InlineData(CertificateStatus.Current, "one tick after notAfter", "ok")] @@ -258,6 +267,7 @@ public async Task RenewGraceIsTheCertificateLifetimeAfterItsNotAfter( // against the same `now`. // A second reading of the clock between the window and the certificate check // would judge the certificate a day later, after it expired. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Fact] public async Task WindowAndCertificateAreJudgedAtOneReadingOfTheClock() { @@ -267,7 +277,8 @@ public async Task WindowAndCertificateAreJudgedAtOneReadingOfTheClock() Assert.Equal("ok", await authority.OutcomeAsync(authority.Valid(AgentOperation.Confirm))); } - // CONTRACT.agent-identity.md §6 step 3: 0 < expires − created ≤ 300. + // Step 3: 0 < expires − created ≤ 300. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Fact] public async Task SignatureLifetimeOf300SecondsIsAccepted() { @@ -283,6 +294,9 @@ public async Task SignatureLifetimeOf300SecondsIsAccepted() // RFC 8941 §3.3.1: an integer may be negative, and RFC 9421 §2.3 serializes it with its sign. // At the Unix epoch, all three inequalities of CONTRACT.agent-identity.md §6 step 3 hold, // and the certificate of device-a, whose notBefore is later, is accepted because §6 does not check notBefore. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: spec RFC 8941 §3.3.1 + // oracle: spec RFC 9421 §2.3 [Fact] public async Task CreatedBeforeTheUnixEpochIsAccepted() { @@ -297,6 +311,8 @@ public async Task CreatedBeforeTheUnixEpochIsAccepted() // CONTRACT.agent-identity.md §6: the earliest failing step decides. // RFC 8941 §3.3.5 allows a byte sequence of no bytes, in its canonical base64 of no characters, // so the signature passes step 1 and its length fails only step 5, after the window of step 3. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: spec RFC 8941 §3.3.5 [Fact] public async Task SignatureOfNoBytesIsJudgedAfterTheWindow() { @@ -311,7 +327,8 @@ public async Task SignatureOfNoBytesIsJudgedAfterTheWindow() Assert.Equal("clock_skew", outcome); } - // CONTRACT.agent-identity.md §6 step 6: a pair the store already holds fails, whichever request recorded it. + // Step 6: a pair the store already holds fails, whichever request recorded it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(Operations))] public async Task NonceAlreadyRecordedIsSignatureInvalid(AgentOperation operation) @@ -321,8 +338,8 @@ public async Task NonceAlreadyRecordedIsSignatureInvalid(AgentOperation operatio Assert.Equal("signature_invalid", await authority.OutcomeAsync(authority.Valid(operation))); } - // CONTRACT.agent-identity.md §6: a header the operation does not cover, such as Content-Digest on confirm, - // is ignored. + // A header the operation does not cover, such as Content-Digest on confirm, is ignored. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Fact] public async Task ConfirmIgnoresContentDigest() { @@ -336,7 +353,10 @@ public async Task ConfirmIgnoresContentDigest() Assert.Equal("ok", outcome); } - // RFC 9110 §9.1: a method is a token, and anything else could put a line break of its own into the signature base. + // A method is a token, and anything else could put a line break of its own into the signature base. + // oracle: spec RFC 9110 §9.1 + // oracle: spec RFC 9110 §5.6.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [InlineData("")] [InlineData("PO ST")] @@ -352,6 +372,8 @@ public async Task MethodThatIsNotATokenIsSignatureInvalid(string method) // A method that is not a token gives @method no value, so no signature base exists to verify, // not even an empty one: RFC 9421 §2.5 ends every base with its @signature-params line. + // oracle: spec RFC 9421 §2.5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Fact] public async Task MethodThatIsNotATokenCannotVerifyASignatureOverNoBytes() { @@ -371,7 +393,10 @@ public async Task MethodThatIsNotATokenCannotVerifyASignatureOverNoBytes() Assert.Equal("signature_invalid", outcome); } - // RFC 9110 §5.6.2: a token may hold any of these punctuation characters, besides letters and digits. + // A token may hold any of these punctuation characters, besides letters and digits. + // oracle: spec RFC 9110 §5.6.2 + // oracle: spec RFC 9110 §9.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Fact] public async Task MethodWithEveryTokenPunctuationCharacterIsAccepted() { @@ -386,9 +411,13 @@ public async Task MethodWithEveryTokenPunctuationCharacterIsAccepted() // The shared intent, security invariant 7: signed opening headers never authenticate a stream on their own. // An uninitialized operation names no endpoint, so it authenticates nothing either. // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants [InlineData(AgentOperation.Connect)] + // oracle: spec Framework Design Guidelines §Validating Arguments [InlineData((AgentOperation)0)] + // oracle: spec Framework Design Guidelines §Validating Arguments [InlineData((AgentOperation)5)] public async Task AuthenticateAsyncRefusesAnOperationThatIsNotASignedHttpRequest(AgentOperation operation) { @@ -397,12 +426,16 @@ public async Task AuthenticateAsyncRefusesAnOperationThatIsNotASignedHttpRequest await Assert.ThrowsAsync( () => authority.Authenticator.AuthenticateAsync(operation, request).AsTask()); + // oracle: scaffold Assert.Empty(authority.Nonces.Calls); } // The registry answers by thumbprint, so another certificate is a host bug, // which must fail as a server error rather than draw an outcome the agent acts on. // CONTRACT.agent-identity.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: spec Framework Design Guidelines §Exception Throwing + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Fact] public async Task RegistryReturningACertificateWithAnotherThumbprintThrows() { @@ -420,6 +453,10 @@ await Assert.ThrowsAsync( // so any other registered bytes are a host bug. // CONTRACT.agent-identity.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. // The keyid is the thumbprint of those bytes, so the registry answers with what it was asked for. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: spec Framework Design Guidelines §Exception Throwing + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [MemberData(nameof(RegisteredBytesOtherThanAP256CertificateNames))] public async Task RegistryReturningSomethingOtherThanAP256CertificateThrows(string registered) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs index 7f3c25ba4f..32636f79d2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs @@ -23,6 +23,7 @@ public class RequestAuthenticatorVectorTests // Each case isolates one rule of CONTRACT.agent-identity.md §6 or its precedence, // so any outcome but the vector's breaks that rule. + // oracle: reference docs/agent-identity/test-vectors.json http_signature.cases [Theory] [MemberData(nameof(VectorData.HttpSignatureCaseNames), MemberType = typeof(VectorData))] public async Task CaseReturnsItsExpectedOutcome(string name) @@ -38,6 +39,7 @@ public async Task CaseReturnsItsExpectedOutcome(string name) // test-vectors.json http_signature.sequences, with CONTRACT.agent-identity.md §6 step 6. // A replayed nonce must fail, a nonce must survive a request that failed verification, // and a nonce accepted for one operation must not authenticate another. + // oracle: reference docs/agent-identity/test-vectors.json http_signature.sequences [Theory] [MemberData(nameof(VectorData.HttpSignatureSequenceNames), MemberType = typeof(VectorData))] public async Task SequenceReturnsItsExpectedOutcomes(string name) @@ -57,6 +59,8 @@ public async Task SequenceReturnsItsExpectedOutcomes(string name) // test-vectors.json signature_base of each case, as RFC 9421 §2.5 builds it. // A signature base that differs from the signed one in any byte fails verification for every agent, // and an accepted request does not show which byte the verifier would get wrong in another request. + // oracle: reference docs/agent-identity/test-vectors.json http_signature.cases + // oracle: spec RFC 9421 §2.5 [Theory] [MemberData(nameof(OkCaseNames))] public void OkCaseSignatureBaseIsTheVectorBase(string name) @@ -79,6 +83,8 @@ public void OkCaseSignatureBaseIsTheVectorBase(string name) // RFC 9421 Appendix B.2.4 is the RFC's own ecdsa-p256-sha256 example, // so it fails a primitive that hashes with another algorithm or reads another signature format. + // oracle: spec RFC 9421 §B.2.4 + // oracle: spec RFC 9421 §B.1.3 [Fact] public void Rfc9421B24VerifiesWithTheLibraryPrimitive() { @@ -92,9 +98,9 @@ public void Rfc9421B24VerifiesWithTheLibraryPrimitive() Assert.Equal(vector.ExpectedValid, valid); } - // CONTRACT.agent-identity.md §6: nonces are committed only after the signature verifies, - // so garbage requests cannot burn nonces. + // Nonces are committed only after the signature verifies, so garbage requests cannot burn nonces. // With an empty nonce store, every failing case fails before step 6. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(FailingCaseNames))] public async Task FailingCaseRecordsNoNonce(string name) @@ -108,6 +114,7 @@ public async Task FailingCaseRecordsNoNonce(string name) // The failure detail goes to host logs, so a detail that echoed the request would carry text the sender chose, // and a captured signature, into those logs. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Failure details [Theory] [MemberData(nameof(FailingCaseNames))] public async Task FailingCaseDetailHoldsNoRequestData(string name) @@ -131,6 +138,8 @@ public async Task FailingCaseDetailHoldsNoRequestData(string name) // CONTRACT.agent-identity.md §6 step 6 and the shared intent: the authority keeps each accepted nonce // until 60 s after its signature expires, the last moment the window could accept the signature again. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Request authentication [Theory] [MemberData(nameof(OkCaseNames))] public async Task OkCaseRecordsItsNonceOnceUntilExpiresPlus60Seconds(string name) @@ -152,6 +161,8 @@ public async Task OkCaseRecordsItsNonceOnceUntilExpiresPlus60Seconds(string name // Code that acts for an identity relies on these facts, // such as the status that tells confirm whether to promote the certificate. // The registered certificates of the vector file state them, in Unix seconds, independently of any time zone. + // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] [MemberData(nameof(OkSignedRequestCaseNames))] public async Task OkCaseIdentityIsThatOfTheSigningCertificate(string name) @@ -179,6 +190,8 @@ public async Task OkCaseIdentityIsThatOfTheSigningCertificate(string name) Assert.Equal(registered.CertificateStatus, identity.CertificateStatus.ToString().ToLowerInvariant()); Assert.Equal(registered.NotBefore, identity.CertificateNotBefore); Assert.Equal(registered.NotAfter, identity.CertificateNotAfter); + // A renewal acts only for an identity that a renew request proved, so the identity carries its operation. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity Assert.Equal(VectorAuthority.Operation(vector.Endpoint), identity.Operation); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs index ab42972f12..d6cb296242 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs @@ -16,34 +16,54 @@ public class StructuredFieldTests // Each expected value names the RFC 8941 type of each value, // so a value of another type, or a success without a value, fails. [Theory] - // RFC 8941 §4.2 step 2: leading spaces are discarded. + // Step 2: leading spaces are discarded. + // oracle: spec RFC 8941 §4.2 [InlineData(" sig=1", "sig=Integer(1)")] - // RFC 8941 §4.2.2: a comma with optional white space around it separates members. + // A comma with optional white space around it separates members. + // oracle: spec RFC 8941 §4.2.2 [InlineData("sig=1, other=2", "sig=Integer(1), other=Integer(2)")] + // oracle: spec RFC 8941 §4.2.2 [InlineData("sig=1 , other=2", "sig=Integer(1), other=Integer(2)")] + // oracle: spec RFC 8941 §4.2.2 [InlineData("sig=1\t,\tother=2", "sig=Integer(1), other=Integer(2)")] // RFC 8941 §4.2.2 and §4.2.3.2: a member or a parameter without a value is the Boolean true. + // oracle: spec RFC 8941 §4.2.2 [InlineData("sig", "sig=Boolean(True)")] + // oracle: spec RFC 8941 §4.2.3.2 [InlineData("sig=1;x", "sig=Integer(1);x=Boolean(True)")] - // RFC 8941 §4.2.3.3: a key may start with an asterisk. + // A key may start with an asterisk. + // oracle: spec RFC 8941 §4.2.3.3 [InlineData("*=1", "*=Integer(1)")] - // RFC 8941 §4.2.4: a decimal has at most 12 integer digits and one to three fraction digits. + // A decimal has at most 12 integer digits and one to three fraction digits. + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=1.23", "sig=Decimal(1.23)")] + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=-1.23", "sig=Decimal(-1.23)")] + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=1.234", "sig=Decimal(1.234)")] + // oracle: spec RFC 8941 §4.2.4 [InlineData("abcdef=1.0", "abcdef=Decimal(1.0)")] + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=123456789012.123", "sig=Decimal(123456789012.123)")] - // RFC 8941 §4.2.5: a string holds printable ASCII, and a backslash escapes a quote or a backslash. + // A string holds printable ASCII, and a backslash escapes a quote or a backslash. + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x y\"", "sig=String(x y)")] + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x~y\"", "sig=String(x~y)")] + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x\\\"y\"", "sig=String(x\"y)")] + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x\\\\y\"", "sig=String(x\\y)")] - // RFC 8941 §4.2.6: a token starts with a letter or an asterisk, and may hold a colon or a slash. + // A token starts with a letter or an asterisk, and may hold a colon or a slash. + // oracle: spec RFC 8941 §4.2.6 [InlineData("sig=abc", "sig=Token(abc)")] + // oracle: spec RFC 8941 §4.2.6 [InlineData("sig=*abc", "sig=Token(*abc)")] + // oracle: spec RFC 8941 §4.2.6 [InlineData("sig=ab:/c", "sig=Token(ab:/c)")] - // RFC 8941 §4.2.8. + // oracle: spec RFC 8941 §4.2.8 [InlineData("sig=?1", "sig=Boolean(True)")] + // oracle: spec RFC 8941 §4.2.8 [InlineData("sig=?0", "sig=Boolean(False)")] public void ParsesADictionary(string input, string expected) { @@ -52,51 +72,77 @@ public void ParsesADictionary(string input, string expected) } [Theory] - // RFC 8941 §4.2 step 1: a field value is ASCII. + // Step 1: a field value is ASCII. + // oracle: spec RFC 8941 §4.2 [InlineData("sig\u0080=1")] - // RFC 8941 §4.2.3.3: a key starts with a lowercase letter or an asterisk. + // A key starts with a lowercase letter or an asterisk. + // oracle: spec RFC 8941 §4.2.3.3 [InlineData("Sig=1")] - // RFC 8941 §4.2.3.1: an item starts with the first character of a bare item. + // An item starts with the first character of a bare item. + // oracle: spec RFC 8941 §4.2.3.1 [InlineData("sig=")] + // oracle: spec RFC 8941 §4.2.3.1 [InlineData("sig=@")] + // oracle: spec RFC 8941 §4.2.3.1 [InlineData("sig=,x=1")] - // RFC 8941 §4.2.3.2: a parameter has a key. + // A parameter has a key. + // oracle: spec RFC 8941 §4.2.3.2 [InlineData("sig;")] + // oracle: spec RFC 8941 §4.2.3.2 [InlineData("sig=1;")] + // oracle: spec RFC 8941 §4.2.3.2 [InlineData("sig=(\"@method\");")] // CONTRACT.agent-identity.md §6 step 1 rejects a repeated key, of which RFC 8941 §4.2.2 would keep the last. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [InlineData("sig=1, sig=2")] - // RFC 8941 §4.2.2: a comma separates members, and none follows the last. + // A comma separates members, and none follows the last. + // oracle: spec RFC 8941 §4.2.2 [InlineData("sig=1 x")] + // oracle: spec RFC 8941 §4.2.2 [InlineData("sig=1,")] - // RFC 8941 §4.2.1.2: an inner list ends with a closing parenthesis. + // An inner list ends with a closing parenthesis. + // oracle: spec RFC 8941 §4.2.1.2 [InlineData("sig=(")] + // oracle: spec RFC 8941 §4.2.1.2 [InlineData("sig=( ")] + // oracle: spec RFC 8941 §4.2.1.2 [InlineData("sig=(\"@method\"")] - // RFC 8941 §4.2.4: a digit follows the sign, an integer has at most 15 digits, - // and a decimal has one to three fraction digits. + // A digit follows the sign, an integer has at most 15 digits, and a decimal has one to three fraction digits. + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=-")] + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=1234567890123456")] + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=1.2345")] + // oracle: spec RFC 8941 §4.2.4 [InlineData("sig=1.")] - // RFC 8941 §4.2.5: a backslash escapes a quote or a backslash, DEL is not printable, and a quote ends a string. + // A backslash escapes a quote or a backslash, DEL is not printable, and a quote ends a string. // Each string reaches the end of the input, so the parser cannot fail later on a character after it. + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x\\")] + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x\\q")] + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x\u007F")] + // oracle: spec RFC 8941 §4.2.5 [InlineData("sig=\"x")] - // RFC 8941 §4.2.7: a byte sequence ends with a colon and holds only base64 characters. + // A byte sequence ends with a colon and holds only base64 characters. + // oracle: spec RFC 8941 §4.2.7 [InlineData("sig=:")] + // oracle: spec RFC 8941 §4.2.7 [InlineData("sig=:A!==:")] - // RFC 8941 §4.2.8: a Boolean is ?1 or ?0. + // A Boolean is ?1 or ?0. + // oracle: spec RFC 8941 §4.2.8 [InlineData("sig=?")] public void RejectsAMalformedDictionary(string input) { Assert.False(TryParseDictionary(input, out IReadOnlyList? dictionary)); + // oracle: scaffold Assert.Null(dictionary); } - // RFC 8941 §4.1.6: a string is quoted, and a backslash precedes each quote or backslash in it. + // A string is quoted, and a backslash precedes each quote or backslash in it. + // oracle: spec RFC 8941 §4.1.6 [Theory] [InlineData("", "\"\"")] [InlineData("x", "\"x\"")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs index 1a490e81c1..65c790d851 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs @@ -4,7 +4,8 @@ namespace Devolutions.AgentIdentity.Tests; public class AgentMetadataTests { - // CONTRACT.agent-identity.md §3: metadata may be an empty object. + // Metadata may be an empty object. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void AcceptsNoEntries() { @@ -14,6 +15,7 @@ public void AcceptsNoEntries() } // AgentMetadata documents that enumeration follows the order in which the agent sent the entries. + // oracle: scaffold [Fact] public void KeepsEveryEntryInTheOrderReceived() { @@ -32,6 +34,7 @@ public void KeepsEveryEntryInTheOrderReceived() // A host that enumerates the metadata through the non-generic IEnumerable sees the same entries in the same order. // Enumerable.Cast would take the generic enumerator, so the loop calls the non-generic one. + // oracle: mutant the non-generic GetEnumerator returns no enumerator instead of the entries [Fact] public void NonGenericEnumerationKeepsEveryEntryInTheOrderReceived() { @@ -48,6 +51,7 @@ public void NonGenericEnumerationKeepsEveryEntryInTheOrderReceived() } // IReadOnlyDictionary.ContainsKey is true for a key received and false for any other. + // oracle: spec .NET API reference §IReadOnlyDictionary.ContainsKey [Fact] public void ContainsOnlyTheKeysReceived() { @@ -57,7 +61,8 @@ public void ContainsOnlyTheKeysReceived() Assert.False(metadata.ContainsKey("absent")); } - // CONTRACT.agent-identity.md §3: at most 32 keys. + // At most 32 keys. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void Accepts32Keys() { @@ -66,13 +71,15 @@ public void Accepts32Keys() Assert.Equal(32, metadata.Count); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void Rejects33Keys() { AssertInvalid(AgentMetadata.Validate(NumberedEntries(33))); } - // CONTRACT.agent-identity.md §3: a key matches ^[a-z][a-z0-9_]{0,63}$. + // A key matches ^[a-z][a-z0-9_]{0,63}$. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData("a")] [InlineData("hostname")] @@ -84,12 +91,14 @@ public void AcceptsKeyMatchingThePattern(string key) AssertValid(AgentMetadata.Validate([new(key, "value")])); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void AcceptsKeyOf64Characters() { AssertValid(AgentMetadata.Validate([new("k" + new string('0', 63), "value")])); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void RejectsKeyOf65Characters() { @@ -98,6 +107,7 @@ public void RejectsKeyOf65Characters() // A case-insensitive match would take the Kelvin sign for 'k', // and a .NET regular expression ending in $ also matches before a final line feed. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData("empty", "")] [InlineData("uppercase first letter", "Hostname")] @@ -115,8 +125,9 @@ public void RejectsKeyNotMatchingThePattern(string form, string key) AssertInvalid(AgentMetadata.Validate([new(key, "value")]), form); } - // CONTRACT.agent-identity.md §3: a value has at most 1024 UTF-8 bytes. + // A value has at most 1024 UTF-8 bytes. // Characters of two, three and four bytes count by their bytes, not as one character or UTF-16 code unit. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData("a", 1024, "")] [InlineData("\u00E9", 512, "")] @@ -127,6 +138,7 @@ public void AcceptsValueOf1024Utf8Bytes(string unit, int count, string suffix) AssertValid(AgentMetadata.Validate([new("hostname", Repeat(unit, count) + suffix)])); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData("a", 1025, "")] [InlineData("\u00E9", 512, "a")] @@ -138,17 +150,18 @@ public void RejectsValueOf1025Utf8Bytes(string unit, int count, string suffix) } // CONTRACT.agent-identity.md §3 gives a value no minimum length. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void AcceptsEmptyValue() { AssertValid(AgentMetadata.Validate([new("domain", "")])); } - // CONTRACT.agent-identity.md §3: no C0 (U+0000 to U+001F), - // DEL (U+007F) or C1 (U+0080 to U+009F) control characters. + // No C0 (U+0000 to U+001F), DEL (U+007F) or C1 (U+0080 to U+009F) control characters. public static TheoryData ControlCharacters => new(Enumerable.Range(0x00, 0x20).Append(0x7F).Concat(Enumerable.Range(0x80, 0x20))); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [MemberData(nameof(ControlCharacters))] public void RejectsValueWithControlCharacter(int codePoint) @@ -157,6 +170,7 @@ public void RejectsValueWithControlCharacter(int codePoint) } // The neighbors of the control ranges, including U+00A0, the first character after C1. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData(0x20)] [InlineData(0x7E)] @@ -168,6 +182,8 @@ public void AcceptsValueWithCharacterNextToAControlRange(int codePoint) // CONTRACT.agent-identity.md §3 measures a value in UTF-8 bytes. // A string holding an unpaired surrogate is not Unicode text, so it has no UTF-8 encoding to measure or store. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: spec RFC 3629 §3 [Theory] [InlineData("lone high surrogate", 0x61, 0xD800, 0x62)] [InlineData("lone low surrogate", 0x61, 0xDC00, 0x62)] @@ -180,9 +196,9 @@ public void RejectsValueWithUnpairedSurrogate(string form, params int[] codeUnit AssertInvalid(AgentMetadata.Validate([new("hostname", value)]), form); } - // CONTRACT.agent-identity.md §3: the whole object has at most 16 KiB, - // the sum of the UTF-8 byte lengths of all keys and values. + // The whole object has at most 16 KiB, the sum of the UTF-8 byte lengths of all keys and values. // Each value has 1021 UTF-8 bytes but 511 characters, so a count of characters would stay far below the limit. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void AcceptsObjectOf16KiB() { @@ -190,6 +206,7 @@ public void AcceptsObjectOf16KiB() AssertValid(AgentMetadata.Validate(EntriesOf1024Bytes(lastKey: "k15"))); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void RejectsObjectOf16KiBAndOneByte() { @@ -199,25 +216,28 @@ public void RejectsObjectOf16KiBAndOneByte() // CONTRACT.agent-identity.md §1 makes one metadata key sent twice invalid_request. // A later entry with the same key would silently replace the earlier one. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void RejectsRepeatedKey() { AssertInvalid(AgentMetadata.Validate([new("hostname", "a"), new("hostname", "b")])); } - // MetadataValidationResult.FailureDetail documents that the detail quotes a key only when the key is valid. - // The failure detail goes to host logs, so it must not copy an invalid key, which could hold a line break. + // The failure detail goes to host logs, so it holds no metadata key, such as an invalid one with a line break. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Failure details [Fact] public void FailureDetailDoesNotQuoteAnInvalidKey() { MetadataValidationResult result = AgentMetadata.Validate([new("host\nforged log line", "value")]); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 AssertInvalid(result); Assert.DoesNotContain("forged log line", result.FailureDetail); } // AgentMetadata.Validate documents an ArgumentException for an entry with a null key or value. // A null key or value can only come from the host, since neither JSON nor protobuf metadata can hold one. + // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [InlineData(null, "value")] [InlineData("hostname", null)] @@ -229,6 +249,7 @@ public void ThrowsForNullKeyOrValue(string? key, string? value) private static AgentMetadata AssertValid(MetadataValidationResult result) { Assert.True(result.Succeeded, result.FailureDetail); + // oracle: scaffold Assert.Null(result.Error); Assert.Null(result.FailureDetail); @@ -238,9 +259,12 @@ private static AgentMetadata AssertValid(MetadataValidationResult result) private static void AssertInvalid(MetadataValidationResult result, string? form = null) { Assert.False(result.Succeeded, form); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); Assert.Equal(400, result.Error.HttpStatusCode); + // oracle: mutant an empty failure detail for a repeated key or for metadata over 16 KiB Assert.False(string.IsNullOrEmpty(result.FailureDetail)); + // oracle: scaffold Assert.Null(result.Metadata); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index e5fb4316fb..3c262be77c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -4,8 +4,9 @@ namespace Devolutions.AgentIdentity.Tests; public class IdentityConfigTests { - // CONTRACT.agent-identity.md §5.2: `config` holds `version`, which is 1, `revision` and `agent_channel_url`. + // `config` holds `version`, which is 1, `revision` and `agent_channel_url`. // An agent reads both numbers as integers, so 1.0 or 3e0 would not do. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Fact] public void JsonHasTheContractMembers() { @@ -24,6 +25,8 @@ public void JsonHasTheContractMembers() // CONTRACT.agent-identity.md §5.2: `agent_channel_url` is absent when the server does not offer the channel, // and §1 omits an absent field rather than writing null. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Fact] public void JsonOmitsAnAbsentAgentChannelUrl() { @@ -35,9 +38,9 @@ public void JsonOmitsAnAbsentAgentChannelUrl() Assert.Null(config.AgentChannelUrl); } - // CONTRACT.agent-identity.md §5.2: `revision` is an unsigned 64-bit integer, - // with no starting value the agent assumes. + // `revision` is an unsigned 64-bit integer, with no starting value the agent assumes. // A double holds no more than 53 bits, so it would round the largest revisions. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Theory] [InlineData(0UL, "0")] [InlineData(9007199254740993UL, "9007199254740993")] @@ -55,29 +58,45 @@ public void JsonWritesEveryRevisionExactly(ulong revision, string expected) // CONTRACT.agent-identity.md §5.2: `agent_channel_url` is an absolute `https` URL without a query or fragment. // RFC 9110 §4.2.4: a sender does not generate user information in an `https` URI. [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [InlineData("")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [InlineData("/dvls")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [InlineData("host/dvls")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [InlineData("http://host/dvls")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [InlineData("wss://host/dvls")] + // oracle: spec RFC 9110 §4.2.2 [InlineData("https:///dvls")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [InlineData("https://host/dvls?tenant=1")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: spec RFC 3986 §3.4 [InlineData("https://host/dvls?")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [InlineData("https://host/dvls#top")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: spec RFC 3986 §3.5 [InlineData("https://host/dvls#")] + // oracle: spec RFC 9110 §4.2.4 [InlineData("https://admin@host/dvls")] + // oracle: spec RFC 9110 §4.2.4 [InlineData("https://@host/dvls")] public void CreateRejectsAnotherAgentChannelUrl(string agentChannelUrl) { ArgumentException exception = Assert.Throws(() => IdentityConfig.Create(1, agentChannelUrl)); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("agentChannelUrl", exception.ParamName); } - // CONTRACT.agent-identity.md §7.2: the agent prepends the URL path to - // `/devolutions.agent.channel.v1.AgentChannel/Connect`, + // The agent prepends the URL path to `/devolutions.agent.channel.v1.AgentChannel/Connect`, // so a URL ending in a slash would put two slashes in the gRPC path. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.2 [Theory] [InlineData("https://host/dvls", "https://host/dvls")] [InlineData("https://host/dvls/", "https://host/dvls")] @@ -91,8 +110,8 @@ public void AgentChannelUrlEndsWithoutASlash(string agentChannelUrl, string expe Assert.Equal(expected, config.AgentChannelUrl); } - // CONTRACT.agent-identity.md §5.2: product-specific config fields, such as a PSU channel URL, - // may coexist with the others. + // Product-specific config fields, such as a PSU channel URL, may coexist with the others. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Fact] public void JsonHasTheProductFieldsBesideTheContractMembers() { @@ -112,9 +131,9 @@ public void JsonHasTheProductFieldsBesideTheContractMembers() Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), members["policy_limits"])); } - // CONTRACT.agent-identity.md §1: JSON keys are snake_case, - // lowercase words of letters and digits joined by single underscores. + // JSON keys are snake_case, lowercase words of letters and digits joined by single underscores. // zz09 starts and goes on with the last letter, and holds the first and last digits. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData("a")] [InlineData("psu_channel_url")] @@ -129,16 +148,27 @@ public void CreateAcceptsASnakeCaseProductFieldName(string name) } [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("Channel")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("psuChannel")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("psu-channel")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("psu channel")] + // oracle: scaffold [InlineData("_psu")] + // oracle: scaffold [InlineData("psu_")] + // oracle: scaffold [InlineData("psu__channel")] + // oracle: scaffold [InlineData("2fa")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("psu\n")] + // oracle: scaffold [InlineData("caf\u00E9")] public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) { @@ -146,6 +176,8 @@ public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) } // CONTRACT.agent-identity.md §5.2 defines these members, so a product field of one of these names would repeat it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: spec RFC 8259 §4 [Theory] [InlineData("version")] [InlineData("revision")] @@ -155,15 +187,17 @@ public void CreateRejectsAProductFieldNamedAfterAContractMember(string name) AssertRejectsProductFields([new(name, TestJson.Parse("2"))]); } - // RFC 8259 §4: an object member is a name and a value. + // An object member is a name and a value. // The default JsonElement holds no JSON value at all. + // oracle: spec RFC 8259 §4 [Fact] public void CreateRejectsAProductFieldWithoutAValue() { AssertRejectsProductFields([new("psu_channel_url", default)]); } - // RFC 8259 §4: a member name is a string, which a null name is not. + // A member name is a string, which a null name is not. + // oracle: spec RFC 8259 §4 [Fact] public void CreateRejectsAProductFieldWithoutAName() { @@ -173,15 +207,18 @@ public void CreateRejectsAProductFieldWithoutAName() // RFC 8259 §8.1: JSON text exchanged between systems is UTF-8, which cannot encode an unpaired surrogate. // Failing here keeps every later serialization of the config from failing. [Theory] + // oracle: scaffold [InlineData("""["ok", "\ud800"]""")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("""{"\ud800": 1}""")] public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value) { AssertRejectsProductFields([new("label", TestJson.Parse(value))]); } - // CONTRACT.agent-identity.md §1: JSON keys are snake_case, so the members inside a product field's value are too, + // JSON keys are snake_case, so the members inside a product field's value are too, // whether an object or an array holds them. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData("""{"maxSessions": 4}""")] [InlineData("""{"limits": {"max_sessions": 4, "Max_paths": 2}}""")] @@ -203,12 +240,14 @@ public void CreateRejectsAMemberNameThatIsNotSnakeCaseInAValue(string value) [new("policy_limits", TestJson.Parse("""{"max_sessions": 4, "max_sessions": 5}"""))], ["member of an object in an array"] = [new("policy_limits", TestJson.Parse("""{"limits": [{"max_sessions": 4, "max_sessions": 5}]}"""))], + // oracle: spec RFC 8259 §7 ["member of a value with an escaped name"] = [new("policy_limits", TestJson.Parse("""{"max_sessions": 4, "max\u005Fsessions": 5}"""))], }; public static TheoryData RepeatedMemberNameForms => new(RepeatedMemberNames.Keys); + // oracle: spec RFC 8259 §4 [Theory] [MemberData(nameof(RepeatedMemberNameForms))] public void CreateRejectsARepeatedMemberName(string form) @@ -216,7 +255,8 @@ public void CreateRejectsARepeatedMemberName(string form) AssertRejectsProductFields(RepeatedMemberNames[form]); } - // CONTRACT.agent-identity.md §1: an absent field is omitted, never written as null, at any depth. + // An absent field is omitted, never written as null, at any depth. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData("psu_channel_url", "null")] [InlineData("policy_limits", """{"proxy": null}""")] @@ -228,6 +268,10 @@ public void CreateRejectsANullFieldOrMember(string name, string value) // RFC 8259 §4 asks for unique names within an object, and §5 makes array elements values rather than members. // A name repeats only within one object, and an array element is no field, so it may be null. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: spec RFC 8259 §4 + // oracle: spec RFC 8259 §5 [Fact] public void CreateAcceptsAValueWhoseMembersFollowTheConventions() { @@ -239,7 +283,9 @@ public void CreateAcceptsAValueWhoseMembersFollowTheConventions() Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), written)); } - // A JsonElement reads from its JsonDocument, which the host may dispose once the config exists. + // A JsonElement reads from its JsonDocument, which the host may dispose once the config exists, + // so the config keeps a copy of each product field rather than the argument it validated. + // oracle: spec Framework Design Guidelines §Validating Arguments [Fact] public void ConfigOutlivesTheDocumentOfItsProductFields() { @@ -256,6 +302,7 @@ public void ConfigOutlivesTheDocumentOfItsProductFields() } // A config is immutable, and the response bodies and ConfigUpdate messages write its JSON as it was created. + // oracle: scaffold [Fact] public void ChangingTheReturnedJsonLeavesTheConfigUnchanged() { @@ -273,6 +320,7 @@ private static void AssertRejectsProductFields(KeyValuePair ArgumentException exception = Assert.Throws( () => IdentityConfig.Create(1, "https://host/dvls", productFields)); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("productFields", exception.ParamName); } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index a1abadde25..d935d1d5fe 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -31,6 +31,10 @@ public abstract class RequestBodyTests protected abstract RequestBodyOutcome Parse(byte[] utf8Json); // CONTRACT.agent-identity.md §5.2, §5.3 and §5.6 define the bodies, and §3 their metadata. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 [Fact] public void AcceptsValidBody() { @@ -39,21 +43,26 @@ public void AcceptsValidBody() Assert.Equal([new KeyValuePair("hostname", "host")], metadata); } - // CONTRACT.agent-identity.md §1: receivers ignore unknown JSON fields. + // Receivers ignore unknown JSON fields. // A name repeated in different objects is not a repeated member. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData(""" "extra": 1""")] [InlineData(""" "extra": {"nested": [1, 2.5e3, true, false, null, {"a": "b"}]}""")] [InlineData(""" "version": 2, "extra": "h\u00e9 \ud83d\ude00" """)] [InlineData(""" "extra": null""")] + // oracle: spec RFC 8259 §4 [InlineData(""" "a": {"b": 1}, "c": {"b": 1}""")] + // oracle: spec RFC 8259 §4 [InlineData(""" "extra": [{"b": 1}, {"b": 1}]""")] public void AcceptsUnknownMembers(string extraMembers) { AssertValid(ParseText(Body(ValidMetadata, extraMembers))); } - // RFC 8259 §7: an escape stands for its character; AgentMetadata keeps the order in which the entries were sent. + // An escape stands for its character; AgentMetadata keeps the order in which the entries were sent. + // oracle: spec RFC 8259 §7 + // oracle: scaffold [Fact] public void ReturnsTheMetadataUnescapedInTheOrderSent() { @@ -63,7 +72,8 @@ public void ReturnsTheMetadataUnescapedInTheOrderSent() Assert.Equal(expected, AssertValid(outcome).Metadata!); } - // CONTRACT.agent-identity.md §3: metadata may be an empty object. + // Metadata may be an empty object. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public void AcceptsEmptyMetadata() { @@ -71,6 +81,8 @@ public void AcceptsEmptyMetadata() } // CONTRACT.agent-identity.md §3: metadata is required, and §1 reads a null member as an absent one. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData(null)] [InlineData("null")] @@ -79,7 +91,8 @@ public void RejectsMissingMetadata(string? metadata) AssertInvalid(ParseText(Body(metadata, ""))); } - // CONTRACT.agent-identity.md §3: metadata is a JSON object of string to string. + // Metadata is a JSON object of string to string. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData(""" "host" """)] [InlineData("[]")] @@ -91,6 +104,7 @@ public void RejectsMetadataThatIsNotAnObject(string metadata) AssertInvalid(ParseText(Body(metadata, ""))); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [InlineData("""{"hostname": 1}""")] [InlineData("""{"hostname": true}""")] @@ -114,6 +128,7 @@ public void RejectsMetadataValueThatIsNotAString(string metadata) { "value of 1025 UTF-8 bytes", $$"""{"hostname": "{{string.Concat(Enumerable.Repeat("\u00E9", 512))}}a"}""" }, }; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Theory] [MemberData(nameof(MetadataBeyondTheLimits))] public void RejectsMetadataBeyondTheLimits(string form, string metadata) @@ -121,10 +136,12 @@ public void RejectsMetadataBeyondTheLimits(string form, string metadata) AssertInvalid(ParseText(Body(metadata, "")), form); } - // CONTRACT.agent-identity.md §1: a body that repeats a member name at any depth, such as one metadata key twice, + // A body that repeats a member name at any depth, such as one metadata key twice, // is invalid, whether the names are written alike or only unescape alike. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData("metadata", ValidMetadata, """ "metadata": {}""")] + // oracle: spec RFC 8259 §7 [InlineData("metadata with an escaped letter", ValidMetadata, """ "m\u0065tadata": {}""")] [InlineData("metadata key", """{"hostname": "a", "hostname": "a"}""", "")] [InlineData("unknown member", ValidMetadata, """ "extra": 1, "extra": 2""")] @@ -137,11 +154,20 @@ public void RejectsRepeatedMemberName(string form, string metadata, string extra // RFC 8259 §2: a JSON text is one value; RFC 8259 §8.1 lets a parser reject a byte order mark. [Theory] + // oracle: spec RFC 8259 §2 [InlineData("trailing content", "", "x")] + // oracle: spec RFC 8259 §2 [InlineData("second value", "", " {}")] + // oracle: spec RFC 8259 §2 [InlineData("line comment after the object", "", " // comment")] + // oracle: spec RFC 8259 §2 [InlineData("block comment before the object", "/* comment */", "")] + // oracle: spec RFC 8259 §2 + // oracle: spec RFC 8259 §8.1 [InlineData("byte order mark", "\uFEFF", "")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 [InlineData("object inside an array", "[", "]")] public void RejectsTextAroundTheObject(string form, string prefix, string suffix) { @@ -150,21 +176,32 @@ public void RejectsTextAroundTheObject(string form, string prefix, string suffix // RFC 8259 grammar, which has no comments, trailing commas, single quotes or NaN. [Theory] + // oracle: spec RFC 8259 §4 [InlineData("trailing comma in the object", """ "extra": 1,""")] + // oracle: spec RFC 8259 §5 [InlineData("trailing comma in an array", """ "extra": [1,]""")] + // oracle: spec RFC 8259 §2 [InlineData("comment", """ /* comment */ "extra": 1""")] + // oracle: spec RFC 8259 §7 [InlineData("single-quoted name", """ 'extra': 1""")] + // oracle: spec RFC 8259 §4 [InlineData("unquoted name", """ extra: 1""")] + // oracle: spec RFC 8259 §6 [InlineData("NaN", """ "extra": NaN""")] + // oracle: spec RFC 8259 §6 [InlineData("number with a leading zero", """ "extra": 01""")] + // oracle: spec RFC 8259 §7 [InlineData("unescaped tab in a string", " \"extra\": \"a\tb\"")] + // oracle: spec RFC 8259 §4 [InlineData("missing value", """ "extra":""")] + // oracle: spec RFC 8259 §4 [InlineData("missing colon", """ "extra" 1""")] public void RejectsMalformedMember(string form, string extraMembers) { AssertInvalid(ParseText(Body(ValidMetadata, extraMembers)), form); } + // oracle: spec RFC 8259 §4 [Fact] public void RejectsTruncatedBody() { @@ -173,6 +210,9 @@ public void RejectsTruncatedBody() // CONTRACT.agent-identity.md §5.2, §5.3 and §5.6: each body is a JSON object; // RFC 8259 §2: a JSON text holds one value. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 [Theory] [InlineData("[]")] [InlineData(""" "body" """)] @@ -186,8 +226,10 @@ public void RejectsBodyThatIsNotAnObject(string json) AssertInvalid(ParseText(json)); } - // RFC 8259 §8.2: an escaped unpaired surrogate is valid JSON syntax but no Unicode text, - // so parsers disagree on what it reads as. + // A string that is not valid Unicode, such as an escaped lone surrogate, in any member, including an unknown one, + // is invalid, since parsers disagree on what it reads as. + // A member name is a string too. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData("lone high surrogate", """ "extra": "\ud800" """)] [InlineData("lone low surrogate", """ "extra": "\udc00" """)] @@ -200,6 +242,8 @@ public void RejectsEscapedStringThatIsNotUnicode(string form, string extraMember // CONTRACT.agent-identity.md §1 and RFC 8259 §8.1: JSON bodies are UTF-8. // The bytes replace the character of a string in an unknown member. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: spec RFC 3629 §4 [Theory] [InlineData(new byte[] { 0xFF })] [InlineData(new byte[] { 0xC0, 0xAF })] @@ -213,14 +257,16 @@ public void RejectsStringThatIsNotUtf8(byte[] bytes) AssertInvalid(Parse([.. body[..index], .. bytes, .. body[(index + 1)..]])); } - // RFC 8259 §9 lets a parser limit nesting, and the Parse methods document a limit of 64 levels. - // The body object is the first level, so 63 nested arrays reach 64 levels. + // A body with more than 64 nested objects and arrays, counting the top-level object, is invalid. + // The body object is the first, so 63 nested arrays make 64 and 64 make 65. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void AcceptsNestingOf64Levels() { AssertValid(ParseText(Body(ValidMetadata, NestedArrays(63)))); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void RejectsNestingOf65Levels() { @@ -245,8 +291,10 @@ protected RequestBodyOutcome ParseText(string json) protected static RequestBodyOutcome AssertValid(RequestBodyOutcome outcome) { Assert.True(outcome.Succeeded, outcome.FailureDetail); + // oracle: scaffold Assert.Null(outcome.Error); Assert.Null(outcome.FailureDetail); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 Assert.NotNull(outcome.Metadata); return outcome; @@ -256,10 +304,16 @@ protected static RequestBodyOutcome AssertValid(RequestBodyOutcome outcome) protected static void AssertInvalid(RequestBodyOutcome outcome, string? form = null) { Assert.False(outcome.Succeeded, form); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors Assert.NotNull(outcome.Error); Assert.Equal(AgentIdentityErrorCode.InvalidRequest, outcome.Error.Code); Assert.Equal(400, outcome.Error.HttpStatusCode); + // oracle: mutant an empty failure detail for a missing or non-string csr, a CSR that is not DER PKCS #10 + // or fails its self-signature, missing or non-object metadata, a non-string metadata value, + // more than 32 metadata keys, a key outside the pattern, or a value with a control character + // or over 1024 UTF-8 bytes Assert.False(string.IsNullOrEmpty(outcome.FailureDetail)); + // oracle: scaffold Assert.Null(outcome.Metadata); Assert.Null(outcome.Csr); } @@ -283,6 +337,8 @@ protected sealed override string Body(string? metadata, string extraMembers) } // test-vectors.json csr.valid gives the public key, which RFC 5480 §2 places in this SubjectPublicKeyInfo. + // oracle: reference docs/agent-identity/test-vectors.json csr.valid + // oracle: spec RFC 5480 §2 [Fact] public void ReturnsTheValidatedCsr() { @@ -293,10 +349,14 @@ public void ReturnsTheValidatedCsr() CertificateSigningRequest csr = AssertValid(ParseText(Body(ValidMetadata, ""))).Csr!; Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray()); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 Assert.Equal(SHA256.HashData(expected), csr.SubjectPublicKeyInfoSha256.ToArray()); } // CONTRACT.agent-identity.md §5.2 and §5.3: csr is required, and §1 reads a null member as an absent one. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 [Theory] [InlineData(null)] [InlineData("null")] @@ -305,7 +365,8 @@ public void RejectsMissingCsr(string? csr) AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata))); } - // CONTRACT.agent-identity.md §1: a CSR is DER encoded base64 in a JSON string. + // A CSR is DER encoded base64 in a JSON string. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData("1")] [InlineData("{}")] @@ -316,11 +377,13 @@ public void RejectsCsrThatIsNotAString(string csr) AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata))); } - // CONTRACT.agent-identity.md §1: base64 in any form but the canonical one is invalid. + // Base64 in any form but the canonical one is invalid. // Each test alters the canonical base64 of a request that the body accepts, // into a form that a lenient decoder would still read as that request. // The requests are built here rather than read from the vector file, // so that each form applies to them whatever request the file holds. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData(1)] [InlineData(2)] @@ -331,8 +394,10 @@ public void RejectsCsrWithoutPadding(int octetsInFinalQuantum) AssertOnlyCanonicalCsrIsValid(canonical, canonical.TrimEnd('=')); } - // RFC 4648 §3.5: the pad bits of the final quantum, four after one octet and two after two octets, are zero. + // The pad bits of the final quantum, four after one octet and two after two octets, are zero. // The character before the padding holds them, so setting its lowest bit sets one pad bit. + // oracle: spec RFC 4648 §3.5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData(1)] [InlineData(2)] @@ -347,6 +412,8 @@ public void RejectsCsrWithNonZeroPadBit(int octetsInFinalQuantum) } // A lenient decoder skips white space, such as the line breaks that RFC 7468 places every 64 characters. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: spec RFC 4648 §3.3 [Theory] [InlineData("\n")] [InlineData(" ")] @@ -358,6 +425,8 @@ public void RejectsCsrWithWhiteSpace(string whiteSpace) } // RFC 4648 §5: the base64url alphabet writes - and _ for the + and / of base64. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void RejectsCsrInTheBase64UrlAlphabet() { @@ -367,6 +436,7 @@ public void RejectsCsrInTheBase64UrlAlphabet() } // test-vectors.json csr.bad_self_signature expects invalid_request. + // oracle: reference docs/agent-identity/test-vectors.json csr.bad_self_signature [Fact] public void RejectsCsrThatFailsValidation() { @@ -375,7 +445,8 @@ public void RejectsCsrThatFailsValidation() AssertInvalid(ParseText(BodyWithCsr(Quote(csr), ValidMetadata))); } - // RFC 2986 §4: a CSR is a DER SEQUENCE, which no empty byte string encodes. + // A CSR is a DER SEQUENCE, which no empty byte string encodes. + // oracle: spec RFC 2986 §4 [Fact] public void RejectsEmptyCsr() { @@ -383,6 +454,7 @@ public void RejectsEmptyCsr() } // CONTRACT.agent-identity.md §1 makes csr sent twice invalid_request. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void RejectsRepeatedCsr() { @@ -418,7 +490,9 @@ private static string CanonicalCsr(int octetsInFinalQuantum) private void AssertOnlyCanonicalCsrIsValid(string canonical, string altered) { + // oracle: scaffold Assert.NotEqual(canonical, altered); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 AssertValid(ParseText(BodyWithCsr(Quote(canonical), ValidMetadata))); AssertInvalid(ParseText(BodyWithCsr(Quote(altered), ValidMetadata))); @@ -463,7 +537,9 @@ protected override RequestBodyOutcome Parse(byte[] utf8Json) public class CheckInRequestBodyTests : RequestBodyTests { - // CONTRACT.agent-identity.md §5.6: a check-in body has no csr, so a csr member is an unknown member. + // A check-in body has no csr, so a csr member is an unknown member. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void IgnoresCsrMember() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs index c36c9f370e..db75df1c5a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs @@ -51,8 +51,8 @@ public class ResponseBodiesTests public static TheoryData ConfigBodyNames => new(ConfigBodies.Keys); - // CONTRACT.agent-identity.md §5.1: each root has `certificate`, `thumbprint`, `not_before` and `not_after`, - // all strings. + // Each root has `certificate`, `thumbprint`, `not_before` and `not_after`, all strings. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 [Fact] public void TrustAnchorRootHasExactlyTheContractMembers() { @@ -67,6 +67,8 @@ public void TrustAnchorRootHasExactlyTheContractMembers() // CONTRACT.agent-identity.md §1: a certificate is its DER in canonical base64, // and its thumbprint is base64url(SHA-256(DER)), which the vector file gives for its root. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: reference docs/agent-identity/test-vectors.json root [Fact] public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() { @@ -79,6 +81,8 @@ public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`. // The validity is given here in offsets other than UTC, and the test machine's own time zone may be another. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: spec RFC 3339 §5.6 [Fact] public void TrustAnchorGivesTheValidityOfTheRootInUtc() { @@ -93,7 +97,9 @@ public void TrustAnchorGivesTheValidityOfTheRootInUtc() Assert.Equal("2036-07-08T13:10:11Z", entry["not_after"].GetString()); } - // CONTRACT.agent-identity.md §5.1: the response lists one root normally and two during a rotation. + // The response lists one root normally and two during a rotation. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 + // oracle: scaffold [Fact] public void TrustAnchorListsEveryRootInTheOrderGiven() { @@ -107,28 +113,34 @@ public void TrustAnchorListsEveryRootInTheOrderGiven() roots.Select(root => TestJson.CanonicalBase64(TestJson.Members(root)["certificate"]))); } - // CONTRACT.agent-identity.md §5.1: the response always lists a root, and two during a rotation. + // The response always lists a root, and two during a rotation. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 [Fact] public void TrustAnchorRejectsNoRoots() { ArgumentException exception = Assert.Throws(() => ResponseBodies.TrustAnchor([])); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("roots", exception.ParamName); } - // CONTRACT.agent-identity.md §5.1: each root has a certificate, which a null root lacks. + // Each root has a certificate, which a null root lacks. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 [Fact] public void TrustAnchorRejectsANullRoot() { ArgumentException exception = Assert.Throws(() => ResponseBodies.TrustAnchor([VectorRoot, null!])); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("roots", exception.ParamName); } // CONTRACT.agent-identity.md §5.2: `authority_id`, `identity_id`, `certificate_chain` and `config`, // every one of them required. // §1: IDs are lowercase hyphenated UUIDs. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Fact] public void EnrollHasExactlyTheContractMembers() { @@ -146,6 +158,8 @@ public void EnrollHasExactlyTheContractMembers() // RFC 9562 §5.9: the nil UUID, with all 128 bits zero, stands for the absence of a UUID, // and CONTRACT.agent-identity.md §5.2 requires both IDs. + // oracle: spec RFC 9562 §5.9 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Theory] [InlineData(NilIdText, IdentityIdText, "authorityId")] [InlineData(AuthorityIdText, NilIdText, "identityId")] @@ -154,10 +168,12 @@ public void EnrollRejectsANilId(string authorityId, string identityId, string pa ArgumentException exception = Assert.Throws( () => ResponseBodies.Enroll(Guid.Parse(authorityId), Guid.Parse(identityId), [DeviceA, VectorRoot], Config)); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal(parameter, exception.ParamName); } - // CONTRACT.agent-identity.md §5.3: `200`: `{ "certificate_chain": [ ... ] }`. + // `200`: `{ "certificate_chain": [ ... ] }`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 [Fact] public void RenewHasExactlyTheContractMembers() { @@ -168,8 +184,8 @@ public void RenewHasExactlyTheContractMembers() Assert.Equal(JsonValueKind.Array, members["certificate_chain"].ValueKind); } - // CONTRACT.agent-identity.md §5.6: `config` and `renewal_requested`, - // the identity's request-renewal flag as a JSON boolean. + // `config` and `renewal_requested`, the identity's request-renewal flag as a JSON boolean. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 [Theory] [InlineData(true, JsonValueKind.True)] [InlineData(false, JsonValueKind.False)] @@ -184,6 +200,8 @@ public void CheckInHasExactlyTheContractMembers(bool renewalRequested, JsonValue } // CONTRACT.agent-identity.md §4: chains are leaf-first; §1: each certificate is its DER in canonical base64. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainIsWrittenLeafFirst(string endpoint) @@ -196,7 +214,9 @@ public void ChainIsWrittenLeafFirst(string endpoint) chain.Select(TestJson.CanonicalBase64)); } - // CONTRACT.agent-identity.md §4: a chain has any length, so the leaf alone is a chain too. + // A chain has any length, so the leaf alone is a chain too. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainOfTheLeafAloneIsWritten(string endpoint) @@ -207,8 +227,10 @@ public void ChainOfTheLeafAloneIsWritten(string endpoint) Assert.Equal([TestVectors.Instance.Key("device-a").Certificate], chain.Select(TestJson.CanonicalBase64)); } - // RFC 5280 §4.1.2.6: a CA's subject is encoded exactly as the issuer of each certificate it issues, + // A CA's subject is encoded exactly as the issuer of each certificate it issues, // so in a leaf-first chain the issuer of each certificate is the subject of the next. + // oracle: spec RFC 5280 §4.1.2.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithTheRootFirstIsRejected(string endpoint) @@ -216,10 +238,13 @@ public void ChainWithTheRootFirstIsRejected(string endpoint) ArgumentException exception = Assert.Throws(() => ChainBodies[endpoint]([VectorRoot, DeviceA])); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("certificateChain", exception.ParamName); } // The other root has another subject than the vector root, which issued the leaf. + // oracle: spec RFC 5280 §4.1.2.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithACertificateThatDidNotIssueThePreviousOneIsRejected(string endpoint) @@ -229,20 +254,26 @@ public void ChainWithACertificateThatDidNotIssueThePreviousOneIsRejected(string ArgumentException exception = Assert.Throws(() => ChainBodies[endpoint]([DeviceA, otherRoot])); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("certificateChain", exception.ParamName); } // CONTRACT.agent-identity.md §4 and §5.2: a chain starts with the leaf, so it holds at least one certificate. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 [Theory] [MemberData(nameof(ChainBodyNames))] public void EmptyChainIsRejected(string endpoint) { ArgumentException exception = Assert.Throws(() => ChainBodies[endpoint]([])); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("certificateChain", exception.ParamName); } - // CONTRACT.agent-identity.md §1: each certificate of a chain is written as its DER, which a null certificate lacks. + // Each certificate of a chain is written as its DER, which a null certificate lacks. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithANullCertificateIsRejected(string endpoint) @@ -250,10 +281,13 @@ public void ChainWithANullCertificateIsRejected(string endpoint) ArgumentException exception = Assert.Throws(() => ChainBodies[endpoint]([DeviceA, null!])); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("certificateChain", exception.ParamName); } // CONTRACT.agent-identity.md §5.2 and §5.6: `config` is the identity's config, in the shape of §5.2. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 [Theory] [MemberData(nameof(ConfigBodyNames))] public void BodyCarriesTheConfig(string endpoint) @@ -271,6 +305,8 @@ public void BodyCarriesTheConfig(string endpoint) // CONTRACT.agent-identity.md §5.2 and §5.6: `config` is the identity's config, // and IdentityConfig.Create sets no depth limit. // The value nests 64 levels, so the body nests 66, beyond the 64 that a System.Text.Json reader allows by default. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 [Theory] [MemberData(nameof(ConfigBodyNames))] public void BodyCarriesADeeplyNestedConfig(string endpoint) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index cbebf7f56f..ed213aec91 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -36,13 +36,15 @@ public class CertificateSigningRequestTests return request.CreateSigningRequest(); }, - // CONTRACT.agent-identity.md §4: the agent's CSR carries an empty attribute set, A0 00. + // The agent's CSR carries an empty attribute set, A0 00. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 ["empty attribute set"] = key => TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: [0xA0, 0x00]), key), // CONTRACT.agent-identity.md §4: the server ignores an extensionRequest with an empty Extensions list, // which common CSR builders emit although RFC 5280 §4.1 gives Extensions SIZE (1..MAX). // The attribute is the PKCS #9 extensionRequest (RFC 2985 §5.4.2) with one value, an empty SEQUENCE. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 ["extensionRequest with empty Extensions"] = key => { byte[] extensionRequest = TestCsr.Sequence( @@ -57,6 +59,7 @@ public class CertificateSigningRequestTests // CONTRACT.agent-identity.md §4: attributes need no "canonical DER form, // such as a sorted `SET OF`" (X.690 §11.6). // Their encodings first differ in the last arc of the OIDs, 04 then 03, so the set descends. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 ["attributes out of DER order"] = key => { static byte[] Attribute(string oid) => @@ -88,6 +91,7 @@ static byte[] Attribute(string oid) => // and RFC 5480 §2 those of SubjectPublicKeyInfo and of its AlgorithmIdentifier. private static readonly Dictionary> RequestsWithAnExtraElement = new() { + // oracle: spec RFC 2986 §4.1 ["CertificationRequestInfo"] = key => { byte[] info = TestCsr.Sequence( @@ -98,6 +102,7 @@ static byte[] Attribute(string oid) => TestCsr.Null); return TestCsr.Request(info, key); }, + // oracle: spec RFC 2986 §4.2 ["CertificationRequest"] = key => { byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); @@ -109,6 +114,7 @@ static byte[] Attribute(string oid) => }, // Another element would give the key a second SubjectPublicKeyInfo, and so a second SHA-256. + // oracle: spec RFC 5480 §2 ["AlgorithmIdentifier of the key"] = key => { byte[] algorithm = TestCsr.Sequence( @@ -118,6 +124,7 @@ static byte[] Attribute(string oid) => byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); return TestCsr.Request(TestCsr.RequestInfo(spki), key); }, + // oracle: spec RFC 5480 §2 ["SubjectPublicKeyInfo"] = key => { byte[] spki = TestCsr.Sequence( @@ -130,7 +137,8 @@ static byte[] Attribute(string oid) => private static readonly Dictionary> RequestsForAKeyNotNamedP256 = new() { - // CONTRACT.agent-identity.md §4: the key is a P-256 key. + // The key is a P-256 key. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 ["P-384 key"] = () => { using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP384); @@ -138,6 +146,7 @@ static byte[] Attribute(string oid) => }, // secp256k1 (SEC 2 §2.4.1) points also take 65 octets uncompressed, so only the curve name tells them apart. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 ["P-256 point named secp256k1"] = () => { using ECDsa key = NewKey(); @@ -169,7 +178,8 @@ static byte[] Attribute(string oid) => // CONTRACT.agent-identity.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. private static readonly Dictionary> PointsNotUncompressed = new() { - // SEC 1 §2.3.3: the compressed form is 02 for an even y or 03 for an odd y, then x. + // The compressed form is 02 for an even y or 03 for an odd y, then x. + // oracle: spec SEC 1 §2.3.3 ["compressed"] = key => { ECParameters parameters = key.ExportParameters(includePrivateParameters: false); @@ -177,6 +187,7 @@ static byte[] Attribute(string oid) => }, // ANSI X9.62 also defines a hybrid form, 06 or 07 then both coordinates; RFC 5480 §2.2 rejects it. + // oracle: spec RFC 5480 §2.2 ["hybrid"] = key => { byte[] point = TestCsr.UncompressedPoint(key); @@ -184,18 +195,21 @@ static byte[] Attribute(string oid) => return point; }, - // SEC 1 §2.3.3: the uncompressed form is 04, then x and y in 32 octets each, 65 octets in all. + // The uncompressed form is 04, then x and y in 32 octets each, 65 octets in all. + // oracle: spec SEC 1 §2.3.3 ["without its form octet"] = key => TestCsr.UncompressedPoint(key)[1..], ["with an extra octet"] = key => [.. TestCsr.UncompressedPoint(key), 0x00], - // SEC 1 §2.3.3: the point at infinity is the single octet 00. + // The point at infinity is the single octet 00. + // oracle: spec SEC 1 §2.3.3 ["point at infinity"] = _ => [0x00], }; // X.690 §11.2.1: DER requires an unused bit to be zero, so each BIT STRING is one whose last octet is even. private static readonly Dictionary> RequestsWithUnusedBits = new() { - // RFC 5480 §2.2: the octets of the point become the bits of the BIT STRING, so none is unused. + // The octets of the point become the bits of the BIT STRING, so none is unused. + // oracle: spec RFC 5480 §2.2 ["subjectPublicKey"] = () => { using ECDsa key = NewKeyWhere(point => (point[^1] & 1) == 0); @@ -206,7 +220,9 @@ static byte[] Attribute(string oid) => return TestCsr.Request(TestCsr.RequestInfo(spki), key); }, - // RFC 3279 §2.2 and §2.2.3: the BIT STRING holds the octets of the DER Ecdsa-Sig-Value, so none is unused. + // The BIT STRING holds the octets of the DER Ecdsa-Sig-Value, so none is unused. + // oracle: spec RFC 3279 §2.2 + // oracle: spec RFC 3279 §2.2.3 ["signature"] = () => { using ECDsa key = NewKey(); @@ -251,6 +267,8 @@ static byte[] Attribute(string oid) => new(PointsWithACoordinateOutOfTheField.Keys); // test-vectors.json csr..expected, the outcome CONTRACT.agent-identity.md §4 gives each request. + // oracle: reference docs/agent-identity/test-vectors.json csr + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Theory] [MemberData(nameof(VectorData.CsrNames), MemberType = typeof(VectorData))] public void CsrVectorHasItsExpectedOutcome(string name) @@ -263,6 +281,8 @@ public void CsrVectorHasItsExpectedOutcome(string name) } // test-vectors.json csr.valid.public_key_sec1, after the RFC 5480 §2 prefix of a P-256 SubjectPublicKeyInfo. + // oracle: reference docs/agent-identity/test-vectors.json csr.valid + // oracle: spec RFC 5480 §2 [Fact] public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() { @@ -273,11 +293,13 @@ public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(vector.Csr)); Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray()); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 Assert.Equal(SHA256.HashData(expected), csr.SubjectPublicKeyInfoSha256.ToArray()); } // A request written by .NET rather than by the test builder. - // CONTRACT.agent-identity.md §4: a P-256 key and ecdsa-with-SHA256, the profile .NET writes for this key. + // A P-256 key and ecdsa-with-SHA256, the profile .NET writes for this key. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void AcceptsRequestWrittenByDotNet() { @@ -289,6 +311,7 @@ public void AcceptsRequestWrittenByDotNet() Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Theory] [MemberData(nameof(RequestWithIgnoredContentsNames))] public void AcceptsAnySubjectAndAttributes(string request) @@ -301,7 +324,8 @@ public void AcceptsAnySubjectAndAttributes(string request) Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } - // RFC 2986 §4: none of these is a CertificationRequest. + // None of these is a CertificationRequest. + // oracle: spec RFC 2986 §4 [Theory] [InlineData("")] [InlineData("00")] @@ -312,8 +336,9 @@ public void RejectsBytesThatAreNotARequest(string hex) AssertInvalid(CertificateSigningRequest.Validate(Convert.FromHexString(hex))); } - // CONTRACT.agent-identity.md §1: a CSR is DER, the encoding of one request. + // A CSR is DER, the encoding of one request. // Bytes after the request would give one request many encodings. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void RejectsTrailingBytes() { @@ -322,6 +347,7 @@ public void RejectsTrailingBytes() AssertInvalid(CertificateSigningRequest.Validate([.. valid, 0x00])); } + // oracle: spec X.690 §10.1 [Theory] [MemberData(nameof(RequestWithALongerLengthNames))] public void RejectsLengthInMoreOctetsThanNeeded(string element) @@ -329,7 +355,8 @@ public void RejectsLengthInMoreOctetsThanNeeded(string element) AssertInvalid(CertificateSigningRequest.Validate(RequestsWithALongerLength[element]())); } - // X.690 §10.1: DER uses the definite form of length. + // DER uses the definite form of length. + // oracle: spec X.690 §10.1 [Fact] public void RejectsIndefiniteLength() { @@ -338,7 +365,8 @@ public void RejectsIndefiniteLength() AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x80, .. contents, 0x00, 0x00])); } - // RFC 2986 §4.1: version is v1, encoded 0. + // version is v1, encoded 0. + // oracle: spec RFC 2986 §4.1 [Fact] public void RejectsVersion1() { @@ -352,7 +380,9 @@ public void RejectsVersion1() AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); } - // RFC 2986 §4.1: the subject is a Name, which is a SEQUENCE. + // The subject is a Name, which is a SEQUENCE. + // oracle: spec RFC 2986 §4.1 + // oracle: spec RFC 5280 §4.1.2.4 [Fact] public void RejectsSubjectThatIsNotASequence() { @@ -362,10 +392,14 @@ public void RejectsSubjectThatIsNotASequence() AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); } - // RFC 2986 §4.1: attributes is a required field, a constructed [0] IMPLICIT SET OF Attribute. + // attributes is a required field, a constructed [0] IMPLICIT SET OF Attribute. // The rows leave it out and write it primitive, 80 00. + // oracle: spec RFC 2986 §4.1 [Theory] [InlineData("")] + // The module of Appendix A uses IMPLICIT TAGS, and X.690 8.14.4 a) keeps an implicitly tagged SET OF constructed. + // oracle: spec RFC 2986 §A + // oracle: spec X.690 §8.14 [InlineData("8000")] public void RejectsRequestInfoWithoutConstructedAttributes(string attributes) { @@ -388,6 +422,7 @@ public void RejectsExtraElement(string sequence) AssertInvalid(CertificateSigningRequest.Validate(RequestsWithAnExtraElement[sequence](key))); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Theory] [MemberData(nameof(RequestForAKeyNotNamedP256Names))] public void RejectsKeyNotNamedP256(string key) @@ -396,6 +431,8 @@ public void RejectsKeyNotNamedP256(string key) } // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; CONTRACT.agent-identity.md §4 needs a signing key. + // oracle: spec RFC 5480 §2.1.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void RejectsKeyRestrictedToKeyAgreement() { @@ -406,6 +443,7 @@ public void RejectsKeyRestrictedToKeyAgreement() AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); } + // oracle: spec RFC 5480 §2.1.1 [Theory] [MemberData(nameof(KeyWithoutANamedCurveNames))] public void RejectsKeyWithoutANamedCurve(string parameters) @@ -416,6 +454,7 @@ public void RejectsKeyWithoutANamedCurve(string parameters) AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Theory] [MemberData(nameof(PointNotUncompressedNames))] public void RejectsPointNotUncompressed(string point) @@ -425,10 +464,11 @@ public void RejectsPointNotUncompressed(string point) AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, PointsNotUncompressed[point](key)))); } - // CONTRACT.agent-identity.md §4: the key is a point on P-256. + // The key is a point on P-256. // Flipping the low bit of y leaves the point off the curve, // since the only other point with the same x has y' = p - y, which is y + 1 or y - 1 only for y = (p ± 1) / 2. // No private key exists for such a point, so the key whose point was altered signs the request. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void RejectsPointOffTheCurve() { @@ -446,12 +486,14 @@ public void RejectsPointOffTheCurve() // a platform's key import throws an exception of a platform-specific type for such a point. // The private key of either point is unknown, and the key is invalid whatever the signature, // so r = s = 1 stands in for the signature. + // oracle: spec SEC 1 §3.2.2.1 [Theory] [MemberData(nameof(PointWithACoordinateOutOfTheFieldNames))] public void RejectsPointWithACoordinateOutOfTheField(string coordinate) { (string x, string y) = PointsWithACoordinateOutOfTheField[coordinate]; byte[] point = [0x04, .. Convert.FromHexString(x), .. Convert.FromHexString(y)]; + // oracle: spec SEC 2 §2.4.2 Assert.True(IsOnP256ModuloP(point)); byte[] der = TestCsr.Sequence( TestCsr.RequestInfo(TestCsr.SubjectPublicKeyInfo(TestCsr.P256Algorithm(), point)), @@ -468,8 +510,8 @@ public void RejectsBitStringWithUnusedBits(string bitString) AssertInvalid(CertificateSigningRequest.Validate(RequestsWithUnusedBits[bitString]())); } - // CONTRACT.agent-identity.md §4: the signature algorithm is ecdsa-with-SHA256, - // even when the signature verifies under it. + // The signature algorithm is ecdsa-with-SHA256, even when the signature verifies under it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void RejectsAnotherSignatureAlgorithmOverASha256Signature() { @@ -481,7 +523,8 @@ public void RejectsAnotherSignatureAlgorithmOverASha256Signature() AssertInvalid(CertificateSigningRequest.Validate(der)); } - // RFC 5758 §3.2: the AlgorithmIdentifier of ecdsa-with-SHA256 MUST omit the parameters. + // The AlgorithmIdentifier of ecdsa-with-SHA256 MUST omit the parameters. + // oracle: spec RFC 5758 §3.2 [Fact] public void RejectsSignatureAlgorithmWithNullParameters() { @@ -493,6 +536,7 @@ public void RejectsSignatureAlgorithmWithNullParameters() } // Security invariant 1: the self-signature is the proof of possession of the request's key. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void RejectsRequestSignedByAnotherKey() { @@ -503,7 +547,9 @@ public void RejectsRequestSignedByAnotherKey() AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, other))); } - // RFC 5758 §3.2: the signature is a DER ECDSA-Sig-Value, not the 64-octet r‖s form that request signatures use. + // The signature is a DER ECDSA-Sig-Value, not the 64-octet r‖s form that request signatures use. + // oracle: spec RFC 5758 §3.2 + // oracle: spec RFC 3279 §2.2.3 [Fact] public void RejectsSignatureInFixedFieldForm() { @@ -521,10 +567,12 @@ public void RejectsSignatureInFixedFieldForm() AssertInvalid(CertificateSigningRequest.Validate(der)); } - // RFC 3279 §2.2.3: r and s are positive INTEGERs. + // SEC 1 4.1.4 step 1: a verifier outputs "invalid" unless r and s are both in [1, n - 1], so both are positive. // An r whose top bit is set needs a leading zero octet; without it, DER reads a negative number, // which the BCL verifier accepts as the unsigned value, so only the library's own check rejects it. // An r that would start FF after the zero octet is skipped, since its negative form would not be minimal DER. + // oracle: spec SEC 1 §4.1.4 + // oracle: spec X.690 §8.3 [Fact] public void RejectsSignatureWithNegativeR() { @@ -532,6 +580,7 @@ public void RejectsSignatureWithNegativeR() (byte[] info, byte[] signature) = SignedInfoWhere(key, (r, _) => r.Length == 33 && r[1] != 0xFF); (byte[] r, byte[] s) = ReadSignature(signature); byte[] negative = TestCsr.Sequence(TestCsr.Tlv(0x02, r[1..]), TestCsr.Tlv(0x02, s)); + // oracle: reference .NET ECDsa.VerifyData Assert.True(key.VerifyData(info, negative, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); byte[] der = TestCsr.Sequence( info, @@ -545,6 +594,7 @@ private static CertificateSigningRequest AssertValid(CsrValidationResult result) { Assert.True(result.Succeeded, result.FailureDetail); Assert.Null(result.Error); + // oracle: scaffold Assert.Null(result.FailureDetail); return result.Csr; @@ -554,9 +604,14 @@ private static CertificateSigningRequest AssertValid(CsrValidationResult result) private static void AssertInvalid(CsrValidationResult result) { Assert.False(result.Succeeded); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors Assert.Equal(400, result.Error.HttpStatusCode); + // oracle: mutant an empty failure detail for a CSR that is followed by other bytes, + // that has a version other than 0, or whose key is not an id-ecPublicKey key Assert.False(string.IsNullOrEmpty(result.FailureDetail)); + // oracle: scaffold Assert.Null(result.Csr); } @@ -564,6 +619,7 @@ private static void AssertInvalid(CsrValidationResult result) private static byte[] AcceptedValidVector() { byte[] valid = TestVectors.Instance.Csr["valid"].Csr; + // oracle: reference docs/agent-identity/test-vectors.json csr.valid AssertValid(CertificateSigningRequest.Validate(valid)); return valid; @@ -579,6 +635,7 @@ private static byte[] ValidVectorContents() // Wrapped again with the fewest length octets, the contents give back the vector, // so a test that wraps them otherwise changes only the form of the length. + // oracle: spec X.690 §10.1 Assert.Equal(valid, TestCsr.Sequence(contents)); return contents; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 31734ebfc4..623dfb7f9c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -53,7 +53,8 @@ public void Dispose() _random.Dispose(); } - // CONTRACT.agent-identity.md §4: the certificate is for the P-256 key of the CSR. + // The certificate is for the P-256 key of the CSR. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void CertifiesTheKeyOfTheCsr() { @@ -62,7 +63,9 @@ public void CertifiesTheKeyOfTheCsr() Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo()); } - // CONTRACT.agent-identity.md §4: CN=, and the root's subject as the issuer. + // CN=, and the root's subject as the issuer. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void SubjectIsTheCommonNameOfTheIdentity() { @@ -71,12 +74,16 @@ public void SubjectIsTheCommonNameOfTheIdentity() X500RelativeDistinguishedName name = Assert.Single(leaf.SubjectName.EnumerateRelativeDistinguishedNames()); Assert.Equal("2.5.4.3", name.GetSingleElementType().Value); Assert.Equal(IdentityIdText, name.GetSingleElementValue()); + // oracle: spec RFC 5280 §4.1.2.4 Assert.Equal(_root.SubjectName.RawData, leaf.IssuerName.RawData); } // CONTRACT.agent-identity.md §4: SAN URI urn:uuid:. // RFC 5280 §4.2.1.6: GeneralNames is a SEQUENCE OF GeneralName, and a URI is [6] IA5String; // with a subject present, the extension is not critical. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: spec RFC 5280 §4.2.1.6 [Fact] public void AlternativeNameIsTheUrnUuidOfTheIdentity() { @@ -92,7 +99,8 @@ public void AlternativeNameIsTheUrnUuidOfTheIdentity() Assert.False(extension.Critical); } - // CONTRACT.agent-identity.md §4: EKU clientAuth. + // EKU clientAuth. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void ExtendedKeyUsageIsClientAuthenticationOnly() { @@ -101,10 +109,12 @@ public void ExtendedKeyUsageIsClientAuthenticationOnly() Assert.IsType(leaf.Extensions[ExtendedKeyUsageOid]); Assert.Equal([ClientAuth], extension.EnhancedKeyUsages.Cast().Select(oid => oid.Value)); + // oracle: scaffold Assert.False(extension.Critical); } // CONTRACT.agent-identity.md §4: KeyUsage digitalSignature; RFC 5280 §4.2.1.3: marked critical. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void KeyUsageIsCriticalDigitalSignatureOnly() { @@ -112,10 +122,13 @@ public void KeyUsageIsCriticalDigitalSignatureOnly() X509KeyUsageExtension extension = Assert.IsType(leaf.Extensions[KeyUsageOid]); Assert.Equal(X509KeyUsageFlags.DigitalSignature, extension.KeyUsages); + // oracle: spec RFC 5280 §4.2.1.3 Assert.True(extension.Critical); } - // RFC 5280 §4.2.1.1: the key identifier of the issuer's subject key identifier, in a non-critical extension. + // The key identifier of the issuer's subject key identifier, in a non-critical extension. + // oracle: spec RFC 5280 §4.2.1.1 + // oracle: spec RFC 5280 §4.2.1.2 [Fact] public void AuthorityKeyIdentifierIsTheSubjectKeyIdentifierOfTheIssuer() { @@ -126,12 +139,15 @@ public void AuthorityKeyIdentifierIsTheSubjectKeyIdentifierOfTheIssuer() Assert.IsType(_root.Extensions[SubjectKeyIdentifierOid]); Assert.Equal(issuer.SubjectKeyIdentifierBytes.ToArray(), authority.KeyIdentifier?.ToArray()); + // oracle: scaffold Assert.Null(authority.NamedIssuer); + // oracle: scaffold Assert.Null(authority.SerialNumber); Assert.False(authority.Critical); } - // RFC 5280 §4.2.1.2, method (1): the SHA-1 hash of the BIT STRING subjectPublicKey, in a non-critical extension. + // Method (1): the SHA-1 hash of the BIT STRING subjectPublicKey, in a non-critical extension. + // oracle: spec RFC 5280 §4.2.1.2 [Fact] public void SubjectKeyIdentifierIsTheSha1OfTheKey() { @@ -141,11 +157,14 @@ public void SubjectKeyIdentifierIsTheSha1OfTheKey() byte[] subjectPublicKey = TestCsr.UncompressedPoint(_agentKey); + // oracle: scaffold Assert.Equal(SHA1.HashData(subjectPublicKey), extension.SubjectKeyIdentifierBytes.ToArray()); Assert.False(extension.Critical); } - // RFC 5280 §4.2.1.9: an end-entity certificate may omit basic constraints, and this profile does. + // The leaf carries SAN, EKU and KeyUsage, and no other extension except an authority key identifier and a subject + // key identifier, so no basicConstraints. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void HasExactlyTheProfileExtensions() { @@ -162,10 +181,12 @@ public void HasExactlyTheProfileExtensions() ]; Assert.Equal(expected, extensions.Order(StringComparer.Ordinal)); + // oracle: spec RFC 5280 §4.1.2.1 Assert.Equal(3, leaf.Version); } // RFC 5758 §3.2: the issuer's generator signs with SHA-256, so ecdsa-with-SHA256 for a P-256 root. + // oracle: scaffold [Fact] public void IsSignedWithSha256() { @@ -174,8 +195,9 @@ public void IsSignedWithSha256() Assert.Equal("1.2.840.10045.4.3.2", leaf.SignatureAlgorithm.Value); } - // RFC 5280 §4.1.2.2: a positive INTEGER of at most 20 octets. + // A positive INTEGER of at most 20 octets. // The serial number is 16 octets from the injected generator with the top bit cleared. + // oracle: spec RFC 5280 §4.1.2.2 [Fact] public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() { @@ -184,10 +206,11 @@ public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(draw)); byte[] serialNumber = SerialNumber(leaf); - // oracle: self-derived; sole guard, with SerialNumberIsDrawnAgainWhenItWouldBeZero, - // for a serial number whose first octet keeps its top bit while its other bits are kept, set or flipped. + // Only this test and SerialNumberIsDrawnAgainWhenItWouldBeZero catch this fault. // RFC 5280 allows any positive encoding, and .NET writes a set top bit with a leading zero octet, // so only the library chooses to clear the top bit of the 16 octets drawn. + // oracle: mutant a serial number whose first octet keeps its top bit while its other bits are kept, + // set or flipped Assert.Equal([0x7F, .. draw[1..]], serialNumber); Assert.True(new BigInteger(serialNumber, isUnsigned: false, isBigEndian: true) > 0); Assert.InRange(serialNumber.Length, 1, 20); @@ -195,6 +218,9 @@ public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() // RFC 5280 §4.1.2.2: the serial number is positive, so never zero. // Clearing the top bit of 80 00 … 00 leaves zero, which is not a positive serial number. + // Only this test and SerialNumberIsTheRandomOctetsWithTheTopBitCleared catch this fault. + // oracle: mutant a serial number whose first octet keeps its top bit while its other bits are kept, + // set or flipped [Fact] public void SerialNumberIsDrawnAgainWhenItWouldBeZero() { @@ -204,8 +230,6 @@ public void SerialNumberIsDrawnAgainWhenItWouldBeZero() using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(topBitOnly, second)); - // oracle: self-derived; sole guard, with SerialNumberIsTheRandomOctetsWithTheTopBitCleared, - // for a serial number whose first octet keeps its top bit while its other bits are kept, set or flipped. // That 80 00 … 00 becomes zero, and that the next draw becomes the serial number as drawn, // follow from the library's own way of making the 16 octets positive. Assert.Equal(second, SerialNumber(leaf)); @@ -213,6 +237,7 @@ public void SerialNumberIsDrawnAgainWhenItWouldBeZero() // RFC 5280 §4.1.2.5.1 and §4.1.2.5.2: certificate times are whole seconds, // and a notBefore rounded up would issue a certificate that is not yet valid. + // oracle: scaffold [Fact] public void NotBeforeIsTheClockTruncatedToTheSecond() { @@ -224,6 +249,7 @@ public void NotBeforeIsTheClockTruncatedToTheSecond() // CONTRACT.agent-identity.md §4: the lifetime is a server setting. // RFC 5280 §4.1.2.5: a certificate holds whole seconds, so a fraction of a second in the lifetime is dropped, // since rounding it up would exceed the lifetime. + // oracle: scaffold [Theory] [InlineData(0)] [InlineData(500)] @@ -236,7 +262,8 @@ public void NotAfterIsNotBeforePlusTheWholeSecondsOfTheLifetime(int lifetimeMill Assert.Equal(NowInSeconds.AddDays(30).UtcDateTime, leaf.NotAfter.ToUniversalTime()); } - // CONTRACT.agent-identity.md §4: the lifetime is capped at the issuing root's notAfter. + // The lifetime is capped at the issuing root's notAfter. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void NotAfterIsCappedAtTheNotAfterOfTheIssuer() { @@ -247,26 +274,31 @@ public void NotAfterIsCappedAtTheNotAfterOfTheIssuer() Assert.Equal(NowInSeconds.AddDays(10).UtcDateTime, leaf.NotAfter.ToUniversalTime()); } - // CONTRACT.agent-identity.md §4: the lifetime is a server setting with a default of 90 days. + // The lifetime is a server setting with a default of 90 days. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void DefaultLifetimeIs90Days() { Assert.Equal(TimeSpan.FromDays(90), IdentityCertificateIssuer.DefaultLifetime); } - // CONTRACT.agent-identity.md §4: the lifetime is a server setting, + // The lifetime is a server setting, // and reading a moving clock twice would give the certificate a validity other than its lifetime. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void ReadsTheClockOnce() { using X509Certificate2 leaf = Issue(clock: new AdvancingTimeProvider(Now, TimeSpan.FromHours(1))); + // oracle: scaffold Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); Assert.Equal((NowInSeconds + Lifetime).UtcDateTime, leaf.NotAfter.ToUniversalTime()); } // The certificate chains to the root that issued it, for client authentication. // RFC 5280 §6 path validation, as X509Chain runs it, with the CONTRACT.agent-identity.md §4 EKU clientAuth. + // oracle: spec RFC 5280 §6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] public void ChainsToTheIssuer() { @@ -285,6 +317,8 @@ public void ChainsToTheIssuer() } // Security invariant 2: the authority mints the subject, and a CSR supplies only a verified public key. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void IgnoresTheSubjectAndExtensionsOfTheCsr() { @@ -297,14 +331,17 @@ public void IgnoresTheSubjectAndExtensionsOfTheCsr() using X509Certificate2 leaf = Issue(csr: csr); Assert.Equal($"CN={IdentityIdText}", leaf.Subject); + // The leaf carries no basicConstraints, so a leaf can never act as a CA. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 Assert.Null(leaf.Extensions[BasicConstraintsOid]); X509EnhancedKeyUsageExtension usage = Assert.IsType(leaf.Extensions[ExtendedKeyUsageOid]); Assert.Equal([ClientAuth], usage.EnhancedKeyUsages.Cast().Select(oid => oid.Value)); } - // RFC 5280 §4.1.2.5: a certificate holds whole seconds, - // so a lifetime under one second would end in the second it begins. + // A certificate holds whole seconds, so a lifetime under one second would end in the second it begins. + // oracle: spec RFC 5280 §4.1.2.5 + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [InlineData(0)] [InlineData(-1_000)] @@ -314,8 +351,9 @@ public void ThrowsForLifetimeShorterThanOneSecond(int milliseconds) Assert.Throws(() => Issue(lifetime: TimeSpan.FromMilliseconds(milliseconds))); } - // CONTRACT.agent-identity.md §4: notAfter is at most the issuer's notAfter, - // so an issuer that expires by notBefore leaves no validity. + // notAfter is at most the issuer's notAfter, so an issuer that expires by notBefore leaves no validity. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [InlineData(0)] [InlineData(-1)] @@ -326,7 +364,10 @@ public void ThrowsWhenTheIssuerExpiresByNotBefore(int seconds) Assert.Throws(() => Issue(root: root)); } - // RFC 5280 §4.2.1.1: the authority key identifier repeats the issuer's subject key identifier. + // A conforming CA certificate carries a subject key identifier, + // which the authority key identifier of each certificate it issues repeats. + // oracle: spec RFC 5280 §4.2.1.2 + // oracle: spec Framework Design Guidelines §Validating Arguments [Fact] public void ThrowsWhenTheIssuerHasNoSubjectKeyIdentifier() { @@ -335,11 +376,15 @@ public void ThrowsWhenTheIssuerHasNoSubjectKeyIdentifier() Assert.Throws(() => Issue(root: root)); } - // INTENT.md: code that acts for an identity takes the identity that the signature check produced. + // Code that acts for an identity takes the identity that the signature check produced. // The renew request is signed by device-a, whose identity ID the vector file gives. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public async Task RenewalCertifiesTheKeyOfTheCsrForTheIdentityThatSignedTheRenewRequest() { + // oracle: reference docs/agent-identity/test-vectors.json keys.device-a Assert.Equal(Guid.Parse(IdentityIdText), TestVectors.Instance.Key("device-a").IdentityId); AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); @@ -349,9 +394,12 @@ public async Task RenewalCertifiesTheKeyOfTheCsrForTheIdentityThatSignedTheRenew Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo()); } - // INTENT.md: code that acts for an identity takes the identity that the signature check produced, + // Code that acts for an identity takes the identity that the signature check produced, // and only a renew request proves the identity that a renewal acts for. // An identity that proved its key for another operation never had a renew request accepted for it. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [InlineData(nameof(IdentityCertificateIssuer), AgentOperation.Confirm)] [InlineData(nameof(IdentityCertificateIssuer), AgentOperation.CheckIn)] @@ -367,11 +415,14 @@ public async Task RenewalThrowsForAnIdentityAuthenticatedForAnotherOperation( ArgumentException exception = Assert.Throws(() => Renewals[renewal](identity)); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("renewing", exception.ParamName); } // INTENT.md: a renewal acts for the identity that a renew request proved, and null is no identity; // the .NET Framework Design Guidelines throw ArgumentNullException for a null argument that is not allowed. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [InlineData(nameof(IdentityCertificateIssuer))] [InlineData(nameof(RenewalRules))] @@ -379,6 +430,7 @@ public void RenewalThrowsForANullIdentity(string renewal) { ArgumentNullException exception = Assert.Throws(() => Renewals[renewal](null!)); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("renewing", exception.ParamName); } @@ -482,6 +534,7 @@ public override void GetBytes(byte[] data) public override void GetBytes(Span data) { byte[] draw = draws[_next++]; + // oracle: scaffold Assert.Equal(draw.Length, data.Length); draw.CopyTo(data); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs index 1b77d0c32e..d04eaad9bb 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs @@ -32,9 +32,10 @@ public class RenewalRulesTests public static TheoryData OtherIssuedKeyNames => new(OtherIssuedKeys.Keys); - // CONTRACT.agent-identity.md §5.3: if the CSR key is the key of the identity's pending certificate, + // If the CSR key is the key of the identity's pending certificate, // the server returns 200 with that pending chain, a retry after a lost response. // A renew request presents no enrollment token, so whatever the host says about one does not count. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 [Theory] [InlineData(false)] [InlineData(true)] @@ -51,9 +52,11 @@ public async Task ReturnsThePendingCertificateForThePendingKeyOfTheIdentity(bool Assert.Equal(RenewalOutcome.ReturnPendingCertificate, decision.Outcome); Assert.Null(decision.Error); + // oracle: scaffold Assert.Null(decision.FailureDetail); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 [Theory] [MemberData(nameof(OtherIssuedKeyNames))] public async Task RejectsAnyOtherIssuedKeyWithInvalidRequest(string key) @@ -64,12 +67,16 @@ public async Task RejectsAnyOtherIssuedKeyWithInvalidRequest(string key) Assert.Equal(RenewalOutcome.Reject, decision.Outcome); Assert.Equal(AgentIdentityErrorCode.InvalidRequest, decision.Error?.Code); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors Assert.Equal(400, decision.Error?.HttpStatusCode); + // oracle: mutant an empty failure detail for the CSR key of a deleted identity, of another identity, + // or of a current or retired certificate of the renewing identity Assert.False(string.IsNullOrEmpty(decision.FailureDetail)); } - // CONTRACT.agent-identity.md §5.3: if the CSR key was never issued, the server issues a new pending certificate + // If the CSR key was never issued, the server issues a new pending certificate // and retires the identity's previous pending certificate, if any. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 [Fact] public async Task IssuesAPendingCertificateForAKeyNeverIssued() { @@ -79,6 +86,7 @@ public async Task IssuesAPendingCertificateForAKeyNeverIssued() Assert.Equal(RenewalOutcome.IssuePendingCertificate, decision.Outcome); Assert.Null(decision.Error); + // oracle: scaffold Assert.Null(decision.FailureDetail); } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index a4aa2dfbbf..27a0aebec9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -262,6 +262,8 @@ public static TheoryData HandshakeEndsThenAnotherCall TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter; // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Theory] [MemberData(nameof(SignatureFailureNames))] public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string failure) @@ -273,6 +275,8 @@ public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string fail // CONTRACT.agent-identity.md §7.3 step 4: a Hello that proves the key but carries metadata violating §3 is // INVALID_ARGUMENT with error-code invalid_request. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public async Task HelloWithInvalidMetadataIsInvalidRequest() { @@ -295,6 +299,9 @@ public async Task HelloWithInvalidMetadataIsInvalidRequest() // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. // CONTRACT.agent-identity.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, // so an agent that has not proved the key never learns whether its metadata would pass. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Theory] [MemberData(nameof(PairsOfFailuresWithDifferentCodes))] public async Task EarlierFailureWins(string earlier, string later) @@ -308,8 +315,9 @@ public async Task EarlierFailureWins(string earlier, string later) // Two failures that both close the stream with signature_invalid differ only in their failure detail, // so the detail shows that the earlier one closed it. - // oracle: self-derived; CONTRACT.agent-identity.md §7.3 step 4 fixes no failure detail, + // CONTRACT.agent-identity.md §7.3 step 4 fixes no failure detail, // so the expected one is the handshake's own for the earlier failure alone. + // oracle: scaffold [Theory] [MemberData(nameof(PairsOfFailuresWithTheSameCode))] public async Task EarlierOfTwoSignatureFailuresWins(string earlier, string later) @@ -321,6 +329,7 @@ public async Task EarlierOfTwoSignatureFailuresWins(string earlier, string later ChannelHandshakeResult result = await HandshakeAsync(FailurePairs[(earlier, later)]); Assert.NotEqual(laterAlone.FailureDetail, earlierAlone.FailureDetail); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 AssertFailed(result, expectedCode, expectedStatus); Assert.Equal(earlierAlone.FailureDetail, result.FailureDetail); } @@ -329,6 +338,7 @@ public async Task EarlierOfTwoSignatureFailuresWins(string earlier, string later // the host cannot know yet. // CONTRACT.agent-identity.md §7.4: PERMISSION_DENIED // with the trailers `error-code: identity_revoked` and `server-time`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() { @@ -338,13 +348,15 @@ public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() AgentIdentityError error = AssertFailed(result, AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.Equal( [("error-code", "identity_revoked"), ("server-time", "2026-09-24T12:00:00Z")], error.ToRpcException(serverTime).Trailers.Select(entry => (entry.Key, entry.Value))); } - // CONTRACT.agent-identity.md §6 step 4: a certificate that is no longer registered, or whose identity was deleted, - // is identity_unknown. + // Step 4: a certificate that is no longer registered, or whose identity was deleted, is identity_unknown. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() { @@ -361,8 +373,12 @@ public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() // so the result reports the retirement. // Failing it would send a healthy agent the terminal identity_unknown. [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [InlineData(CertificateStatus.Pending, CertificateStatus.Pending)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.5 [InlineData(CertificateStatus.Pending, CertificateStatus.Current)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData(CertificateStatus.Current, CertificateStatus.Retired)] public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake( CertificateStatus opened, @@ -381,6 +397,7 @@ public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake( // A retired certificate passes the handshake, but the revocation of its identity still blocks it. // CONTRACT.agent-identity.md §7.4: revocation closes a stream still awaiting its Hello, with PERMISSION_DENIED and // identity_revoked. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] public async Task RevocationWinsOverARetiredCertificate() { @@ -393,6 +410,7 @@ public async Task RevocationWinsOverARetiredCertificate() // Whatever the outcome, the handshake reads the registry once, for the certificate that opened the stream. // CONTRACT.agent-identity.md §7.4 applies a deletion or a revocation since the opening to a stream awaiting its // Hello, so the handshake reads the registry again; that it reads it exactly once is the library's API. + // oracle: scaffold [Theory] [MemberData(nameof(ScenarioNames))] public async Task HandshakeReadsTheRegistryOnce(string scenario) @@ -409,6 +427,8 @@ public async Task HandshakeReadsTheRegistryOnce(string scenario) // so another answer is a host bug, which must fail as a server error rather than draw an outcome the agent acts on. // CONTRACT.agent-identity.md §5.4: an unexpected server failure is the transient `500 unavailable`, // whereas identity_unknown and identity_revoked are terminal; the exception type is the library's API. + // oracle: mutant a handshake that acts on a registry answer for another certificate, + // or for the opening certificate under another identity [Theory] [MemberData(nameof(WrongRegistryAnswersAndCalls))] public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string answer, string call) @@ -426,6 +446,8 @@ public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string // Only the library's API decides that the call, not the answer, ends the handshake, // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call // that the object's state does not allow. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] public async Task HandshakeEndsBeforeTheRegistryAnswers() { @@ -440,6 +462,7 @@ public async Task HandshakeEndsBeforeTheRegistryAnswers() ValueTask timingOut = opening.HelloTimedOutAsync(); answer.SetResult(); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types await Assert.ThrowsAsync(() => timingOut.AsTask()); Assert.True((await verifying).Succeeded); } @@ -451,6 +474,8 @@ public async Task HandshakeEndsBeforeTheRegistryAnswers() // Only the library's API decides that the call, not the answer, ends the handshake, // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call // that the object's state does not allow. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] public async Task TimeoutEndsTheHandshakeBeforeTheRegistryAnswers() { @@ -465,12 +490,14 @@ public async Task TimeoutEndsTheHandshakeBeforeTheRegistryAnswers() opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); answer.SetResult(); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types await Assert.ThrowsAsync(() => verifying.AsTask()); AssertFailed(await timingOut, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } // CONTRACT.agent-identity.md §6 reads the clock before the registry, and so does the handshake, // so a Hello that arrives in time stays in time however long the registry takes to answer. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task SlowRegistryDoesNotMakeAHelloLate() { @@ -490,9 +517,11 @@ public async Task SlowRegistryDoesNotMakeAHelloLate() Assert.True(result.Succeeded, result.FailureDetail); } - // CONTRACT.agent-identity.md §7.3 step 4: the server verifies the proof with the key of the certificate that - // authenticated the opening request, so the stream is authenticated for that certificate and its identity. + // Step 4: the server verifies the proof with the key of the certificate that authenticated the opening request, + // so the stream is authenticated for that certificate and its identity. // The vector file states the facts of the certificate, in Unix seconds, independently of any time zone. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a [Fact] public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() { @@ -509,13 +538,14 @@ public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() Assert.Equal(registered.CertificateStatus, identity.CertificateStatus.ToString().ToLowerInvariant()); Assert.Equal(registered.NotBefore, identity.CertificateNotBefore); Assert.Equal(registered.NotAfter, identity.CertificateNotAfter); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures Assert.Equal(AgentOperation.Connect, identity.Operation); Assert.Null(result.Error); Assert.Null(result.FailureDetail); } - // CONTRACT.agent-identity.md §3: the server replaces the identity's metadata - // with the metadata of each authenticated Hello. + // The server replaces the identity's metadata with the metadata of each authenticated Hello. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public async Task ValidHelloYieldsItsMetadata() { @@ -527,9 +557,10 @@ public async Task ValidHelloYieldsItsMetadata() Assert.Equal(VectorChannel.ValidMetadata, result.Metadata); } - // CONTRACT.agent-identity.md §3: metadata may be an empty object. + // Metadata may be an empty object. // A protobuf map without entries is also how a Hello without metadata arrives, // so the rule that rejects a JSON body without metadata cannot apply here. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public async Task HelloWithoutMetadataSucceeds() { @@ -546,6 +577,8 @@ public async Task HelloWithoutMetadataSucceeds() // CONTRACT.agent-identity.md §7.3 step 4: Welcome has correlation_id = Hello id. // CONTRACT.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. // The Hello id is in uppercase, so a Welcome that normalized it would no longer match what the agent sent. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public async Task WelcomeAnswersTheHello() { @@ -564,10 +597,11 @@ public async Task WelcomeAnswersTheHello() Assert.Equal(hello.Id, welcome.CorrelationId); } - // channel.proto: Welcome carries server_time. + // Welcome carries server_time. // The clock reads the time of the opening twice, to judge the request and to start the Hello timeout, // then the time of the handshake, then a later time. // The Welcome carries the time at which the server answers, from that one reading. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 [Fact] public async Task WelcomeCarriesTheServerTimeOfTheHandshake() { @@ -584,10 +618,10 @@ public async Task WelcomeCarriesTheServerTimeOfTheHandshake() Assert.Equal(250_000_000, serverTime.Nanos); } - // CONTRACT.agent-identity.md §7.3 step 3: the Hello answers the Challenge id, - // with a proof over the challenge bytes. + // Step 3: the Hello answers the Challenge id, with a proof over the challenge bytes. // The agent answers the Challenge it received, so the handshake holds the Hello to the id and bytes as sent, // even if the host later edits the message object it sent. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task HandshakeChecksTheChallengeAsSent() { @@ -601,8 +635,9 @@ public async Task HandshakeChecksTheChallengeAsSent() Assert.True(result.Succeeded, result.FailureDetail); } - // CONTRACT.agent-identity.md §6: a certificate is unexpired while now ≤ notAfter, compared to the exact instant. + // A certificate is unexpired while now ≤ notAfter, compared to the exact instant. // The Welcome shows that the clock reading that judged the certificate is the one at notAfter. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Fact] public async Task HandshakeSucceedsAtTheCertificateNotAfter() { @@ -611,6 +646,7 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); Assert.True(result.Succeeded, result.FailureDetail); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 Assert.Equal(Timestamp.FromDateTimeOffset(DeviceANotAfter), result.Welcome.Welcome.ServerTime); } @@ -618,9 +654,13 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() // CONTRACT.agent-identity.md §7.4 and the shared intent, security invariant 8: a stream closes no later than the // notAfter of the certificate that opened it, with UNAUTHENTICATED and error-code certificate_expired, // even while it waits for its Hello, so the timeout fails as a timeout only until then. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData("a valid Hello", 1, AgentIdentityErrorCode.CertificateExpired)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [InlineData("no Hello in time", 0, AgentIdentityErrorCode.SignatureInvalid)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData("no Hello in time", 1, AgentIdentityErrorCode.CertificateExpired)] public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( string end, @@ -638,6 +678,9 @@ public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. // CONTRACT.agent-identity.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the // failures of §7.3 step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Theory] [MemberData(nameof(NoHelloNames))] public async Task NoHelloInTimeIsSignatureInvalidUnlessAClosureApplies(string noHello) @@ -649,15 +692,17 @@ public async Task NoHelloInTimeIsSignatureInvalidUnlessAClosureApplies(string no AssertFailed(result, expectedCode, expectedStatus); } - // CONTRACT.agent-identity.md §7.3 step 4: the server waits 10 s for the Hello. + // Step 4: the server waits 10 s for the Hello. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public void HelloTimeoutIs10Seconds() { Assert.Equal(TimeSpan.FromSeconds(10), ChannelHandshake.HelloTimeout); } - // CONTRACT.agent-identity.md §7.3 step 4: the Hello must arrive within 10 s of the opening, 10 s included. + // Step 4: the Hello must arrive within 10 s of the opening, 10 s included. // The Welcome shows that the clock reading that judged the timeout is the one at 10 s. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task HelloExactlyAtTheTimeoutSucceeds() { @@ -669,11 +714,13 @@ public async Task HelloExactlyAtTheTimeoutSucceeds() ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime); } - // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. + // Step 4: no Hello within 10 s closes the stream with signature_invalid. // A Hello that the host passes on after the timeout fails, so a late host timer cannot let it through. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() { @@ -686,11 +733,12 @@ public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } - // CONTRACT.agent-identity.md §7.3 steps 1 and 2: the agent sends nothing until it receives the Challenge, + // Steps 1 and 2: the agent sends nothing until it receives the Challenge, // which the server sends once it has verified the opening request. // The agent can answer only once the opening is verified and its challenge sent, // so the 10 s run from the end of verification, not from the earlier reading that judged the request. // Here the registry and the nonce store take 5 s, and the Hello comes 10 s after them. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task HelloTimeoutRunsFromTheEndOfTheOpening() { @@ -709,6 +757,8 @@ public async Task HelloTimeoutRunsFromTheEndOfTheOpening() // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. // Only the library's API decides that a later call throws InvalidOperationException, // which the Framework Design Guidelines give a call that the object's state does not allow. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [MemberData(nameof(HandshakeEndsThenAnotherCall))] public async Task HandshakeEndsAtTheFirstCall(string first, string second) @@ -721,6 +771,8 @@ public async Task HandshakeEndsAtTheFirstCall(string first, string second) // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure // and the trailers `error-code: <§5.4 code>` and `server-time: `, which the agent reads. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Theory] [InlineData("a Hello with the proof of another device", StatusCode.Unauthenticated, "signature_invalid")] [InlineData("a Hello with invalid metadata", StatusCode.InvalidArgument, "invalid_request")] @@ -738,6 +790,7 @@ public async Task FailureClosesTheStreamWithTheContractStatusAndTrailers( RpcException exception = result.Error.ToRpcException(serverTime); Assert.Equal(expectedStatus, exception.StatusCode); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.Equal( [("error-code", expectedCode), ("server-time", "2026-09-24T12:00:00Z")], exception.Trailers.Select(entry => (entry.Key, entry.Value))); @@ -752,7 +805,12 @@ private static AgentIdentityError AssertFailed( Assert.False(result.Succeeded); Assert.Equal(expectedCode, result.Error.Code); Assert.Equal(expectedStatus, result.Error.ToRpcException(DateTimeOffset.UnixEpoch).StatusCode); + // oracle: mutant an empty failure detail for a late Hello, a first message that is not a Hello, + // a Hello without a correlation_id or correlated to another id, a proof that does not verify, + // no Hello before the timeout, a certificate no longer registered, or a revoked identity Assert.False(string.IsNullOrEmpty(result.FailureDetail)); + // No server push or metadata update happens before step 4 succeeds, and only then is the stream authenticated. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 Assert.Null(result.Identity); Assert.Null(result.Metadata); Assert.Null(result.Welcome); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs index 5ea16830b7..53f9dc019b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs @@ -8,8 +8,10 @@ public class ChannelHandshakeVectorTests { private static ChannelProofVectors Vectors => TestVectors.Instance.ChannelProof; - // CONTRACT.agent-identity.md §7.3 step 4: a proof that does not verify closes the stream with signature_invalid. + // Step 4: a proof that does not verify closes the stream with signature_invalid. // Each failing case changes the key, one part of the signed bytes, or the encoding of the proof. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: reference docs/agent-identity/test-vectors.json channel_proof [Theory] [MemberData(nameof(VectorData.ChannelProofCaseNames), MemberType = typeof(VectorData))] public async Task CaseMatchesItsExpectedValidity(string name) @@ -24,9 +26,10 @@ public async Task CaseMatchesItsExpectedValidity(string name) Assert.Equal(vector.ExpectedValid ? "ok" : "signature_invalid", outcome); } - // CONTRACT.agent-identity.md §7.3 step 3: the proof signs the domain, a zero byte, - // the challenge and the connect nonce. + // Step 3: the proof signs the domain, a zero byte, the challenge and the connect nonce. // A proof input that differs from the vector's in any byte fails every agent's proof. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: reference docs/agent-identity/test-vectors.json channel_proof.message_hex [Fact] public void ProofInputIsTheVectorMessage() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs index c34be70169..e08ad58f76 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs @@ -31,6 +31,8 @@ public class ChannelMessagesTests // CONTRACT.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. // A push answers no agent message, so it has no correlation_id. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Theory] [MemberData(nameof(MessageNames))] public void MessageHasALowercaseUuidIdAndNoCorrelationId(string message) @@ -38,11 +40,13 @@ public void MessageHasALowercaseUuidIdAndNoCorrelationId(string message) ServerMessage built = Messages[message](); Assert.Matches(TestJson.LowercaseUuidPattern, built.Id); + // oracle: scaffold Assert.False(built.HasCorrelationId); } - // CONTRACT.agent-identity.md §7.3 steps 5 and 7: the agent acknowledges a message by its id, - // so no two messages share one. + // Steps 5 and 7: the agent acknowledges a message by its id, so no two messages share one. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [MemberData(nameof(MessageNames))] public void EachMessageHasItsOwnId(string message) @@ -52,6 +56,9 @@ public void EachMessageHasItsOwnId(string message) // CONTRACT.agent-identity.md §7.3 step 7 and channel.proto: ConfigUpdate carries the config, in the shape of §5.2, // as a UTF-8 JSON object. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Fact] public void ConfigUpdateCarriesTheConfigJson() { @@ -68,8 +75,10 @@ public void ConfigUpdateCarriesTheConfigJson() // channel.proto: the reason of RenewRequested is "admin" or "rotation"; // CONTRACT.agent-identity.md §8.3 sends "rotation" at the start of a root rotation. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 [Theory] [InlineData(RenewalRequestReason.Admin, "admin")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §8.3 [InlineData(RenewalRequestReason.Rotation, "rotation")] public void RenewRequestedCarriesItsReason(RenewalRequestReason reason, string expected) { @@ -79,8 +88,8 @@ public void RenewRequestedCarriesItsReason(RenewalRequestReason reason, string e Assert.Equal(expected, message.RenewRequested.Reason); } - // CONTRACT.agent-identity.md §7.4: the streams of a retired certificate get - // Reconnect{reason:"certificate_rotated"}. + // The streams of a retired certificate get Reconnect{reason:"certificate_rotated"}. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] public void ReconnectCarriesItsReason() { @@ -93,6 +102,8 @@ public void ReconnectCarriesItsReason() // Reasons start at 1, so a reason that the host never set is not a valid one, // and a value after the last reason is not one either. // Framework Design Guidelines: validate enum arguments, and throw the most derived ArgumentException naming them. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [InlineData("RenewRequested", 0)] [InlineData("RenewRequested", 3)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index 30f69eb1c8..151a9b4082 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -19,6 +19,8 @@ public class ChannelOpeningTests // with 32 random bytes fresh for this stream. // CONTRACT.agent-identity.md §1 and §7.3: ids are lowercase hyphenated UUIDs. // The bytes come from the injected generator, the host's source of every secret byte. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public async Task ChallengeHolds32BytesFromTheInjectedGenerator() { @@ -28,13 +30,16 @@ public async Task ChallengeHolds32BytesFromTheInjectedGenerator() ServerMessage challenge = opening.Challenge; Assert.Equal(ServerMessage.PayloadOneofCase.Challenge, challenge.PayloadCase); + // oracle: scaffold Assert.Equal(Enumerable.Range(0, 32).Select(i => (byte)i), challenge.Challenge.Challenge_.ToByteArray()); Assert.Matches(UuidPattern, challenge.Id); + // oracle: scaffold Assert.False(challenge.HasCorrelationId); } - // CONTRACT.agent-identity.md §7.3 step 2: the challenge holds 32 random bytes fresh for this stream. + // Step 2: the challenge holds 32 random bytes fresh for this stream. // A challenge reused across streams would let a proof captured on one stream authenticate another. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task EachOpeningHasItsOwnChallenge() { @@ -43,15 +48,20 @@ public async Task EachOpeningHasItsOwnChallenge() ChannelOpening first = await OpenAsync(authority, TestRequest.DefaultNonce); ChannelOpening second = await OpenAsync(authority, "EBESExQVFhcYGRobHB0eHw"); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.NotEqual(first.Challenge.Id, second.Challenge.Id); + // oracle: scaffold Assert.Equal( Enumerable.Range(32, 32).Select(i => (byte)i), second.Challenge.Challenge.Challenge_.ToByteArray()); } - // CONTRACT.agent-identity.md §7.3 steps 3 and 4: the proof covers the challenge as sent and the connect nonce, + // Steps 3 and 4: the proof covers the challenge as sent and the connect nonce, // and is verified with the key of the certificate that authenticated the opening, // so the opening keeps the nonce and that certificate for the handshake. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: reference docs/agent-identity/test-vectors.json channel_proof + // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a [Fact] public async Task OpeningKeepsWhatTheChannelProofCovers() { @@ -78,10 +88,10 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() Assert.Equal(registered.NotAfter, opening.Certificate.NotAfter); } - // CONTRACT.agent-identity.md §7.3 steps 3 and 4: the agent signs the challenge it received, - // and the server verifies that proof. + // Steps 3 and 4: the agent signs the challenge it received, and the server verifies that proof. // The handshake checks the proof against the challenge as sent, // so a host that edits the message object after sending it must not change what the agent has to sign. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() { @@ -97,9 +107,11 @@ public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() Assert.Equal(sentBytes, opening.ChallengeBytes.ToArray()); } - // CONTRACT.agent-identity.md §7.4: a stream closes no later than its certificate's notAfter, + // A stream closes no later than its certificate's notAfter, // even while it waits for its Hello, so the opening gives the host that time. // The vector file states it in Unix seconds, independently of any time zone. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a [Fact] public async Task OpeningGivesTheNotAfterOfItsCertificate() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs index 19c827928b..5c2a328b4a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs @@ -20,7 +20,7 @@ public class ConstructionTests // IdentityConfig.Create checks the host's values and returns the config itself rather than a result. public static TheoryData CheckedTypes => new([.. Validated, typeof(IdentityConfig)]); - // oracle: self-derived; sole guard for a public IdentityConfig constructor that takes already-serialized JSON. + // oracle: mutant a public IdentityConfig constructor that takes already-serialized JSON [Theory] [MemberData(nameof(CheckedTypes))] public void HasNoPublicOrProtectedConstructor(Type type) @@ -35,7 +35,7 @@ public void HasNoPublicOrProtectedConstructor(Type type) // Validation returns a result that holds the instance, // so a public static member returning the type itself would be another factory. - // oracle: self-derived; sole guard for a public AgentMetadata factory that skips validation. + // oracle: mutant a public AgentMetadata factory that skips validation [Theory] [MemberData(nameof(ValidatedTypes))] public void NoPublicStaticMemberReturnsTheType(Type type) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs index 246315bcc1..0052246e0a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs @@ -19,23 +19,27 @@ private static Dictionary FieldLinesWithoutABearerToken { Dictionary fieldLines = new() { - // CONTRACT.agent-identity.md §2.1 (1): `token_invalid` when the `Authorization` header is missing. + // (1): `token_invalid` when the `Authorization` header is missing. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 ["no field"] = [], - // CONTRACT.agent-identity.md §2.1 (1): `token_invalid` when the `Authorization` header is repeated, + // (1): `token_invalid` when the `Authorization` header is repeated, // even when every field line holds the same valid token. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 ["the same token twice"] = ["Bearer " + Token, "Bearer " + Token], ["a token, then an empty field"] = ["Bearer " + Token, ""], ["an empty field, then a token"] = ["", "Bearer " + Token], - // CONTRACT.agent-identity.md §2.1 (1): `token_invalid` when the `Authorization` header is not `Bearer`. + // (1): `token_invalid` when the `Authorization` header is not `Bearer`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 ["Basic"] = ["Basic " + Token], ["DPoP"] = ["DPoP " + Token], ["Bearers"] = ["Bearers " + Token], ["Bear"] = ["Bear " + Token], ["no scheme"] = [Token], - // RFC 9110 §11.4: one or more spaces, and nothing else, separate the scheme from the token. + // One or more spaces, and nothing else, separate the scheme from the token. + // oracle: spec RFC 9110 §11.4 ["the scheme alone"] = ["Bearer"], ["the scheme and a space"] = ["Bearer "], ["no space"] = ["Bearer" + Token], @@ -43,7 +47,8 @@ private static Dictionary FieldLinesWithoutABearerToken ["a space and a tab"] = ["Bearer \t" + Token], }; - // CONTRACT.agent-identity.md §2.1 (1): a field that holds no token gets `token_invalid`. + // (1): a field that holds no token gets `token_invalid`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 foreach ((string name, string token) in MalformedTokens) { fieldLines.Add(name, ["Bearer " + token]); @@ -53,8 +58,9 @@ private static Dictionary FieldLinesWithoutABearerToken } } - // CONTRACT.agent-identity.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`. + // A complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`. // The pattern covers the whole value, so a line terminator after the token is not part of a match. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token private static Dictionary MalformedTokens => new() { ["another version"] = "dvaet2." + Bag + "." + Secret, @@ -82,24 +88,30 @@ private static Dictionary FieldLinesWithoutABearerToken // CONTRACT.agent-identity.md §2.1: the authority finds the token whose SHA-256(secret) matches, // and §2 makes it match the secret text exactly. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [Theory] [InlineData("a bearer token", "Bearer " + Token)] - // RFC 9110 §11.1: the authentication scheme is case-insensitive. + // The authentication scheme is case-insensitive. + // oracle: spec RFC 9110 §11.1 [InlineData("a lowercase scheme", "bearer " + Token)] [InlineData("an uppercase scheme", "BEARER " + Token)] [InlineData("a mixed-case scheme", "bEaReR " + Token)] - // RFC 9110 §11.4: `credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]`. + // `credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]`. + // oracle: spec RFC 9110 §11.4 [InlineData("two spaces", "Bearer " + Token)] [InlineData("three spaces", "Bearer " + Token)] // CONTRACT.agent-identity.md §2.1 (1): the authority finds a token by `SHA-256(secret)` alone, // and §2 makes the bag routing data that the authority never trusts, so it is not decoded. // The rows are a bag of an impossible base64url length, one that decodes to bytes that are not JSON, // and the bag of {"u":"https://other/psu"}. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [InlineData("a one-character bag", "Bearer dvaet1.A." + Secret)] [InlineData("a bag that is not JSON", "Bearer dvaet1._-_-." + Secret)] [InlineData("the bag of another URL", "Bearer dvaet1.eyJ1IjoiaHR0cHM6Ly9vdGhlci9wc3UifQ." + Secret)] - // RFC 9110 §5.5: a field value excludes the white space around it, - // which a parser removes before evaluating the value. + // A field value excludes the white space around it, which a parser removes before evaluating the value. + // oracle: spec RFC 9110 §5.5 [InlineData("a space before", " Bearer " + Token)] [InlineData("a space after", "Bearer " + Token + " ")] [InlineData("tabs around", "\tBearer " + Token + "\t")] @@ -112,6 +124,7 @@ public void ReadsTheSecretHashOfABearerToken(string name, string fieldValue) Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Theory] [MemberData(nameof(FieldLinesWithoutABearerTokenNames))] public void RejectsFieldLinesWithoutABearerToken(string fieldLines) @@ -122,8 +135,9 @@ public void RejectsFieldLinesWithoutABearerToken(string fieldLines) AssertTokenInvalid(result); } - // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes. + // A token is at most 4096 bytes. // The bag is not decoded, so a bag of any length in the alphabet sets the token length. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [Fact] public void AcceptsATokenOf4096Bytes() { @@ -138,6 +152,8 @@ public void AcceptsATokenOf4096Bytes() // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes; // §2.1 (1): a field that holds no token gets `token_invalid`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Fact] public void RejectsATokenLongerThan4096Bytes() { @@ -149,9 +165,11 @@ public void RejectsATokenLongerThan4096Bytes() AssertTokenInvalid(result); } - // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly. + // The authority matches the `secret` text exactly. // Ending in '9' (sextet 61) instead of '8' (sextet 60) sets a pad bit, // so a lenient decoder reads the same 32 bytes, but the text, and so its hash, differs. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Fact] public void HashesANonCanonicalSecretAsItsText() { @@ -180,25 +198,30 @@ public void HashesANonCanonicalSecretAsItsText() new[] { "Bearer " + Token + "\n" }, }; + // Security invariant 9: tokens never appear in logs, not even partially. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants [Theory] [MemberData(nameof(RefusedFieldLines))] public void FailureRevealsNoPartOfTheToken(string[] fieldLines) { EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(fieldLines); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 Assert.False(result.Succeeded); EnrollmentTokenAssert.RevealsNoPart(result.FailureDetail, Bag, Secret); EnrollmentTokenAssert.RevealsNoPart(result.Error.Message, Bag, Secret); EnrollmentTokenAssert.RevealsNoPart($"{result}", Bag, Secret); } - // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments [Fact] public void RejectsNullFieldLines() { ArgumentNullException exception = Assert.Throws( () => EnrollmentAuthorization.ReadBearerToken(null!)); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("authorizationFieldLines", exception.ParamName); } @@ -207,6 +230,7 @@ private static void AssertTokenInvalid(EnrollmentAuthorizationResult result) { Assert.False(result.Succeeded); Assert.Equal(AgentIdentityErrorCode.TokenInvalid, result.Error.Code); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors Assert.Equal(401, result.Error.HttpStatusCode); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index 18bc5560c7..488e70cb2a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -21,11 +21,14 @@ public class EnrollmentRulesTests ["the key of a deleted identity"] = IssuedKeyMatch.ForDeletedIdentity(), }; - // CONTRACT.agent-identity.md §2.1: a token creates an identity when it exists, has not been deleted, + // A token creates an identity when it exists, has not been deleted, // has not expired, and fewer than `max_uses` identities have been created with it; (6) a new identity. // The rows are a usable token, one that expires one tick after now, and one that can create one more identity. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Theory] [InlineData(TimeSpan.TicksPerDay, 3)] + // `token_expired` only when `now ≥ expires_at`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [InlineData(1L, 3)] [InlineData(TimeSpan.TicksPerDay, 9)] public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int identitiesCreated) @@ -39,23 +42,27 @@ public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int Assert.Null(decision.Error); } - // CONTRACT.agent-identity.md §2.1: when several outcomes apply, the earliest wins: + // When several outcomes apply, the earliest wins: // (4) `token_invalid` for a deleted token, then `token_expired` for an expired one; (5) `token_exhausted`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Theory] - // CONTRACT.agent-identity.md §5.4: `token_expired` when `now ≥ expires_at`. + // `token_expired` when `now ≥ expires_at`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [InlineData(false, 0L, 10, 3, AgentIdentityErrorCode.TokenExpired)] - // CONTRACT.agent-identity.md §2.1: a token creates an identity only while fewer than `max_uses` identities - // have been created with it. + // A token creates an identity only while fewer than `max_uses` identities have been created with it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [InlineData(false, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 10, 11, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 0, 0, AgentIdentityErrorCode.TokenExhausted)] - // CONTRACT.agent-identity.md §2.1: a deleted token authenticates only for an idempotent replay; + // A deleted token authenticates only for an idempotent replay; // every other use returns `token_invalid`, whether or not the token has also expired or been exhausted. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [InlineData(true, TimeSpan.TicksPerDay, 10, 3, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, 0L, 10, 3, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, 0L, 10, 10, AgentIdentityErrorCode.TokenInvalid)] - // CONTRACT.agent-identity.md §2.1: (4) `token_expired` comes before (5) `token_exhausted`. + // (4) `token_expired` comes before (5) `token_exhausted`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [InlineData(false, 0L, 10, 10, AgentIdentityErrorCode.TokenExpired)] public void RejectsANewKeyWithTheEarliestTokenOutcome( bool deleted, @@ -71,10 +78,11 @@ public void RejectsANewKeyWithTheEarliestTokenOutcome( AssertRejected(expected, decision); } - // CONTRACT.agent-identity.md §2.1: the key of the current certificate of a non-revoked identity enrolled with the + // The key of the current certificate of a non-revoked identity enrolled with the // same token → 200 with that identity, without counting a use. // The key lookup (3) comes before (4) and (5), and a deleted token authenticates for this replay, // so the replay wins whether the token is deleted, expired or exhausted. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Theory] [InlineData(false, false, false)] [InlineData(true, false, false)] @@ -92,12 +100,13 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, Assert.Null(decision.Error); } - // CONTRACT.agent-identity.md §2.1: the first matching key rule decides. + // The first matching key rule decides. // The key of an identity enrolled with a different token → `invalid_request`, before the revocation rule; // any certificate key of a revoked identity → `identity_revoked`, before the pending-or-retired rule // and instead of the replay, which needs a non-revoked identity; // the key of a pending or retired certificate of a non-revoked identity → `invalid_request`, // instead of the replay, which needs the current certificate. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Theory] [InlineData(false, false, CertificateStatus.Current, false, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(false, false, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.InvalidRequest)] @@ -108,8 +117,8 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, [InlineData(true, true, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.IdentityRevoked)] [InlineData(true, false, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(true, false, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.InvalidRequest)] - // CONTRACT.agent-identity.md §2.1: (3) the result of the key lookup comes - // before (4) `token_expired` and (5) `token_exhausted`. + // (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [InlineData(false, false, CertificateStatus.Current, true, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(false, false, CertificateStatus.Current, false, true, AgentIdentityErrorCode.InvalidRequest)] [InlineData(true, true, CertificateStatus.Current, true, false, AgentIdentityErrorCode.IdentityRevoked)] @@ -132,10 +141,11 @@ public void RejectsAnIssuedKeyByTheFirstMatchingRule( AssertRejected(expected, decision); } - // CONTRACT.agent-identity.md §2.1: the key of a certificate of a deleted identity → `identity_revoked`, + // The key of a certificate of a deleted identity → `identity_revoked`, // and (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. // The authority keeps only the SPKI SHA-256 and issuance time of a deleted identity's keys, // so no other key rule applies to them. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Theory] [InlineData(false, false)] [InlineData(true, true)] @@ -149,8 +159,9 @@ public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, boo AssertRejected(AgentIdentityErrorCode.IdentityRevoked, decision); } - // CONTRACT.agent-identity.md §2.1 (3): a deleted token gets `token_invalid` for anything but an idempotent replay, + // (3): a deleted token gets `token_invalid` for anything but an idempotent replay, // whatever the CSR public key matches. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Theory] [MemberData(nameof(KeysButTheReplayNames))] public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(string key) @@ -160,8 +171,10 @@ public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(strin AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); } - // Framework Design Guidelines: a bad argument throws the most derived ArgumentException, whose ParamName names it, + // A bad argument throws the most derived ArgumentException, whose ParamName names it, // here ArgumentOutOfRangeException for a count below zero. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [InlineData(-1, 0, "maxUses")] [InlineData(1, -1, "identitiesCreated")] @@ -176,6 +189,9 @@ public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, // Statuses start at 1, so a status that the host never set is not a valid one, // and a value after the last status is not one either. // Framework Design Guidelines: validate enum arguments, and throw the most derived ArgumentException naming them. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [InlineData(0)] [InlineData(4)] @@ -187,13 +203,15 @@ public void KeyMatchRejectsAnUndefinedCertificateStatus(int status) Assert.Equal("status", exception.ParamName); } - // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments [Fact] public void DecideRejectsANullToken() { ArgumentNullException exception = Assert.Throws( () => EnrollmentRules.Decide(null!, null, Now)); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("token", exception.ParamName); } @@ -202,6 +220,8 @@ private static void AssertRejected(AgentIdentityErrorCode expected, EnrollmentDe Assert.Equal(EnrollmentOutcome.Reject, decision.Outcome); Assert.Equal(expected, decision.Error?.Code); Assert.Null(decision.ExistingIdentityId); + // oracle: mutant an empty failure detail for an enrollment refused for a deleted, expired or exhausted token, + // or for a CSR key of a deleted, revoked or other-token identity or of a pending or retired certificate Assert.False(string.IsNullOrEmpty(decision.FailureDetail)); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index e93c6f18d5..0c9d3e3719 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -11,7 +11,8 @@ public class EnrollmentTokenTests private static readonly byte[] SecretBytes = [.. Enumerable.Range(0, 32).Select(i => (byte)i)]; private const string SecretText = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; - // CONTRACT.agent-identity.md §2: `..`, where `secret` is base64url of 32 random bytes. + // `..`, where `secret` is base64url of 32 random bytes. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [Fact] public void CreateEncodesTheGeneratorBytesAsTheSecret() { @@ -21,8 +22,10 @@ public void CreateEncodesTheGeneratorBytesAsTheSecret() Assert.EndsWith("." + SecretText, token.Value, StringComparison.Ordinal); } - // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly, + // The authority matches the `secret` text exactly, // so it hashes the 43 characters rather than the bytes they encode. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Fact] public void SecretHashIsTheSha256OfTheSecretText() { @@ -31,9 +34,11 @@ public void SecretHashIsTheSha256OfTheSecretText() Assert.Equal(SHA256.HashData(Encoding.ASCII.GetBytes(SecretText)), token.SecretHash.ToArray()); } - // CONTRACT.agent-identity.md §2: a complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`, + // A complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`, // and its secret is the canonical base64url encoding of the 32 random bytes. // The random values are seeded, so a failure reproduces. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void CreateWritesEverySecretInTheContractPattern() { @@ -57,6 +62,8 @@ public void CreateWritesEverySecretInTheContractPattern() // and finds the token of an enroll request by the hash that ReadBearerToken returns. // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly, // so both hashes are the SHA-256 of the 43 ASCII characters of the token's secret. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 [Fact] public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() { @@ -81,12 +88,13 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() } } - // CONTRACT.agent-identity.md §2: `bag` is the base64url encoding of a UTF-8 JSON object - // whose `u` is the product base URL. + // `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [Theory] [InlineData("https://host/dvls", "https://host/dvls")] // The agent appends `/api/agent-identity/v1/...` to `u` (CONTRACT.agent-identity.md §5), // so `u` ends without a slash. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5 [InlineData("https://host/dvls/", "https://host/dvls")] [InlineData("https://host/dvls//", "https://host/dvls")] [InlineData("https://host/", "https://host")] @@ -94,13 +102,18 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() // A URI holds no white space (RFC 3986 §2): // white space around it is not part of it (Appendix C), // and a space or a non-ASCII character inside it is percent-encoded (§2.1; RFC 3987 §3.1). + // oracle: spec RFC 3986 §Appendix C [InlineData(" https://host/dvls\t", "https://host/dvls")] + // oracle: spec RFC 3986 §2.1 [InlineData("https://host/my dvls", "https://host/my%20dvls")] + // oracle: spec RFC 3987 §3.1 [InlineData("https://host/dvls\u00E9", "https://host/dvls%C3%A9")] public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl, string expected) { EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + // The bag of a token the library creates holds `u` alone. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Enrollment tokens Assert.Equal(("u", expected), Assert.Single(BagMembers(token))); } @@ -108,24 +121,30 @@ public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl, string ex // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. private static readonly string[] UnacceptableUrls = [ + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token "", "/dvls", "host/dvls", "http://host/dvls", "wss://host/dvls", + // oracle: spec RFC 9110 §4.2.2 "https:///dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token "https://host/dvls?tenant=1", "https://host/dvls?", "https://host/dvls#top", "https://host/dvls#", + // oracle: spec RFC 9110 §4.2.4 "https://admin@host/dvls", "https://@host/dvls", ]; // The unacceptable URLs, and a URL one byte longer than the longest that fits in a token // (CONTRACT.agent-identity.md §2). + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls, UrlOfLength(3026)]); + // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [MemberData(nameof(RejectedUrlRows))] public void CreateRejectsAnotherUrl(string productBaseUrl) @@ -133,12 +152,16 @@ public void CreateRejectsAnotherUrl(string productBaseUrl) ArgumentException exception = Assert.Throws( () => EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes))); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("productBaseUrl", exception.ParamName); } // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes. + // A token's bag holds `u` alone, without insignificant white space, which fixes the longest URL that fits. // A 3025-byte URL makes a 3033-byte bag object, a 4044-character bag and a 4095-byte token. // One more byte makes a 4046-character bag and a 4097-byte token, since no base64url text has 4045 characters. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: intent dotnet/AgentIdentity/INTENT.md §Enrollment tokens [Fact] public void CreateAcceptsTheLongestUrlThatFits() { @@ -147,8 +170,9 @@ public void CreateAcceptsTheLongestUrlThatFits() Assert.Equal(4095, token.Value.Length); } - // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially, + // Security invariant 9: tokens never appear in logs, not even partially, // and a log statement may format the token object. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants [Fact] public void ToStringRevealsNoPartOfTheToken() { @@ -158,7 +182,8 @@ public void ToStringRevealsNoPartOfTheToken() EnrollmentTokenAssert.RevealsNoPart(token.ToString(), segments[1], segments[2]); } - // INTENT.agent-identity.md, security invariant 9: a host may log the exception of a failed creation. + // Security invariant 9: a host may log the exception of a failed creation. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants [Theory] [MemberData(nameof(RejectedUrlRows))] public void CreateFailureRevealsNoPartOfTheSecret(string productBaseUrl) @@ -169,7 +194,8 @@ public void CreateFailureRevealsNoPartOfTheSecret(string productBaseUrl) EnrollmentTokenAssert.RevealsNoPart(exception.Message, SecretText); } - // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [InlineData("productBaseUrl")] [InlineData("randomNumberGenerator")] @@ -177,6 +203,7 @@ public void CreateRejectsANullArgument(string parameter) { ArgumentNullException exception = Assert.Throws(CreationsWithANullArgument[parameter]); + // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal(parameter, exception.ParamName); } @@ -198,8 +225,10 @@ private static string UrlOfLength(int length) private static (string Name, string? Value)[] BagMembers(EnrollmentToken token) { string bag = token.Value.Split('.')[1]; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token Assert.True(Base64Url.IsValid(bag)); byte[] json = Base64Url.DecodeFromChars(bag); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.Equal(bag, Base64Url.EncodeToString(json)); using JsonDocument document = JsonDocument.Parse(json); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs index 15a0a78158..6290d00a11 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs @@ -36,23 +36,36 @@ public class FriendlyNameFormatTests // INTENT.agent-identity.md, "Enrollment": the placeholders are `{}` and `{token_name}`, // the enrollment token's name; CONTRACT.agent-identity.md §3 lists the known metadata keys. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{hostname}", "pc-042")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{fqdn}", "pc-042.corp.example")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{domain}", "corp.example")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{os_name}", "Windows")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{os_version}", "10.0.26100")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{arch}", "x86_64")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{agent_version}", "2026.3.0")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{machine_id}", "4c4c4544-0042-3510-8052-b4c04f4a4e32")] [InlineData("{token_name}", "Fleet")] - // INTENT.agent-identity.md, "Enrollment": `{{` and `}}` escape braces, and other text is literal. + // `{{` and `}}` escape braces, and other text is literal. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [InlineData("{{", "{")] [InlineData("}}", "}")] [InlineData("}}{{", "}{")] [InlineData("{{hostname}}", "{hostname}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{{{hostname}}}", "{pc-042}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("Laptop {hostname} ({os_name}, {arch})", "Laptop pc-042 (Windows, x86_64)")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{token_name}/{hostname}{hostname}", "Fleet/pc-042pc-042")] public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expected) { @@ -62,6 +75,7 @@ public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expe // INTENT.agent-identity.md, "Enrollment": a format with an unpaired brace or an unknown placeholder is rejected. // A placeholder names a known metadata key of CONTRACT.agent-identity.md §3 or `token_name` exactly, // so another case, white space or another key, such as one that §3 allows but does not list, is unknown. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Theory] [InlineData("{")] [InlineData("}")] @@ -72,25 +86,39 @@ public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expe [InlineData("{{hostname}}}")] [InlineData("{{}")] [InlineData("}{")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{Hostname}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{HOSTNAME}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{ hostname }")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{hostname }")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{host{name}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{token}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{name}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{custom_key}")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [InlineData("{0}")] public void TryParseRejectsAnUnpairedBraceOrAnUnknownPlaceholder(string format) { Assert.False(FriendlyNameFormat.TryParse(format, out FriendlyNameFormat? parsed, out string? error)); + // oracle: scaffold Assert.Null(parsed); + // oracle: scaffold Assert.False(string.IsNullOrWhiteSpace(error)); } // The error says where the problem is, counting Unicode scalar values from 1, // so that U+1F600, which UTF-16 writes as two code units, counts as one character. + // oracle: mutant a parse error that leaves out the position of the problem, + // or gives a position that does not count up from 1 [Theory] [InlineData("{", 1)] [InlineData("ab}", 3)] @@ -106,6 +134,7 @@ public void TryParseErrorGivesThePositionOfTheProblem(string format, int positio } // Every error lists the placeholders, so that the administrator can correct the format. + // oracle: mutant a parse error that lists a placeholder without one of its braces [Theory] [InlineData("{")] [InlineData("}")] @@ -120,9 +149,10 @@ public void TryParseErrorListsThePlaceholders(string format) } } - // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially. + // Security invariant 9: tokens never appear in logs, not even partially. // A format is administrator input that may hold a pasted token, and a host may log why a format was refused. // Each row holds the token in an unknown placeholder, or as literal text before each kind of error. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants [Theory] [InlineData("in an unknown placeholder", "{" + Token + "}")] [InlineData("before an unknown placeholder", "{hostname} " + Token + " {Hostname}")] @@ -130,15 +160,18 @@ public void TryParseErrorListsThePlaceholders(string format) [InlineData("before an unpaired closing brace", Token + "}")] public void TryParseErrorRevealsNoPartOfTheFormat(string name, string format) { + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment Assert.False(FriendlyNameFormat.TryParse(format, out _, out string? error), name); EnrollmentTokenAssert.RevealsNoPart(error, Bag, Secret); } - // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially. + // Security invariant 9: tokens never appear in logs, not even partially. // A log statement may format the object of a valid format that holds a pasted token as literal text. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants [Fact] public void ToStringRevealsNoPartOfTheFormat() { + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment Assert.True( FriendlyNameFormat.TryParse("{hostname} " + Token, out FriendlyNameFormat? parsed, out string? error), error); @@ -146,7 +179,8 @@ public void ToStringRevealsNoPartOfTheFormat() EnrollmentTokenAssert.RevealsNoPart($"{parsed}", Bag, Secret); } - // INTENT.agent-identity.md, "Enrollment": a missing metadata value evaluates to empty. + // A missing metadata value evaluates to empty. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Fact] public void EvaluatesAMissingMetadataValueToEmpty() { @@ -158,7 +192,8 @@ public void EvaluatesAMissingMetadataValueToEmpty() // `{token_name}` is the enrollment token's name, // and CONTRACT.agent-identity.md §3 lets an agent report an unknown key such as `token_name`, - // which is never used for anything. + // which is never used for authorization. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Fact] public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue() { @@ -167,9 +202,9 @@ public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue() Assert.Equal("Fleet", Evaluate("{token_name}", metadata)); } - // INTENT.agent-identity.md, "Enrollment": the format holds the placeholders and the escaped braces, - // and a placeholder evaluates to its value. + // The format holds the placeholders and the escaped braces, and a placeholder evaluates to its value. // A value is substituted once, so braces that a device reports are text, not placeholders. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Fact] public void EvaluatesBracesInAValueAsText() { @@ -189,7 +224,9 @@ public void EvaluatesBracesInAValueAsText() public static TheoryData WhiteSpaceRows => new(WhiteSpace); - // INTENT.agent-identity.md, "Enrollment": the result is trimmed of Unicode `White_Space`. + // The result is trimmed of Unicode `White_Space`. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment + // oracle: spec Unicode PropList.txt §White_Space [Theory] [MemberData(nameof(WhiteSpaceRows))] public void TrimsAWhiteSpaceCodePointFromBothEnds(int codePoint) @@ -202,6 +239,8 @@ public void TrimsAWhiteSpaceCodePointFromBothEnds(int codePoint) // No code point outside `White_Space` is trimmed, such as U+180E MONGOLIAN VOWEL SEPARATOR, // which left `White_Space` in Unicode 6.3, U+200B ZERO WIDTH SPACE and U+FEFF ZERO WIDTH NO-BREAK SPACE. // Every other scalar value of the Basic Multilingual Plane is checked, and a few beyond it. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment + // oracle: spec Unicode PropList.txt §White_Space [Fact] public void KeepsEveryOtherCodePointAtBothEnds() { @@ -237,13 +276,16 @@ public void KeepsEveryOtherCodePointAtBothEnds() ["an emoji after the cut"] = (new string('a', 255) + "\U0001F600", new string('a', 255)), ["300 emoji"] = (Repeat("\U0001F600", 300), Repeat("\U0001F600", 255)), ["200 accented letters"] = (Repeat("e\u0301", 200), Repeat("e\u0301", 127) + "e"), + // oracle: spec Unicode PropList.txt §White_Space ["white space at the cut"] = (new string('a', 250) + new string(' ', 10) + "bbbbb", new string('a', 250)), + // oracle: spec Unicode PropList.txt §White_Space ["white space before 255 letters"] = ("\u3000" + new string(' ', 9) + new string('a', 255), new string('a', 255)), }; public static TheoryData TokenNamesToTruncateNames => new(TokenNamesToTruncate.Keys); + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Theory] [MemberData(nameof(TokenNamesToTruncateNames))] public void TruncatesTo255ScalarValuesBetweenTwoTrims(string tokenName) @@ -255,10 +297,14 @@ public void TruncatesTo255ScalarValuesBetweenTwoTrims(string tokenName) // INTENT.agent-identity.md, "Enrollment": an empty result falls back to the identity ID, // which CONTRACT.agent-identity.md §1 writes as a lowercase hyphenated UUID. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Theory] [InlineData("{fqdn}", TokenName)] [InlineData("", TokenName)] + // oracle: spec Unicode PropList.txt §White_Space [InlineData(" \t\u00A0", TokenName)] + // oracle: spec Unicode PropList.txt §White_Space [InlineData("{token_name}", "\u2028\u3000")] public void FallsBackToTheIdentityIdForAnEmptyName(string format, string tokenName) { @@ -270,6 +316,7 @@ public void FallsBackToTheIdentityIdForAnEmptyName(string format, string tokenNa // A lone surrogate is no Unicode scalar value, so it counts as one U+FFFD REPLACEMENT CHARACTER, // which Unicode §3.9 recommends for an ill-formed code unit, and the name stays well-formed UTF-16. // Validated metadata holds no lone surrogate, but a token name or the format text can. + // oracle: scaffold [Fact] public void ReplacesALoneSurrogateWithTheReplacementCharacter() { @@ -279,19 +326,23 @@ public void ReplacesALoneSurrogateWithTheReplacementCharacter() Assert.Equal("pc\uFFFD", Evaluate("pc\uD83D", tokenName: string.Empty)); } - // INTENT.agent-identity.md, "Enrollment": the default format is `{hostname}`. + // The default format is `{hostname}`. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Fact] public void DefaultFormatIsTheHostname() { AgentMetadata withoutHostname = Validated(new Dictionary { ["fqdn"] = "pc-042.corp.example" }); Assert.Equal("pc-042", FriendlyNameFormat.Default.Evaluate(Metadata, TokenName, IdentityId)); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.Equal( "0f8fad5b-d9cb-469f-a165-70867728950e", FriendlyNameFormat.Default.Evaluate(withoutHostname, TokenName, IdentityId)); } - // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Fact] public void TryParseRejectsANullFormat() { @@ -301,7 +352,9 @@ public void TryParseRejectsANullFormat() Assert.Equal("format", exception.ParamName); } - // Framework Design Guidelines: a null argument throws ArgumentNullException, whose ParamName names it. + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] [InlineData("metadata")] [InlineData("tokenName")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index 4b64d1a475..3ab52e081d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -19,6 +19,7 @@ public class AgentIdentityErrorCodeTests { AgentIdentityErrorCode.Unavailable, "unavailable" }, }; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [Theory] [MemberData(nameof(WireValues))] public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expected) @@ -30,15 +31,26 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec // The table gives `unavailable` 5xx, and an unexpected failure gets 500. public static TheoryData HttpStatusCodes => new() { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.TokenInvalid, 401 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.TokenExhausted, 403 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.TokenExpired, 401 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.IdentityRevoked, 403 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.IdentityUnknown, 401 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.CertificateExpired, 401 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.SignatureInvalid, 401 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.ClockSkew, 401 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.InvalidRequest, 400 }, + // An unexpected server failure → `500 unavailable`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.Unavailable, 500 }, }; @@ -55,15 +67,25 @@ public void HttpStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, int e // `unavailable` gets UNAVAILABLE, which gRPC defines as a transient condition. public static TheoryData GrpcStatusCodes => new() { + // oracle: mutant the gRPC mapping throws for token_invalid as for an undefined code { AgentIdentityErrorCode.TokenInvalid, StatusCode.Unauthenticated }, + // oracle: mutant the gRPC mapping throws for token_exhausted as for an undefined code { AgentIdentityErrorCode.TokenExhausted, StatusCode.Unauthenticated }, + // oracle: mutant the gRPC mapping throws for token_expired as for an undefined code { AgentIdentityErrorCode.TokenExpired, StatusCode.Unauthenticated }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 { AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 { AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 { AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 { AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 { AgentIdentityErrorCode.ClockSkew, StatusCode.Unauthenticated }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 { AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument }, + // oracle: spec gRPC status codes §Status codes and their use in gRPC { AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable }, }; @@ -76,6 +98,7 @@ public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, Statu // CONTRACT.agent-identity.md §5.4 lists exactly these ten codes. // A code added without a contract row would otherwise go untested in every table above. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [Fact] public void EveryCodeHasAContractRow() { @@ -88,6 +111,8 @@ public void EveryCodeHasAContractRow() // The default value of the enum is not a code, so a mapping must not turn it into one. // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Fact] public void UndefinedCodeHasNoMapping() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs index 55a844a071..1db927f20d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs @@ -31,34 +31,45 @@ .. Enum.GetValues().Select(code => ($"From({code})", Age .. FrameworkLevelErrorNames.Select(name => (name, FrameworkLevelError(name))), ]; - // CONTRACT.agent-identity.md §5.4, column "HTTP". + // Column "HTTP". [Theory] [MemberData(nameof(AgentIdentityErrorCodeTests.HttpStatusCodes), MemberType = typeof(AgentIdentityErrorCodeTests))] public void FromCarriesTheCodeWithItsContractStatus(AgentIdentityErrorCode code, int expectedStatus) { AgentIdentityError error = AgentIdentityError.From(code); + // The body's `error` is the code. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors Assert.Equal(code, error.Code); Assert.Equal(expectedStatus, error.HttpStatusCode); } // CONTRACT.agent-identity.md §5.4 has no code for the default value of the enum. // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Fact] public void FromRejectsAnUndefinedCode() { Assert.Throws(() => AgentIdentityError.From(default)); } - // CONTRACT.agent-identity.md §5.4: malformed JSON or body → 400 invalid_request; + // Malformed JSON or body → 400 invalid_request; // unknown route → 404 invalid_request; wrong method → 405 invalid_request; body too large → 413 invalid_request; // an unexpected server failure → 500 unavailable; overload or a missing dependency → 503 unavailable. [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [InlineData(nameof(AgentIdentityError.MalformedBody), 400, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [InlineData(nameof(AgentIdentityError.UnknownRoute), 404, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [InlineData(nameof(AgentIdentityError.MethodNotAllowed), 405, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [InlineData(nameof(AgentIdentityError.BodyTooLarge), 413, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [InlineData(nameof(AgentIdentityError.UnexpectedFailure), 500, AgentIdentityErrorCode.Unavailable)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: spec RFC 9110 §15.6.4 [InlineData(nameof(AgentIdentityError.ServiceUnavailable), 503, AgentIdentityErrorCode.Unavailable)] public void FrameworkLevelErrorHasItsContractStatusAndCode( string name, @@ -71,9 +82,9 @@ public void FrameworkLevelErrorHasItsContractStatusAndCode( Assert.Equal(expectedCode, error.Code); } - // CONTRACT.agent-identity.md §5.4: every error body, framework-level failures included, - // carries a `message` of human text. + // Every error body, framework-level failures included, carries a `message` of human text. // The contract leaves its wording free, so only its presence is checked. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [Theory] [MemberData(nameof(PublicErrorNames))] public void ErrorHasAHumanMessage(string name) @@ -85,6 +96,9 @@ public void ErrorHasAHumanMessage(string name) // CONTRACT.agent-identity.md §5.4 body, with the snake_case keys and the timestamp format of §1. // Parsing the raw bytes also rejects a byte order mark, which RFC 8259 §8.1 forbids in a JSON text. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: spec RFC 8259 §8.1 [Fact] public void JsonBodyHasExactlyTheContractMembers() { @@ -103,9 +117,13 @@ public void JsonBodyHasExactlyTheContractMembers() // CONTRACT.agent-identity.md §7.4: a refused channel opening carries the code's status. // §7.3 step 4: invalid `Hello` metadata gets INVALID_ARGUMENT. [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData(AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData(AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [InlineData(AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument)] + // oracle: spec gRPC status codes §Status codes and their use in gRPC [InlineData(AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable)] public void RpcExceptionHasTheChannelStatus(AgentIdentityErrorCode code, StatusCode expected) { @@ -114,8 +132,9 @@ public void RpcExceptionHasTheChannelStatus(AgentIdentityErrorCode code, StatusC Assert.Equal(expected, exception.StatusCode); } - // CONTRACT.agent-identity.md §7.4: trailers `error-code: <§5.4 code>` and `server-time: `, - // in no stated order. + // Trailers `error-code: <§5.4 code>` and `server-time: `, in no stated order. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] public void RpcExceptionHasExactlyTheContractTrailers() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs index cfd05b86a3..be0090caaf 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs @@ -18,9 +18,13 @@ .. TestVectors.Instance.HttpSignature.Registered.Select( // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. // A timestamp names the whole second that contains the instant. [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("2026-09-24T12:00:00+00:00", "2026-09-24T12:00:00Z")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("2026-09-24T14:00:00+02:00", "2026-09-24T12:00:00Z")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("2026-09-24T23:30:00-01:00", "2026-09-25T00:30:00Z")] + // oracle: scaffold [InlineData("2026-09-24T12:00:00.9999999+00:00", "2026-09-24T12:00:00Z")] public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected) { @@ -31,6 +35,9 @@ public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected // RFC 3339 dates are Gregorian, and §5.6 writes them in ASCII digits with fixed separators, whatever the culture. // A culture-sensitive format would write the Thai Buddhist year 2569 and the time separator of this culture. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: spec RFC 3339 §1 + // oracle: spec RFC 3339 §5.6 [Fact] public void FormatTimestampIgnoresTheCurrentCulture() { @@ -54,6 +61,9 @@ public void FormatTimestampIgnoresTheCurrentCulture() // CONTRACT.agent-identity.md §1: a thumbprint is base64url(SHA-256(certificate DER)). // CONTRACT.agent-identity.md §6: a request's `keyid` is the thumbprint of the certificate that signs it. // The vector file gives each certificate with its thumbprint, or a registered certificate with its keyid. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: reference docs/agent-identity/test-vectors.json [Theory] [MemberData(nameof(VectorCertificateNames))] public void CertificateHasTheVectorThumbprint(string name) @@ -63,7 +73,8 @@ public void CertificateHasTheVectorThumbprint(string name) Assert.Equal(thumbprint, AgentIdentityProtocol.ComputeThumbprint(certificate)); } - // CONTRACT.agent-identity.md §5.4: servers accept request bodies up to 64 KiB. + // Servers accept request bodies up to 64 KiB. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors [Fact] public void MaxRequestBodyBytesIs64KiB() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs index efb9b2d716..5fc85bf0a2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs @@ -55,6 +55,10 @@ public class StrictBase64Tests { "-_8", [0xFB, 0xFF] }, }; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: spec RFC 4648 §10 + // oracle: spec RFC 4648 §4 + // oracle: spec RFC 4648 §5 [Theory] [MemberData(nameof(CanonicalForms))] public void DecodesTheCanonicalForm(string encoding, string encoded, byte[] expected) @@ -65,6 +69,10 @@ public void DecodesTheCanonicalForm(string encoding, string encoded, byte[] expe // Each row is a form that RFC 4648 §4, or §5 without padding, does not produce for any byte string, // so accepting it would give one value two encodings. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: spec RFC 4648 §4 + // oracle: spec RFC 4648 §5 [Theory] [InlineData("base64", "missing padding", "Zg")] [InlineData("base64", "missing one padding character", "Zm9vYg=")] @@ -92,6 +100,8 @@ public void RejectsANonCanonicalForm(string encoding, string form, string encode } // RFC 4648 §10 vectors with the number of bytes they encode; padding characters encode none. + // oracle: spec RFC 4648 §10 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions [Theory] [InlineData("base64", "", 0)] [InlineData("base64", "Zg==", 1)] @@ -108,6 +118,8 @@ public void AcceptsTheExactDecodedLength(string encoding, string encoded, int le } // RFC 4648 §10 gives the length of each vector, which differs from the length each row requires. + // oracle: spec RFC 4648 §10 + // oracle: mutant the decoder accepts a value whose decoded length differs from the length the caller requires [Theory] [InlineData("base64", "", 1)] [InlineData("base64", "Zg==", 0)] @@ -127,6 +139,9 @@ public void RejectsAnotherDecodedLength(string encoding, string encoded, int len } // CONTRACT.agent-identity.md Definitions: base64url is RFC 4648 §5 without padding. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: spec RFC 4648 §5 + // oracle: spec RFC 4648 §10 [Theory] [MemberData(nameof(Base64UrlForms))] public void Base64UrlEncodesWithoutPadding(string expected, byte[] value) @@ -137,6 +152,9 @@ public void Base64UrlEncodesWithoutPadding(string expected, byte[] value) // The BCL encoder writes canonical base64, // and RFC 4648 §5 derives base64url from it by swapping the last two characters and dropping the padding. // Random values of every length up to 96 bytes end in each final-quantum shape many times. + // oracle: spec RFC 4648 §4 + // oracle: spec RFC 4648 §5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions [Fact] public void DecodesAndEncodesEveryLengthLikeTheBcl() { @@ -157,8 +175,12 @@ public void DecodesAndEncodesEveryLengthLikeTheBcl() } } - // RFC 4648 §3.5: only the final quantum has pad bits, which a canonical encoding sets to zero, + // Only the final quantum has pad bits, which a canonical encoding sets to zero, // so trying every final quantum of one or two bytes checks that each value has exactly one accepted encoding. + // oracle: spec RFC 4648 §3.5 + // oracle: spec RFC 4648 §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions [Theory] [InlineData("base64")] [InlineData("base64url")] From ca9fddf788a75269057b407c51bb77aecc056a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 19:08:04 +0900 Subject: [PATCH 56/93] test(dotnet-agent-identity): drop assertions that no source decides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete or narrow the tests, rows and assertions whose expected value neither the contract, the library intent nor a cited specification decides, so that a refactor which keeps the agent-facing protocol is not held back by them. The leaf certificate tests keep the profile of contract §4 and stop pinning the signature hash, criticality, key identifier and clock rounding choices it leaves open. Metadata and trust-anchor tests compare entries without order, the channel tests stop pinning the challenge bytes, the absent correlation_id and the number of registry reads, and helpers stop checking the empty members of a result object. The constructor rows of the channel result types stay as mutant guards, since only they catch a public constructor of either type. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/AuthenticationTypeTests.cs | 4 +- .../RequestAuthenticatorTests.cs | 6 -- .../Authentication/StructuredFieldTests.cs | 4 +- .../Bodies/AgentMetadataTests.cs | 15 ++--- .../Bodies/IdentityConfigTests.cs | 26 --------- .../Bodies/RequestBodyTests.cs | 17 ++---- .../Bodies/ResponseBodiesTests.cs | 14 +++-- .../CertificateSigningRequestTests.cs | 4 -- .../IdentityCertificateIssuerTests.cs | 57 +++---------------- .../Certificates/RenewalRulesTests.cs | 4 -- .../Channel/ChannelHandshakeTests.cs | 30 +++++----- .../Channel/ChannelMessagesTests.cs | 5 +- .../Channel/ChannelOpeningTests.cs | 15 ++--- .../Enrollment/FriendlyNameFormatTests.cs | 19 +------ .../Errors/AgentIdentityErrorTests.cs | 7 +-- .../Protocol/AgentIdentityProtocolTests.cs | 3 - 16 files changed, 53 insertions(+), 177 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs index a863fc0db5..f8911df8b2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs @@ -21,9 +21,9 @@ public class AuthenticationTypeTests typeof(ChannelOpening), // oracle: mutant a public AuthenticationResult constructor typeof(AuthenticationResult), - // oracle: scaffold + // oracle: mutant a public ChannelOpeningResult constructor typeof(ChannelOpeningResult), - // oracle: scaffold + // oracle: mutant a public ChannelHandshakeResult constructor typeof(ChannelHandshakeResult), ]; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs index f5eeec854d..87ea891a89 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs @@ -136,10 +136,6 @@ public async Task BodyOverTheLimitIsRejectedWith413First(string request) Assert.False(result.Succeeded); Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); Assert.Equal(413, result.Error.HttpStatusCode); - // oracle: scaffold - Assert.Empty(authority.Registry.Lookups); - // oracle: scaffold - Assert.Empty(authority.Nonces.Calls); } // Servers accept request bodies up to 64 KiB. @@ -426,8 +422,6 @@ public async Task AuthenticateAsyncRefusesAnOperationThatIsNotASignedHttpRequest await Assert.ThrowsAsync( () => authority.Authenticator.AuthenticateAsync(operation, request).AsTask()); - // oracle: scaffold - Assert.Empty(authority.Nonces.Calls); } // The registry answers by thumbprint, so another certificate is a host bug, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs index d6cb296242..6c43bc4811 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs @@ -136,9 +136,7 @@ public void ParsesADictionary(string input, string expected) [InlineData("sig=?")] public void RejectsAMalformedDictionary(string input) { - Assert.False(TryParseDictionary(input, out IReadOnlyList? dictionary)); - // oracle: scaffold - Assert.Null(dictionary); + Assert.False(TryParseDictionary(input, out _)); } // A string is quoted, and a backslash precedes each quote or backslash in it. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs index 65c790d851..b6e2605ac2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs @@ -14,10 +14,10 @@ public void AcceptsNoEntries() Assert.Empty(metadata); } - // AgentMetadata documents that enumeration follows the order in which the agent sent the entries. - // oracle: scaffold + // Metadata is a JSON object of string to string, and keeps each entry received. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] - public void KeepsEveryEntryInTheOrderReceived() + public void KeepsEveryEntryReceived() { KeyValuePair[] entries = [ @@ -28,7 +28,9 @@ public void KeepsEveryEntryInTheOrderReceived() AgentMetadata metadata = AssertValid(AgentMetadata.Validate(entries)); - Assert.Equal(entries, metadata); + Assert.Equal( + entries.OrderBy(entry => entry.Key, StringComparer.Ordinal), + metadata.OrderBy(entry => entry.Key, StringComparer.Ordinal)); Assert.Equal("host", metadata["hostname"]); } @@ -249,9 +251,6 @@ public void ThrowsForNullKeyOrValue(string? key, string? value) private static AgentMetadata AssertValid(MetadataValidationResult result) { Assert.True(result.Succeeded, result.FailureDetail); - // oracle: scaffold - Assert.Null(result.Error); - Assert.Null(result.FailureDetail); return result.Metadata; } @@ -264,8 +263,6 @@ private static void AssertInvalid(MetadataValidationResult result, string? form Assert.Equal(400, result.Error.HttpStatusCode); // oracle: mutant an empty failure detail for a repeated key or for metadata over 16 KiB Assert.False(string.IsNullOrEmpty(result.FailureDetail)); - // oracle: scaffold - Assert.Null(result.Metadata); } private static IEnumerable> NumberedEntries(int count) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index 3c262be77c..f7a6c6cbf0 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -158,18 +158,8 @@ public void CreateAcceptsASnakeCaseProductFieldName(string name) [InlineData("psu-channel")] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("psu channel")] - // oracle: scaffold - [InlineData("_psu")] - // oracle: scaffold - [InlineData("psu_")] - // oracle: scaffold - [InlineData("psu__channel")] - // oracle: scaffold - [InlineData("2fa")] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("psu\n")] - // oracle: scaffold - [InlineData("caf\u00E9")] public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) { AssertRejectsProductFields([new(name, TestJson.Parse("true"))]); @@ -207,8 +197,6 @@ public void CreateRejectsAProductFieldWithoutAName() // RFC 8259 §8.1: JSON text exchanged between systems is UTF-8, which cannot encode an unpaired surrogate. // Failing here keeps every later serialization of the config from failing. [Theory] - // oracle: scaffold - [InlineData("""["ok", "\ud800"]""")] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("""{"\ud800": 1}""")] public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value) @@ -301,20 +289,6 @@ public void ConfigOutlivesTheDocumentOfItsProductFields() Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), written)); } - // A config is immutable, and the response bodies and ConfigUpdate messages write its JSON as it was created. - // oracle: scaffold - [Fact] - public void ChangingTheReturnedJsonLeavesTheConfigUnchanged() - { - IdentityConfig config = IdentityConfig.Create(3, "https://host/dvls"); - byte[] first = config.ToUtf8Json(); - byte[] original = [.. first]; - - first.AsSpan().Fill((byte)' '); - - Assert.Equal(original, config.ToUtf8Json()); - } - private static void AssertRejectsProductFields(KeyValuePair[] productFields) { ArgumentException exception = Assert.Throws( diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index d935d1d5fe..911b5ee52c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -60,16 +60,15 @@ public void AcceptsUnknownMembers(string extraMembers) AssertValid(ParseText(Body(ValidMetadata, extraMembers))); } - // An escape stands for its character; AgentMetadata keeps the order in which the entries were sent. + // An escape stands for its character. // oracle: spec RFC 8259 §7 - // oracle: scaffold [Fact] - public void ReturnsTheMetadataUnescapedInTheOrderSent() + public void ReturnsTheMetadataUnescaped() { RequestBodyOutcome outcome = ParseText(Body("""{"os_name": "Windows", "host\u006eame": "h\u00e9"}""", "")); - KeyValuePair[] expected = [new("os_name", "Windows"), new("hostname", "hé")]; + KeyValuePair[] expected = [new("hostname", "hé"), new("os_name", "Windows")]; - Assert.Equal(expected, AssertValid(outcome).Metadata!); + Assert.Equal(expected, AssertValid(outcome).Metadata!.OrderBy(entry => entry.Key, StringComparer.Ordinal)); } // Metadata may be an empty object. @@ -291,9 +290,6 @@ protected RequestBodyOutcome ParseText(string json) protected static RequestBodyOutcome AssertValid(RequestBodyOutcome outcome) { Assert.True(outcome.Succeeded, outcome.FailureDetail); - // oracle: scaffold - Assert.Null(outcome.Error); - Assert.Null(outcome.FailureDetail); // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 Assert.NotNull(outcome.Metadata); @@ -313,9 +309,6 @@ protected static void AssertInvalid(RequestBodyOutcome outcome, string? form = n // more than 32 metadata keys, a key outside the pattern, or a value with a control character // or over 1024 UTF-8 bytes Assert.False(string.IsNullOrEmpty(outcome.FailureDetail)); - // oracle: scaffold - Assert.Null(outcome.Metadata); - Assert.Null(outcome.Csr); } private static string NestedArrays(int depth) @@ -490,8 +483,6 @@ private static string CanonicalCsr(int octetsInFinalQuantum) private void AssertOnlyCanonicalCsrIsValid(string canonical, string altered) { - // oracle: scaffold - Assert.NotEqual(canonical, altered); // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 AssertValid(ParseText(BodyWithCsr(Quote(canonical), ValidMetadata))); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs index db75df1c5a..2ff83d3c37 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs @@ -99,18 +99,20 @@ public void TrustAnchorGivesTheValidityOfTheRootInUtc() // The response lists one root normally and two during a rotation. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 - // oracle: scaffold [Fact] - public void TrustAnchorListsEveryRootInTheOrderGiven() + public void TrustAnchorListsEveryRoot() { using X509Certificate2 newRoot = SelfSignedRoot(Now.AddDays(-1), Now.AddYears(10)); byte[] body = ResponseBodies.TrustAnchor([VectorRoot, newRoot]); - JsonElement[] roots = Roots(body); - Assert.Equal( - [VectorRoot.RawData, newRoot.RawData], - roots.Select(root => TestJson.CanonicalBase64(TestJson.Members(root)["certificate"]))); + byte[][] listed = + [ + .. Roots(body).Select(root => TestJson.CanonicalBase64(TestJson.Members(root)["certificate"])), + ]; + Assert.Equal(2, listed.Length); + Assert.Contains(VectorRoot.RawData, listed); + Assert.Contains(newRoot.RawData, listed); } // The response always lists a root, and two during a rotation. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index ed213aec91..357e898393 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -594,8 +594,6 @@ private static CertificateSigningRequest AssertValid(CsrValidationResult result) { Assert.True(result.Succeeded, result.FailureDetail); Assert.Null(result.Error); - // oracle: scaffold - Assert.Null(result.FailureDetail); return result.Csr; } @@ -611,8 +609,6 @@ private static void AssertInvalid(CsrValidationResult result) // oracle: mutant an empty failure detail for a CSR that is followed by other bytes, // that has a version other than 0, or whose key is not an id-ecPublicKey key Assert.False(string.IsNullOrEmpty(result.FailureDetail)); - // oracle: scaffold - Assert.Null(result.Csr); } // The valid vector, checked to be accepted, so that only the rule a test breaks in it can reject the result. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 623dfb7f9c..9f1fb97c9c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -109,8 +109,6 @@ public void ExtendedKeyUsageIsClientAuthenticationOnly() Assert.IsType(leaf.Extensions[ExtendedKeyUsageOid]); Assert.Equal([ClientAuth], extension.EnhancedKeyUsages.Cast().Select(oid => oid.Value)); - // oracle: scaffold - Assert.False(extension.Critical); } // CONTRACT.agent-identity.md §4: KeyUsage digitalSignature; RFC 5280 §4.2.1.3: marked critical. @@ -139,26 +137,18 @@ public void AuthorityKeyIdentifierIsTheSubjectKeyIdentifierOfTheIssuer() Assert.IsType(_root.Extensions[SubjectKeyIdentifierOid]); Assert.Equal(issuer.SubjectKeyIdentifierBytes.ToArray(), authority.KeyIdentifier?.ToArray()); - // oracle: scaffold - Assert.Null(authority.NamedIssuer); - // oracle: scaffold - Assert.Null(authority.SerialNumber); Assert.False(authority.Critical); } - // Method (1): the SHA-1 hash of the BIT STRING subjectPublicKey, in a non-critical extension. + // A subject key identifier, in a non-critical extension. // oracle: spec RFC 5280 §4.2.1.2 [Fact] - public void SubjectKeyIdentifierIsTheSha1OfTheKey() + public void SubjectKeyIdentifierIsNotCritical() { using X509Certificate2 leaf = Issue(); X509SubjectKeyIdentifierExtension extension = Assert.IsType(leaf.Extensions[SubjectKeyIdentifierOid]); - byte[] subjectPublicKey = TestCsr.UncompressedPoint(_agentKey); - - // oracle: scaffold - Assert.Equal(SHA1.HashData(subjectPublicKey), extension.SubjectKeyIdentifierBytes.ToArray()); Assert.False(extension.Critical); } @@ -185,16 +175,6 @@ public void HasExactlyTheProfileExtensions() Assert.Equal(3, leaf.Version); } - // RFC 5758 §3.2: the issuer's generator signs with SHA-256, so ecdsa-with-SHA256 for a P-256 root. - // oracle: scaffold - [Fact] - public void IsSignedWithSha256() - { - using X509Certificate2 leaf = Issue(); - - Assert.Equal("1.2.840.10045.4.3.2", leaf.SignatureAlgorithm.Value); - } - // A positive INTEGER of at most 20 octets. // The serial number is 16 octets from the injected generator with the top bit cleared. // oracle: spec RFC 5280 §4.1.2.2 @@ -235,31 +215,14 @@ public void SerialNumberIsDrawnAgainWhenItWouldBeZero() Assert.Equal(second, SerialNumber(leaf)); } - // RFC 5280 §4.1.2.5.1 and §4.1.2.5.2: certificate times are whole seconds, - // and a notBefore rounded up would issue a certificate that is not yet valid. - // oracle: scaffold + // The lifetime is a server setting. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 [Fact] - public void NotBeforeIsTheClockTruncatedToTheSecond() + public void NotAfterIsNotBeforePlusTheLifetime() { - using X509Certificate2 leaf = Issue(); - - Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); - } - - // CONTRACT.agent-identity.md §4: the lifetime is a server setting. - // RFC 5280 §4.1.2.5: a certificate holds whole seconds, so a fraction of a second in the lifetime is dropped, - // since rounding it up would exceed the lifetime. - // oracle: scaffold - [Theory] - [InlineData(0)] - [InlineData(500)] - public void NotAfterIsNotBeforePlusTheWholeSecondsOfTheLifetime(int lifetimeMilliseconds) - { - TimeSpan lifetime = TimeSpan.FromDays(30) + TimeSpan.FromMilliseconds(lifetimeMilliseconds); - - using X509Certificate2 leaf = Issue(lifetime: lifetime); + using X509Certificate2 leaf = Issue(lifetime: TimeSpan.FromDays(30)); - Assert.Equal(NowInSeconds.AddDays(30).UtcDateTime, leaf.NotAfter.ToUniversalTime()); + Assert.Equal(TimeSpan.FromDays(30), leaf.NotAfter.ToUniversalTime() - leaf.NotBefore.ToUniversalTime()); } // The lifetime is capped at the issuing root's notAfter. @@ -290,9 +253,7 @@ public void ReadsTheClockOnce() { using X509Certificate2 leaf = Issue(clock: new AdvancingTimeProvider(Now, TimeSpan.FromHours(1))); - // oracle: scaffold - Assert.Equal(NowInSeconds.UtcDateTime, leaf.NotBefore.ToUniversalTime()); - Assert.Equal((NowInSeconds + Lifetime).UtcDateTime, leaf.NotAfter.ToUniversalTime()); + Assert.Equal(Lifetime, leaf.NotAfter.ToUniversalTime() - leaf.NotBefore.ToUniversalTime()); } // The certificate chains to the root that issued it, for client authentication. @@ -534,8 +495,6 @@ public override void GetBytes(byte[] data) public override void GetBytes(Span data) { byte[] draw = draws[_next++]; - // oracle: scaffold - Assert.Equal(draw.Length, data.Length); draw.CopyTo(data); } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs index d04eaad9bb..ee42996dc1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs @@ -52,8 +52,6 @@ public async Task ReturnsThePendingCertificateForThePendingKeyOfTheIdentity(bool Assert.Equal(RenewalOutcome.ReturnPendingCertificate, decision.Outcome); Assert.Null(decision.Error); - // oracle: scaffold - Assert.Null(decision.FailureDetail); } // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 @@ -86,7 +84,5 @@ public async Task IssuesAPendingCertificateForAKeyNeverIssued() Assert.Equal(RenewalOutcome.IssuePendingCertificate, decision.Outcome); Assert.Null(decision.Error); - // oracle: scaffold - Assert.Null(decision.FailureDetail); } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index 27a0aebec9..49af82a08f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -313,25 +313,17 @@ public async Task EarlierFailureWins(string earlier, string later) AssertFailed(result, expectedCode, expectedStatus); } - // Two failures that both close the stream with signature_invalid differ only in their failure detail, - // so the detail shows that the earlier one closed it. - // CONTRACT.agent-identity.md §7.3 step 4 fixes no failure detail, - // so the expected one is the handshake's own for the earlier failure alone. - // oracle: scaffold + // Two failures that both close the stream with signature_invalid close it with signature_invalid together. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Theory] [MemberData(nameof(PairsOfFailuresWithTheSameCode))] - public async Task EarlierOfTwoSignatureFailuresWins(string earlier, string later) + public async Task TwoSignatureFailuresAreSignatureInvalid(string earlier, string later) { (_, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[earlier]; - ChannelHandshakeResult earlierAlone = await HandshakeAsync(Scenarios[earlier]); - ChannelHandshakeResult laterAlone = await HandshakeAsync(Scenarios[later]); ChannelHandshakeResult result = await HandshakeAsync(FailurePairs[(earlier, later)]); - Assert.NotEqual(laterAlone.FailureDetail, earlierAlone.FailureDetail); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 AssertFailed(result, expectedCode, expectedStatus); - Assert.Equal(earlierAlone.FailureDetail, result.FailureDetail); } // The shared intent, security invariant 5: revocation blocks authentication, including of a stream whose identity @@ -407,20 +399,24 @@ public async Task RevocationWinsOverARetiredCertificate() AssertFailed(result, AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied); } - // Whatever the outcome, the handshake reads the registry once, for the certificate that opened the stream. // CONTRACT.agent-identity.md §7.4 applies a deletion or a revocation since the opening to a stream awaiting its - // Hello, so the handshake reads the registry again; that it reads it exactly once is the library's API. - // oracle: scaffold + // Hello, so whatever the outcome, the handshake reads the registry again + // for the certificate that opened the stream. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Theory] [MemberData(nameof(ScenarioNames))] - public async Task HandshakeReadsTheRegistryOnce(string scenario) + public async Task HandshakeReadsTheRegistryForTheOpeningCertificate(string scenario) { + VectorAuthority openingOnly = new(); + await VectorChannel.OpenAsync(openingOnly); VectorAuthority authority = new(); await HandshakeAsync(Scenarios[scenario], authority); - // The lookup of the opening, then the one of the handshake. - Assert.Equal([DeviceA.Thumbprint, DeviceA.Thumbprint], authority.Registry.Lookups); + // The lookups of the opening, then those of the handshake. + string[] handshakeLookups = [.. authority.Registry.Lookups.Skip(openingOnly.Registry.Lookups.Count)]; + Assert.NotEmpty(handshakeLookups); + Assert.All(handshakeLookups, thumbprint => Assert.Equal(DeviceA.Thumbprint, thumbprint)); } // The registry answers a thumbprint with the certificate issued under it, for its one identity, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs index e08ad58f76..cb81cc51b7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs @@ -30,18 +30,15 @@ public class ChannelMessagesTests public static TheoryData MessageNames => new(Messages.Keys); // CONTRACT.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. - // A push answers no agent message, so it has no correlation_id. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Theory] [MemberData(nameof(MessageNames))] - public void MessageHasALowercaseUuidIdAndNoCorrelationId(string message) + public void MessageHasALowercaseUuidId(string message) { ServerMessage built = Messages[message](); Assert.Matches(TestJson.LowercaseUuidPattern, built.Id); - // oracle: scaffold - Assert.False(built.HasCorrelationId); } // Steps 5 and 7: the agent acknowledges a message by its id, so no two messages share one. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index 151a9b4082..09073505e8 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -18,11 +18,10 @@ public class ChannelOpeningTests // CONTRACT.agent-identity.md §7.3 step 2: the first message is Challenge, // with 32 random bytes fresh for this stream. // CONTRACT.agent-identity.md §1 and §7.3: ids are lowercase hyphenated UUIDs. - // The bytes come from the injected generator, the host's source of every secret byte. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [Fact] - public async Task ChallengeHolds32BytesFromTheInjectedGenerator() + public async Task ChallengeHolds32Bytes() { TestAuthority authority = new(TestAuthority.DefaultNow); @@ -30,11 +29,8 @@ public async Task ChallengeHolds32BytesFromTheInjectedGenerator() ServerMessage challenge = opening.Challenge; Assert.Equal(ServerMessage.PayloadOneofCase.Challenge, challenge.PayloadCase); - // oracle: scaffold - Assert.Equal(Enumerable.Range(0, 32).Select(i => (byte)i), challenge.Challenge.Challenge_.ToByteArray()); + Assert.Equal(32, challenge.Challenge.Challenge_.Length); Assert.Matches(UuidPattern, challenge.Id); - // oracle: scaffold - Assert.False(challenge.HasCorrelationId); } // Step 2: the challenge holds 32 random bytes fresh for this stream. @@ -50,9 +46,10 @@ public async Task EachOpeningHasItsOwnChallenge() // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.NotEqual(first.Challenge.Id, second.Challenge.Id); - // oracle: scaffold - Assert.Equal( - Enumerable.Range(32, 32).Select(i => (byte)i), + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + Assert.Equal(32, second.Challenge.Challenge.Challenge_.Length); + Assert.NotEqual( + first.Challenge.Challenge.Challenge_.ToByteArray(), second.Challenge.Challenge.Challenge_.ToByteArray()); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs index 6290d00a11..cb061dba33 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs @@ -108,11 +108,7 @@ public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expe [InlineData("{0}")] public void TryParseRejectsAnUnpairedBraceOrAnUnknownPlaceholder(string format) { - Assert.False(FriendlyNameFormat.TryParse(format, out FriendlyNameFormat? parsed, out string? error)); - // oracle: scaffold - Assert.Null(parsed); - // oracle: scaffold - Assert.False(string.IsNullOrWhiteSpace(error)); + Assert.False(FriendlyNameFormat.TryParse(format, out _, out _)); } // The error says where the problem is, counting Unicode scalar values from 1, @@ -313,19 +309,6 @@ public void FallsBackToTheIdentityIdForAnEmptyName(string format, string tokenNa Assert.Equal("0f8fad5b-d9cb-469f-a165-70867728950e", Evaluate(format, metadata, tokenName)); } - // A lone surrogate is no Unicode scalar value, so it counts as one U+FFFD REPLACEMENT CHARACTER, - // which Unicode §3.9 recommends for an ill-formed code unit, and the name stays well-formed UTF-16. - // Validated metadata holds no lone surrogate, but a token name or the format text can. - // oracle: scaffold - [Fact] - public void ReplacesALoneSurrogateWithTheReplacementCharacter() - { - Assert.Equal("\uFFFDpc", Evaluate("{token_name}", tokenName: "\uD83Dpc")); - Assert.Equal("pc\uFFFD", Evaluate("{token_name}", tokenName: "pc\uDE00")); - Assert.Equal("\uFFFD\uFFFD", Evaluate("{token_name}", tokenName: "\uDE00\uD83D")); - Assert.Equal("pc\uFFFD", Evaluate("pc\uD83D", tokenName: string.Empty)); - } - // The default format is `{hostname}`. // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Fact] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs index 1db927f20d..45bbfd9bb6 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs @@ -5,10 +5,9 @@ namespace Devolutions.AgentIdentity.Tests; public class AgentIdentityErrorTests { - // 12:00:00.750 UTC given in a +02:00 offset, - // which CONTRACT.agent-identity.md §1 writes as a UTC timestamp in whole seconds. - private static readonly DateTimeOffset ServerTime = - new DateTimeOffset(2026, 9, 24, 14, 0, 0, TimeSpan.FromHours(2)).AddMilliseconds(750); + // 12:00:00 UTC given in a +02:00 offset, + // which CONTRACT.agent-identity.md §1 writes as a UTC timestamp. + private static readonly DateTimeOffset ServerTime = new(2026, 9, 24, 14, 0, 0, TimeSpan.FromHours(2)); private const string ServerTimeText = "2026-09-24T12:00:00Z"; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs index be0090caaf..f7b9a792f0 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs @@ -16,7 +16,6 @@ .. TestVectors.Instance.HttpSignature.Registered.Select( ]; // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. - // A timestamp names the whole second that contains the instant. [Theory] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("2026-09-24T12:00:00+00:00", "2026-09-24T12:00:00Z")] @@ -24,8 +23,6 @@ .. TestVectors.Instance.HttpSignature.Registered.Select( [InlineData("2026-09-24T14:00:00+02:00", "2026-09-24T12:00:00Z")] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 [InlineData("2026-09-24T23:30:00-01:00", "2026-09-25T00:30:00Z")] - // oracle: scaffold - [InlineData("2026-09-24T12:00:00.9999999+00:00", "2026-09-24T12:00:00Z")] public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected) { DateTimeOffset value = DateTimeOffset.Parse(instant, CultureInfo.InvariantCulture); From d51eba0d3e19750ae23d25c92a5b501038468310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 18:41:23 +0900 Subject: [PATCH 57/93] feat(dotnet-agent-identity): reject a non-empty confirm body A confirm request carries no body: the contract makes a non-empty confirm body invalid_request with no state change, and an authentication error still wins over it. ConfirmRequestBody.Parse accepts only a body of zero bytes and rejects any other, white space included, with invalid_request and a failure detail that repeats nothing from the body. Hosts authenticate, then parse, then act, as for the other request bodies. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Bodies/ConfirmRequestBodyTests.cs | 53 +++++++++++++++++++ .../ConstructionTests.cs | 1 + .../Bodies/ConfirmRequestBody.cs | 30 +++++++++++ .../Bodies/RequestBodyParseResult.cs | 3 +- dotnet/AgentIdentity/README.md | 5 +- 5 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ConfirmRequestBody.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs new file mode 100644 index 0000000000..1dedce84ea --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs @@ -0,0 +1,53 @@ +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +public class ConfirmRequestBodyTests +{ + // A token for https://host/dvls whose secret encodes the bytes 00 to 1F (CONTRACT.agent-identity.md §2). + private const string Bag = "eyJ1IjoiaHR0cHM6Ly9ob3N0L2R2bHMifQ"; + private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + + // A confirm request carries no body. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.5 + [Fact] + public void AcceptsEmptyBody() + { + RequestBodyParseResult result = ConfirmRequestBody.Parse([]); + + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Null(result.Error); + } + + // A body holding only white space, a byte order mark, an empty JSON object or a NUL byte is not empty. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.5 + [Theory] + [InlineData("empty JSON object", "{}")] + [InlineData("JSON null", "null")] + [InlineData("space", " ")] + [InlineData("line break", "\r\n")] + [InlineData("byte order mark", "\uFEFF")] + [InlineData("NUL byte", "\0")] + public void RejectsNonEmptyBody(string form, string body) + { + RequestBodyParseResult result = ConfirmRequestBody.Parse(Encoding.UTF8.GetBytes(body)); + + Assert.False(result.Succeeded, form); + Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); + } + + // Invariant 9: tokens never appear in logs, not even partially, + // and a host logs the failure detail and the error of a rejected body, whatever the body holds. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants + [Fact] + public void FailureRevealsNoPartOfATokenInTheBody() + { + byte[] body = Encoding.UTF8.GetBytes("dvaet1." + Bag + "." + Secret); + + RequestBodyParseResult result = ConfirmRequestBody.Parse(body); + + Assert.False(result.Succeeded); + EnrollmentTokenAssert.RevealsNoPart(result.FailureDetail, Bag, Secret); + EnrollmentTokenAssert.RevealsNoPart(result.Error.Message, Bag, Secret); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs index 5c2a328b4a..81676fd9ff 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs @@ -13,6 +13,7 @@ public class ConstructionTests typeof(EnrollRequestBody), typeof(RenewRequestBody), typeof(CheckInRequestBody), + typeof(ConfirmRequestBody), ]; public static TheoryData ValidatedTypes => new(Validated); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ConfirmRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ConfirmRequestBody.cs new file mode 100644 index 0000000000..6b4bac0d08 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ConfirmRequestBody.cs @@ -0,0 +1,30 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of a confirm request, which is empty. +/// +public sealed class ConfirmRequestBody +{ + private ConfirmRequestBody() + { + } + + /// + /// Checks that the body of a confirm request is empty. + /// + /// + /// Any byte, white space included, makes the body invalid. + /// Authenticate the request first and act on it only once the body is valid, + /// so that an authentication error wins over an invalid body and an invalid body changes no state. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan body) + { + if (!body.IsEmpty) + { + return RequestBodyParseResult.Failure("The body is not empty."); + } + + return RequestBodyParseResult.Success(new ConfirmRequestBody()); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs index 756e1c939e..07b26032db 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs @@ -6,7 +6,8 @@ namespace Devolutions.AgentIdentity; /// The outcome of parsing a request body: the validated body, or the error that rejects it. /// /// -/// The body type: , or . +/// The body type: , , +/// or . /// public sealed class RequestBodyParseResult where TBody : class diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 41448d87e2..a4098eb280 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -8,7 +8,7 @@ The contract is `docs/agent-identity/CONTRACT.agent-identity.md` in the [devolut - Enrollment tokens: `EnrollmentToken.Create` creates a token, `FriendlyNameFormat` parses and evaluates its friendly-name format, and `EnrollmentAuthorization.ReadBearerToken` reads it from an `enroll` request. The host stores the token's `SecretHash`, never its `Value`, which it shows once and never logs. - Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.agent-identity.md [§6]). -- Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`). +- Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`), and `ConfirmRequestBody` checks that a `confirm` body is empty. - Decisions: `EnrollmentRules.Decide` decides an enrollment and `RenewalRules.Decide` a renewal, given the issued certificate that holds the CSR key. - Certificates: `IdentityCertificateIssuer.Issue` issues the certificate of a new identity, or of the identity that a renew request proved. - Responses: `IdentityConfig` holds an identity's config, and `ResponseBodies` writes the bodies of the `trust-anchor`, `enroll`, `renew` and `check-in` responses. @@ -72,7 +72,7 @@ Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normall 1. Build a `SignedHttpRequest` from the method, the `Signature-Input`, `Signature` and `Content-Digest` field lines, and the body bytes, all as received. 2. Pass it to `RequestAuthenticator.AuthenticateAsync` with `AgentOperation.Renew`, `AgentOperation.Confirm` or `AgentOperation.CheckIn`. It answers a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge` before it checks the signature. -3. Parse a `renew` body with `RenewRequestBody.Parse` and a `check-in` body with `CheckInRequestBody.Parse`; a `confirm` body is empty. +3. Parse a `renew` body with `RenewRequestBody.Parse`, a `confirm` body with `ConfirmRequestBody.Parse` and a `check-in` body with `CheckInRequestBody.Parse`, and answer an invalid body with the `Error` of the parse result. Each then acts for the result's `Identity` only. @@ -88,6 +88,7 @@ Each then acts for the result's `Identity` only. ### `confirm` +Parse the body with `ConfirmRequestBody.Parse`, and answer a non-empty body with the result's `Error` before changing any state (CONTRACT.agent-identity.md [§5.5]). When `Identity.CertificateStatus` is `CertificateStatus.Pending`, make the certificate current, retire the previous current certificate, and clear the request-renewal flag if it was set before the certificate was issued (CONTRACT.agent-identity.md [§8.2]). Then send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` on each stream that the retired certificate opened, and close those streams with `OK` 60 s later (CONTRACT.agent-identity.md [§7.4]). Answer `204`, also when the certificate was already current (CONTRACT.agent-identity.md [§5.5]). From 21323730985d20f4897fe93af7905d7fc15bc149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 19:50:42 +0900 Subject: [PATCH 58/93] fix(dotnet-agent-identity): keep metadata keys out of failure details A failure detail meant for host logs never contains a value taken from the request, so that an agent can neither write into an authority's logs nor leak replayable material through them. Metadata failure details now name an entry by its position, as the key pattern failure already did, and never quote a key, even a valid one, nor give the code point of a control character: a repeated key reads "Metadata key 3 repeats an earlier key." and a control character "The value of metadata key 3 holds a control character.". The request bodies and the channel handshake pass these details on unchanged. Error codes and statuses do not change. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Bodies/AgentMetadataTests.cs | 63 +++++++++++++++++-- .../Bodies/RequestBodyTests.cs | 34 ++++++++++ .../Channel/ChannelHandshakeTests.cs | 36 +++++++++++ .../Bodies/AgentMetadata.cs | 11 ++-- .../Bodies/MetadataValidationResult.cs | 2 +- .../Bodies/RequestBodyParseResult.cs | 3 +- .../Channel/ChannelHandshakeResult.cs | 2 +- dotnet/AgentIdentity/README.md | 2 +- 8 files changed, 140 insertions(+), 13 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs index b6e2605ac2..1ac99b3916 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs @@ -4,6 +4,31 @@ namespace Devolutions.AgentIdentity.Tests; public class AgentMetadataTests { + // Text that the entry where the metadata fails holds in its key and in its value. + private const string LeakedKey = "leaked_key"; + private const string LeakedValue = "leaked value"; + + // Metadata that breaks each rule of CONTRACT.agent-identity.md §1 and §3, by name, + // at an entry whose key holds LeakedKey and whose value holds LeakedValue. + private static readonly Dictionary[]> Failures = new() + { + ["33 keys"] = [.. Enumerable.Range(0, 33).Select(i => Entry($"{LeakedKey}_{i}", LeakedValue))], + ["key outside the pattern"] = [Entry($"{LeakedKey}\n", LeakedValue)], + ["repeated key"] = [Entry(LeakedKey, "first"), Entry(LeakedKey, LeakedValue)], + ["value with a control character"] = [Entry(LeakedKey, $"{LeakedValue}\u0007")], + ["value that is not Unicode"] = [Entry(LeakedKey, $"{LeakedValue}\uD800")], + ["value of 1025 UTF-8 bytes"] = [Entry(LeakedKey, LeakedValue + new string('a', 1025 - LeakedValue.Length))], + + // 17 entries of 13 + 1000 bytes: the first 16 hold 16208 bytes, and all 17 hold 17221, past 16 KiB. + ["object over 16 KiB"] = + [ + .. Enumerable.Range(0, 17) + .Select(i => Entry($"{LeakedKey}_{i:D2}", LeakedValue + new string('a', 1000 - LeakedValue.Length))), + ], + }; + + public static TheoryData FailureNames => new(Failures.Keys); + // Metadata may be an empty object. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] @@ -225,16 +250,39 @@ public void RejectsRepeatedKey() AssertInvalid(AgentMetadata.Validate([new("hostname", "a"), new("hostname", "b")])); } - // The failure detail goes to host logs, so it holds no metadata key, such as an invalid one with a line break. + // The failure detail goes to host logs, so it holds no key or value of the metadata, valid or not, + // since an agent could otherwise write into the authority's logs. // oracle: intent dotnet/AgentIdentity/INTENT.md §Failure details - [Fact] - public void FailureDetailDoesNotQuoteAnInvalidKey() + [Theory] + [MemberData(nameof(FailureNames))] + public void FailureDetailHoldsNoKeyOrValue(string failure) + { + MetadataValidationResult result = AgentMetadata.Validate(Failures[failure]); + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: spec RFC 3629 §3 + AssertInvalid(result, failure); + Assert.DoesNotContain(LeakedKey, result.FailureDetail); + Assert.DoesNotContain(LeakedValue, result.FailureDetail); + } + + // The control character is part of the value, so the detail holds neither the character + // nor its code point in a hexadecimal rendering such as U+0007, 0x07, 0x0007 or 0007. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Failure details + [Theory] + [MemberData(nameof(ControlCharacters))] + public void FailureDetailDoesNotNameTheControlCharacter(int codePoint) { - MetadataValidationResult result = AgentMetadata.Validate([new("host\nforged log line", "value")]); + MetadataValidationResult result = AgentMetadata.Validate([Entry("hostname", $"host{(char)codePoint}name")]); + string[] renderings = [$"U+{codePoint:X4}", $"0x{codePoint:X2}", $"0x{codePoint:X4}", $"{codePoint:X4}"]; // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 AssertInvalid(result); - Assert.DoesNotContain("forged log line", result.FailureDetail); + Assert.DoesNotContain(((char)codePoint).ToString(), result.FailureDetail, StringComparison.Ordinal); + Assert.All( + renderings, + rendering => Assert.DoesNotContain(rendering, result.FailureDetail, StringComparison.OrdinalIgnoreCase)); } // AgentMetadata.Validate documents an ArgumentException for an entry with a null key or value. @@ -265,6 +313,11 @@ private static void AssertInvalid(MetadataValidationResult result, string? form Assert.False(string.IsNullOrEmpty(result.FailureDetail)); } + private static KeyValuePair Entry(string key, string value) + { + return new KeyValuePair(key, value); + } + private static IEnumerable> NumberedEntries(int count) { return Enumerable.Range(0, count).Select(i => new KeyValuePair($"key_{i}", "value")); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index 911b5ee52c..259f749bfb 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -22,6 +22,10 @@ public abstract class RequestBodyTests { protected const string ValidMetadata = """{"hostname": "host"}"""; + // Text that the metadata entry where a body fails holds in its key and in its value. + private const string LeakedKey = "leaked_key"; + private const string LeakedValue = "leaked value"; + /// /// A valid body of the type under test, with the given metadata member, omitted when null, /// and the given members after the others. @@ -135,6 +139,36 @@ public void RejectsMetadataBeyondTheLimits(string form, string metadata) AssertInvalid(ParseText(Body(metadata, "")), form); } + // Metadata that fails at an entry whose key holds LeakedKey and whose value holds LeakedValue, by name. + public static TheoryData MetadataFailingAtAnEntry => new() + { + { "key outside the pattern", $$"""{"{{LeakedKey}}\n": "{{LeakedValue}}"}""" }, + { "repeated key", $$"""{"{{LeakedKey}}": "first", "{{LeakedKey}}": "{{LeakedValue}}"}""" }, + { "value with a control character", $$"""{"{{LeakedKey}}": "{{LeakedValue}}\u0007"}""" }, + { "value that is not Unicode", $$"""{"{{LeakedKey}}": "{{LeakedValue}}\ud800"}""" }, + { + "value of 1025 UTF-8 bytes", + $$"""{"{{LeakedKey}}": "{{LeakedValue + new string('a', 1025 - LeakedValue.Length)}}"}""" + }, + { "value that is not a string", $$"""{"{{LeakedKey}}": ["{{LeakedValue}}"]}""" }, + }; + + // The failure detail goes to host logs, so it holds no key or value of the metadata, valid or not, + // since an agent could otherwise write into the authority's logs. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Failure details + [Theory] + [MemberData(nameof(MetadataFailingAtAnEntry))] + public void FailureDetailHoldsNoMetadataKeyOrValue(string form, string metadata) + { + RequestBodyOutcome outcome = ParseText(Body(metadata, "")); + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + AssertInvalid(outcome, form); + Assert.DoesNotContain(LeakedKey, outcome.FailureDetail); + Assert.DoesNotContain(LeakedValue, outcome.FailureDetail); + } + // A body that repeats a member name at any depth, such as one metadata key twice, // is invalid, whether the names are written alike or only unescape alike. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index 49af82a08f..cba9825033 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -15,6 +15,19 @@ public class ChannelHandshakeTests { private const string UuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + // Text that the metadata entry where a Hello fails holds in its key and in its value. + private const string LeakedKey = "leaked_key"; + private const string LeakedValue = "leaked value"; + + // Metadata entries that CONTRACT.agent-identity.md §3 rejects, by name, + // each with a key holding LeakedKey and a value holding LeakedValue. + private static readonly Dictionary> InvalidMetadataEntries = new() + { + ["a key outside the pattern"] = new($"{LeakedKey}\n", LeakedValue), + ["a value with a control character"] = new(LeakedKey, $"{LeakedValue}\u0007"), + ["a value of 1025 UTF-8 bytes"] = new(LeakedKey, LeakedValue + new string('a', 1025 - LeakedValue.Length)), + }; + // Messages that break exactly one rule of §7.3 step 4 that closes the stream with signature_invalid. private static readonly Dictionary> SignatureFailures = new() { @@ -100,6 +113,8 @@ private static readonly Dictionary SignatureFailureNames => new(SignatureFailures.Keys); + public static TheoryData InvalidMetadataEntryNames => new(InvalidMetadataEntries.Keys); + public static TheoryData ScenarioNames => new(Scenarios.Keys); // No Hello in time, alone and after each closure. @@ -289,6 +304,27 @@ public async Task HelloWithInvalidMetadataIsInvalidRequest() AssertFailed(result, AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument); } + // The failure detail goes to host logs, so it holds no key or value of the metadata, valid or not, + // since an agent could otherwise write into the authority's logs. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Failure details + [Theory] + [MemberData(nameof(InvalidMetadataEntryNames))] + public async Task FailureDetailHoldsNoMetadataKeyOrValue(string entry) + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + AgentMessage hello = VectorChannel.ValidHelloMessage(opening); + (string key, string value) = InvalidMetadataEntries[entry]; + hello.Hello.Metadata.Add(key, value); + + ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + AssertFailed(result, AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument); + Assert.DoesNotContain(LeakedKey, result.FailureDetail); + Assert.DoesNotContain(LeakedValue, result.FailureDetail); + } + // The handshake checks its failures in an order, and a stream that fails two of them closes as it would for the // earlier one alone. // CONTRACT.agent-identity.md §6 step 4 and §7.4: the registry and the clock decide before the message, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs index f1e7e25858..950884bcea 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs @@ -69,20 +69,23 @@ public static MetadataValidationResult Validate(IEnumerable= 0x7F and <= 0x9F)) { - return $"holds the control character U+{rune.Value:X4}"; + return "holds a control character"; } utf8Length += rune.Utf8SequenceLength; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs index 214a7225be..dfdeb1bad7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs @@ -39,7 +39,7 @@ private MetadataValidationResult(AgentMetadata? metadata, AgentIdentityError? er /// Why the metadata is invalid, for the host's logs, when is false. /// /// - /// The detail quotes a key only when the key itself is valid, so it never repeats arbitrary agent input. + /// The detail never repeats a key or value of the metadata: it names an entry by its position. /// public string? FailureDetail { get; } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs index 07b26032db..6309ab876e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs @@ -44,7 +44,8 @@ private RequestBodyParseResult(TBody? body, AgentIdentityError? error, string? f /// Why the body is invalid, for the host's logs, when is false. /// /// - /// The detail never repeats a value from the body, and quotes a metadata key only when the key itself is valid. + /// The detail never repeats a value from the body, not even a metadata key: + /// it names a metadata entry by its position. /// public string? FailureDetail { get; } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs index 84d0f0d677..edf2f62453 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs @@ -81,7 +81,7 @@ private ChannelHandshakeResult( /// The rule that the handshake failed on, for host logs, when the handshake failed. /// /// - /// It never repeats a value from the message, and quotes a metadata key only when the key itself is valid. + /// It never repeats a value from the message, not even a metadata key: it names a metadata entry by its position. /// public string? FailureDetail { get; } diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index a4098eb280..6fa9de09b9 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -135,7 +135,7 @@ The library writes no headers, so the host adds `Retry-After` to `ServiceUnavail Over gRPC, end the call by throwing `ToRpcException(serverTime)`, which carries the status of `ToGrpcStatusCode` and the trailers `error-code` and `server-time` (CONTRACT.agent-identity.md [§7.4]). For a closure that the host decides, such as a revocation, build the error with `AgentIdentityError.From`. -Log `FailureDetail` beside the error: it names the rule that the request broke and never holds a token or a secret. +Log `FailureDetail` beside the error: it names the rule that the request broke and never holds a value taken from the request, such as a metadata key or value, a header, signature data or a token. ## Authenticated identities From 4a42ad1f056e3288fa504ab89f07afe4f5e7fae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 21:23:21 +0900 Subject: [PATCH 59/93] test(dotnet-agent-identity): narrow assertions to what their sources decide Each assertion now checks only what the source in its marker decides. Trailers and Hello metadata compare without order. The serial number tests separate the positive INTEGER of at most 20 octets that RFC 5280 requires from the library's choice to clear the top bit of the octets drawn. A Hello after the handshake ended is checked for yielding no identity rather than for the exception type of the call. The Welcome server_time is checked against a clock that stands still, and `unavailable` is checked against the 5xx class the error table gives it. A product base URL not in the form of `u` may now be rejected, but a token created from it must hold the form its source decides: no trailing slash, no surrounding white space, and percent-encoding for a non-ASCII character. Other presence checks and URL normalizations that no source decides are removed or narrowed, rows that cannot fail are deleted, and the list of members that produce an authenticated identity is marked as the mutant guard it is. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/AuthenticationTypeTests.cs | 8 +- .../RequestAuthenticatorVectorTests.cs | 1 - .../Bodies/IdentityConfigTests.cs | 18 ++- .../IdentityCertificateIssuerTests.cs | 47 ++++--- .../Channel/ChannelHandshakeTests.cs | 115 ++++++++---------- .../Enrollment/EnrollmentTokenTests.cs | 69 ++++++++--- .../Errors/AgentIdentityErrorCodeTests.cs | 21 +++- 7 files changed, 153 insertions(+), 126 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs index f8911df8b2..86b182dc6c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs @@ -44,10 +44,12 @@ public void TypeHasNoPublicOrProtectedConstructor(Type type) Assert.Empty(reachable); } - // Any other public member producing one of these types would be a factory that bypasses the signature check. - // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + // A new public member producing one of these types could be a factory that bypasses the signature check. + // The intent forbids creating an identity outside that check but not forwarding one that it created, + // so the list of members allowed to produce one is the library's own. + // oracle: mutant a public member that builds an AuthenticatedIdentity outside the signature check [Fact] - public void OnlyAuthenticationProducesTheseTypes() + public void NoOtherPublicMemberProducesTheseTypes() { const BindingFlags declared = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs index 32636f79d2..6c20a0d821 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs @@ -132,7 +132,6 @@ public async Task FailingCaseDetailHoldsNoRequestData(string name) IEnumerable values = headers .SelectMany(header => Regex.Matches(header, "(?:keyid|nonce)=\"([^\"]+)\"|=:([^:]+):")) .Select(match => match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value); - Assert.NotNull(outcome.FailureDetail); Assert.All(headers.Concat(values), value => Assert.DoesNotContain(value, outcome.FailureDetail)); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index f7a6c6cbf0..5f99ff568b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -93,21 +93,19 @@ public void CreateRejectsAnotherAgentChannelUrl(string agentChannelUrl) Assert.Equal("agentChannelUrl", exception.ParamName); } - // The agent prepends the URL path to `/devolutions.agent.channel.v1.AgentChannel/Connect`, - // so a URL ending in a slash would put two slashes in the gRPC path. + // `agent_channel_url` is an absolute `https` URL without a query or fragment, which the config carries as the host + // gave it. + // Whether a URL ending in a slash is rejected or written without the slash is the library's choice. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.2 [Theory] - [InlineData("https://host/dvls", "https://host/dvls")] - [InlineData("https://host/dvls/", "https://host/dvls")] - [InlineData("https://host/", "https://host")] - [InlineData("https://host", "https://host")] - public void AgentChannelUrlEndsWithoutASlash(string agentChannelUrl, string expected) + [InlineData("https://host/dvls")] + [InlineData("https://host")] + public void AgentChannelUrlIsWrittenAsGiven(string agentChannelUrl) { IdentityConfig config = IdentityConfig.Create(1, agentChannelUrl); - Assert.Equal(expected, TestJson.Members(config.ToUtf8Json())["agent_channel_url"].GetString()); - Assert.Equal(expected, config.AgentChannelUrl); + Assert.Equal(agentChannelUrl, TestJson.Members(config.ToUtf8Json())["agent_channel_url"].GetString()); + Assert.Equal(agentChannelUrl, config.AgentChannelUrl); } // Product-specific config fields, such as a PSU channel URL, may coexist with the others. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 9f1fb97c9c..1a3aadb153 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -31,6 +31,17 @@ public sealed class IdentityCertificateIssuerTests : IDisposable private static readonly TimeSpan Lifetime = TimeSpan.FromDays(30); + // What the injected generator draws for a serial number, by name: + // all ones, which as drawn would encode a negative INTEGER, + // and the top bit alone, which is zero once that bit is cleared, followed by a second draw. + private static readonly Dictionary SerialNumberDraws = new() + { + ["all ones"] = [[.. Enumerable.Repeat((byte)0xFF, 16)]], + ["the top bit alone, then all 01"] = [[0x80, .. new byte[15]], [.. Enumerable.Repeat((byte)0x01, 16)]], + }; + + public static TheoryData SerialNumberDrawNames => new(SerialNumberDraws.Keys); + private readonly ECDsa _rootKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); private readonly ECDsa _agentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); private readonly RandomNumberGenerator _random = RandomNumberGenerator.Create(); @@ -175,44 +186,32 @@ public void HasExactlyTheProfileExtensions() Assert.Equal(3, leaf.Version); } - // A positive INTEGER of at most 20 octets. - // The serial number is 16 octets from the injected generator with the top bit cleared. + // A positive INTEGER, so never zero, of at most 20 octets, whatever the injected generator draws. // oracle: spec RFC 5280 §4.1.2.2 - [Fact] - public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() + [Theory] + [MemberData(nameof(SerialNumberDrawNames))] + public void SerialNumberIsAPositiveIntegerOfAtMost20Octets(string draws) { - byte[] draw = [.. Enumerable.Repeat((byte)0xFF, 16)]; - - using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(draw)); + using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(SerialNumberDraws[draws])); byte[] serialNumber = SerialNumber(leaf); - // Only this test and SerialNumberIsDrawnAgainWhenItWouldBeZero catch this fault. - // RFC 5280 allows any positive encoding, and .NET writes a set top bit with a leading zero octet, - // so only the library chooses to clear the top bit of the 16 octets drawn. - // oracle: mutant a serial number whose first octet keeps its top bit while its other bits are kept, - // set or flipped - Assert.Equal([0x7F, .. draw[1..]], serialNumber); Assert.True(new BigInteger(serialNumber, isUnsigned: false, isBigEndian: true) > 0); Assert.InRange(serialNumber.Length, 1, 20); } - // RFC 5280 §4.1.2.2: the serial number is positive, so never zero. - // Clearing the top bit of 80 00 … 00 leaves zero, which is not a positive serial number. - // Only this test and SerialNumberIsTheRandomOctetsWithTheTopBitCleared catch this fault. + // The serial number is the 16 octets drawn with their top bit cleared. + // RFC 5280 allows any positive encoding, and .NET writes a set top bit with a leading zero octet, + // so only the library chooses to clear the top bit of the 16 octets drawn. // oracle: mutant a serial number whose first octet keeps its top bit while its other bits are kept, // set or flipped [Fact] - public void SerialNumberIsDrawnAgainWhenItWouldBeZero() + public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() { - byte[] topBitOnly = new byte[16]; - topBitOnly[0] = 0x80; - byte[] second = [.. Enumerable.Repeat((byte)0x01, 16)]; + byte[] draw = [.. Enumerable.Repeat((byte)0xFF, 16)]; - using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(topBitOnly, second)); + using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(draw)); - // That 80 00 … 00 becomes zero, and that the next draw becomes the serial number as drawn, - // follow from the library's own way of making the 16 octets positive. - Assert.Equal(second, SerialNumber(leaf)); + Assert.Equal([0x7F, .. draw[1..]], SerialNumber(leaf)); } // The lifetime is a server setting. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index cba9825033..1688f694a3 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -148,22 +148,8 @@ public static TheoryData WrongRegistryAnswersAndCalls public static TheoryData PairsOfFailuresWithTheSameCode => FailurePairNames((earlier, later) => earlier.Code == later.Code); - // Every way to end a handshake, followed by each of the two calls a host makes. - public static TheoryData HandshakeEndsThenAnotherCall - { - get - { - TheoryData cases = new(); - - foreach (string first in HandshakeEnds.Keys) - { - cases.Add(first, "a valid Hello"); - cases.Add(first, "no Hello in time"); - } - - return cases; - } - } + // Every way to end a handshake. + public static TheoryData HandshakeEndNames => new(HandshakeEnds.Keys); private static byte[] ValidProof => TestVectors.Instance.ChannelProof.Case("valid").Proof; @@ -365,7 +351,7 @@ public async Task TwoSignatureFailuresAreSignatureInvalid(string earlier, string // The shared intent, security invariant 5: revocation blocks authentication, including of a stream whose identity // the host cannot know yet. // CONTRACT.agent-identity.md §7.4: PERMISSION_DENIED - // with the trailers `error-code: identity_revoked` and `server-time`. + // with the trailers `error-code: identity_revoked` and `server-time`, in no stated order. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() @@ -379,7 +365,9 @@ public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.Equal( [("error-code", "identity_revoked"), ("server-time", "2026-09-24T12:00:00Z")], - error.ToRpcException(serverTime).Trailers.Select(entry => (entry.Key, entry.Value))); + error.ToRpcException(serverTime).Trailers + .Select(entry => (entry.Key, entry.Value)) + .OrderBy(entry => entry.Key, StringComparer.Ordinal)); } // Step 4: a certificate that is no longer registered, or whose identity was deleted, is identity_unknown. @@ -472,16 +460,13 @@ public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string await Assert.ThrowsAsync(async () => await HandshakeEnds[call](opening)); } - // The handshake ends when VerifyHelloAsync is called, not when the registry answers, - // so a Hello timer that fires during the lookup cannot end the handshake a second time. // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. - // Only the library's API decides that the call, not the answer, ends the handshake, - // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call - // that the object's state does not allow. + // A Hello timer that fires while the registry answers the Hello comes after the Hello, + // so it does not fail the Hello. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] - public async Task HandshakeEndsBeforeTheRegistryAnswers() + public async Task HelloDecidesTheStreamWhenTheTimeoutFiresDuringItsLookup() { VectorAuthority authority = new(); ChannelOpening opening = await VectorChannel.OpenAsync(authority); @@ -491,25 +476,20 @@ public async Task HandshakeEndsBeforeTheRegistryAnswers() opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.False(verifying.IsCompleted); - ValueTask timingOut = opening.HelloTimedOutAsync(); + _ = opening.HelloTimedOutAsync(); answer.SetResult(); - // oracle: spec Framework Design Guidelines §Using Standard Exception Types - await Assert.ThrowsAsync(() => timingOut.AsTask()); Assert.True((await verifying).Succeeded); } - // The timeout ends the handshake when HelloTimedOutAsync is called too, - // so a Hello that arrives during its lookup cannot authenticate a stream that is being closed. // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both, // and closes it for the timeout with signature_invalid. - // Only the library's API decides that the call, not the answer, ends the handshake, - // and that a later call throws InvalidOperationException, which the Framework Design Guidelines give a call - // that the object's state does not allow. + // A Hello that arrives while the registry answers the timeout comes after it, + // so it cannot authenticate a stream that is being closed. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Fact] - public async Task TimeoutEndsTheHandshakeBeforeTheRegistryAnswers() + public async Task TimeoutDecidesTheStreamWhenAHelloArrivesDuringItsLookup() { VectorAuthority authority = new(); ChannelOpening opening = await VectorChannel.OpenAsync(authority); @@ -522,8 +502,7 @@ public async Task TimeoutEndsTheHandshakeBeforeTheRegistryAnswers() opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); answer.SetResult(); - // oracle: spec Framework Design Guidelines §Using Standard Exception Types - await Assert.ThrowsAsync(() => verifying.AsTask()); + await AssertYieldsNoIdentityAsync(() => verifying); AssertFailed(await timingOut, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); } @@ -572,11 +551,10 @@ public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() Assert.Equal(registered.NotAfter, identity.CertificateNotAfter); // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures Assert.Equal(AgentOperation.Connect, identity.Operation); - Assert.Null(result.Error); - Assert.Null(result.FailureDetail); } // The server replaces the identity's metadata with the metadata of each authenticated Hello. + // Metadata is an object of string to string, whose entries have no order. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 [Fact] public async Task ValidHelloYieldsItsMetadata() @@ -586,7 +564,9 @@ public async Task ValidHelloYieldsItsMetadata() ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); - Assert.Equal(VectorChannel.ValidMetadata, result.Metadata); + Assert.Equal( + VectorChannel.ValidMetadata.OrderBy(entry => entry.Key, StringComparer.Ordinal), + result.Metadata.OrderBy(entry => entry.Key, StringComparer.Ordinal)); } // Metadata may be an empty object. @@ -629,25 +609,23 @@ public async Task WelcomeAnswersTheHello() Assert.Equal(hello.Id, welcome.CorrelationId); } - // Welcome carries server_time. - // The clock reads the time of the opening twice, to judge the request and to start the Hello timeout, - // then the time of the handshake, then a later time. - // The Welcome carries the time at which the server answers, from that one reading. + // Step 4: the server replies Welcome when it verifies the Hello, and the schema's Welcome carries server_time. + // The clock stands still at an instant after the opening, with a fraction of a second, + // so whenever the handshake reads it, that instant is the server's time. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] - public async Task WelcomeCarriesTheServerTimeOfTheHandshake() + public async Task WelcomeCarriesTheServerTime() { - DateTimeOffset opened = VectorChannel.OpeningTime; - DateTimeOffset answered = opened.AddMilliseconds(3_250); - ChannelOpening opening = await VectorChannel.OpenAsync( - new SteppingTimeProvider(opened, opened, answered, answered.AddDays(1).AddMilliseconds(500))); + FixedTimeProvider clock = new(VectorChannel.OpeningTime); + ChannelOpening opening = await VectorChannel.OpenAsync(clock); + DateTimeOffset answered = VectorChannel.OpeningTime.AddMilliseconds(3_250); + clock.Now = answered; ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); - Timestamp serverTime = result.Welcome.Welcome.ServerTime; - Assert.Equal(answered.ToUnixTimeSeconds(), serverTime.Seconds); - Assert.Equal(250_000_000, serverTime.Nanos); + Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime); } // Step 3: the Hello answers the Challenge id, with a proof over the challenge bytes. @@ -733,7 +711,6 @@ public void HelloTimeoutIs10Seconds() } // Step 4: the Hello must arrive within 10 s of the opening, 10 s included. - // The Welcome shows that the clock reading that judged the timeout is the one at 10 s. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 [Fact] public async Task HelloExactlyAtTheTimeoutSucceeds() @@ -746,8 +723,6 @@ public async Task HelloExactlyAtTheTimeoutSucceeds() ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); Assert.True(result.Succeeded, result.FailureDetail); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 - Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime); } // Step 4: no Hello within 10 s closes the stream with signature_invalid. @@ -786,23 +761,22 @@ public async Task HelloTimeoutRunsFromTheEndOfTheOpening() // The first agent message decides the stream: a second one could retry a failed proof, // and a late Hello could authenticate a stream that already timed out. - // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. - // Only the library's API decides that a later call throws InvalidOperationException, - // which the Framework Design Guidelines give a call that the object's state does not allow. + // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both, + // so a valid Hello after the handshake ended yields no identity, whether the call throws or returns. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] - [MemberData(nameof(HandshakeEndsThenAnotherCall))] - public async Task HandshakeEndsAtTheFirstCall(string first, string second) + [MemberData(nameof(HandshakeEndNames))] + public async Task ValidHelloAfterTheHandshakeEndedYieldsNoIdentity(string end) { ChannelOpening opening = await VectorChannel.OpenAsync(); - await HandshakeEnds[first](opening); + await HandshakeEnds[end](opening); - await Assert.ThrowsAsync(async () => await HandshakeEnds[second](opening)); + await AssertYieldsNoIdentityAsync(() => opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening))); } // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure // and the trailers `error-code: <§5.4 code>` and `server-time: `, which the agent reads. + // The contract states no order for the trailers. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Theory] @@ -825,7 +799,9 @@ public async Task FailureClosesTheStreamWithTheContractStatusAndTrailers( // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 Assert.Equal( [("error-code", expectedCode), ("server-time", "2026-09-24T12:00:00Z")], - exception.Trailers.Select(entry => (entry.Key, entry.Value))); + exception.Trailers + .Select(entry => (entry.Key, entry.Value)) + .OrderBy(entry => entry.Key, StringComparer.Ordinal)); } // Asserts that the handshake failed with the code and the gRPC status, and returns its error. @@ -841,14 +817,19 @@ private static AgentIdentityError AssertFailed( // a Hello without a correlation_id or correlated to another id, a proof that does not verify, // no Hello before the timeout, a certificate no longer registered, or a revoked identity Assert.False(string.IsNullOrEmpty(result.FailureDetail)); - // No server push or metadata update happens before step 4 succeeds, and only then is the stream authenticated. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - Assert.Null(result.Identity); - Assert.Null(result.Metadata); - Assert.Null(result.Welcome); return result.Error; } + // Asserts that the call yields no identity, whether it throws or returns. + private static async Task AssertYieldsNoIdentityAsync(Func> call) + { + ChannelHandshakeResult? result = null; + + _ = await Record.ExceptionAsync(async () => result = await call()); + + Assert.Null(result?.Identity); + } + private static AgentMessage Altered(AgentMessage message, Action alteration) { alteration(message); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index 0c9d3e3719..e916ee2843 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -88,33 +88,42 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() } } - // `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL. + // `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL, + // so a URL already in the form of `u` is written as given. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [Theory] - [InlineData("https://host/dvls", "https://host/dvls")] - // The agent appends `/api/agent-identity/v1/...` to `u` (CONTRACT.agent-identity.md §5), - // so `u` ends without a slash. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5 + [InlineData("https://host/dvls")] + [InlineData("https://host")] + public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl) + { + EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + + // The bag of a token the library creates holds `u` alone. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Enrollment tokens + Assert.Equal(("u", productBaseUrl), Assert.Single(BagMembers(token))); + } + + // Create may reject a URL that is not in the form of `u`, + // but a token it creates holds in `u` the form that the source of the row decides. + [Theory] + // `u` has no trailing slash, and the agent removes trailing slashes, so `https://host/` names `https://host`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [InlineData("https://host/dvls/", "https://host/dvls")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [InlineData("https://host/dvls//", "https://host/dvls")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token [InlineData("https://host/", "https://host")] - [InlineData("https://host", "https://host")] - // A URI holds no white space (RFC 3986 §2): - // white space around it is not part of it (Appendix C), - // and a space or a non-ASCII character inside it is percent-encoded (§2.1; RFC 3987 §3.1). + // White space around a URI is not part of it. // oracle: spec RFC 3986 §Appendix C [InlineData(" https://host/dvls\t", "https://host/dvls")] - // oracle: spec RFC 3986 §2.1 - [InlineData("https://host/my dvls", "https://host/my%20dvls")] + // A non-ASCII character becomes its UTF-8 octets, percent-encoded with uppercase hexadecimal digits. // oracle: spec RFC 3987 §3.1 [InlineData("https://host/dvls\u00E9", "https://host/dvls%C3%A9")] - public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl, string expected) + public void CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, string normalized) { - EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + Creation creation = CreateFrom(productBaseUrl); - // The bag of a token the library creates holds `u` alone. - // oracle: intent dotnet/AgentIdentity/INTENT.md §Enrollment tokens - Assert.Equal(("u", expected), Assert.Single(BagMembers(token))); + Assert.Contains(creation, (Creation[])[Creation.Holding(normalized), Creation.Rejection]); } // CONTRACT.agent-identity.md §2: `u` is an absolute `https` URL without a query or fragment. @@ -221,6 +230,23 @@ private static string UrlOfLength(int length) return prefix + new string('a', length - prefix.Length); } + // What Create makes of the URL: the `u` that the bag of its token holds, or a rejection. + private static Creation CreateFrom(string productBaseUrl) + { + EnrollmentToken token; + + try + { + token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + } + catch (ArgumentException) + { + return Creation.Rejection; + } + + return Creation.Holding(Assert.Single(BagMembers(token), member => member.Name == "u").Value); + } + // Reads the members of the bag, after checking that the bag is the canonical base64url encoding of its bytes. private static (string Name, string? Value)[] BagMembers(EnrollmentToken token) { @@ -236,6 +262,17 @@ private static (string Name, string? Value)[] BagMembers(EnrollmentToken token) return [.. document.RootElement.EnumerateObject().Select(member => (member.Name, member.Value.GetString()))]; } + // What Create makes of a URL: a rejection, or a token whose bag holds `U`. + private sealed record Creation(bool Rejected, string? U) + { + public static Creation Rejection { get; } = new(Rejected: true, U: null); + + public static Creation Holding(string? u) + { + return new Creation(Rejected: false, U: u); + } + } + /// /// A generator that returns the given bytes in order, so that a test knows the secret it produces. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index 3ab52e081d..aab95d8820 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -27,8 +27,7 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec Assert.Equal(expected, code.ToWireValue()); } - // CONTRACT.agent-identity.md §5.4, column "HTTP". - // The table gives `unavailable` 5xx, and an unexpected failure gets 500. + // CONTRACT.agent-identity.md §5.4, column "HTTP", for the codes to which it gives one status. public static TheoryData HttpStatusCodes => new() { // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors @@ -49,9 +48,14 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec { AgentIdentityErrorCode.ClockSkew, 401 }, // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors { AgentIdentityErrorCode.InvalidRequest, 400 }, - // An unexpected server failure → `500 unavailable`. + }; + + // CONTRACT.agent-identity.md §5.4, column "HTTP", for the codes to which it gives a class of statuses, + // with the lowest and the highest status of the class: `unavailable` is 5xx. + public static TheoryData HttpStatusClasses => new() + { // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors - { AgentIdentityErrorCode.Unavailable, 500 }, + { AgentIdentityErrorCode.Unavailable, 500, 599 }, }; [Theory] @@ -61,6 +65,13 @@ public void HttpStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, int e Assert.Equal(expected, code.ToHttpStatusCode()); } + [Theory] + [MemberData(nameof(HttpStatusClasses))] + public void HttpStatusCodeIsInTheContractClass(AgentIdentityErrorCode code, int lowest, int highest) + { + Assert.InRange(code.ToHttpStatusCode(), lowest, highest); + } + // CONTRACT.agent-identity.md §7.4: an authentication failure closes the stream with UNAUTHENTICATED, // or PERMISSION_DENIED for `identity_revoked`. // §7.3 step 4: invalid `Hello` metadata closes it with INVALID_ARGUMENT and `invalid_request`. @@ -105,7 +116,7 @@ public void EveryCodeHasAContractRow() AgentIdentityErrorCode[] defined = Enum.GetValues(); Assert.Equal(defined, CodesOf(WireValues)); - Assert.Equal(defined, CodesOf(HttpStatusCodes)); + Assert.Equal(defined, CodesOf(HttpStatusCodes).Concat(CodesOf(HttpStatusClasses)).Order()); Assert.Equal(defined, CodesOf(GrpcStatusCodes)); } From f9c5b7b353cc1e89ef2d03250ff180630def8369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 21 Oct 2026 23:58:27 +0900 Subject: [PATCH 60/93] feat(dotnet-agent-identity): end the handshake on an undecodable first message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACT.agent-identity.md §7.3 step 4 counts a first agent message that cannot be decoded as a message that is not a Hello, since no proof can be read from it, so the server closes the stream with UNAUTHENTICATED and error-code signature_invalid. The transport can fail to decode the first AgentMessage before any message exists, for example when a protobuf string holds invalid UTF-8, so the host has no message to pass to VerifyHelloAsync. It calls ChannelOpening.FirstMessageUnreadableAsync instead. Like HelloTimedOutAsync, the call reads the clock, then the registry, once each, fails with identity_unknown, identity_revoked or certificate_expired when one of those closures applies, and otherwise fails with signature_invalid. It takes the same single-use slot as the other two calls: whichever is called first ends the handshake, and a later call throws. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/AuthenticationTypeTests.cs | 1 + .../Channel/ChannelHandshakeTests.cs | 95 +++++++++++++++---- .../Channel/ChannelOpening.cs | 59 ++++++++++-- dotnet/AgentIdentity/README.md | 3 +- 4 files changed, 128 insertions(+), 30 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs index 86b182dc6c..cd49d18736 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs @@ -69,6 +69,7 @@ public void NoOtherPublicMemberProducesTheseTypes() [ "AuthenticationResult.get_Identity", "ChannelHandshakeResult.get_Identity", + "ChannelOpening.FirstMessageUnreadableAsync", "ChannelOpening.HelloTimedOutAsync", "ChannelOpening.VerifyHelloAsync", "ChannelOpeningResult.get_Opening", diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index 1688f694a3..643ae389ef 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -101,6 +101,7 @@ private static readonly (string Earlier, string Later)[] MessageFailurePairs = ["a Hello with invalid metadata"] = opening => opening.VerifyHelloAsync( Altered(VectorChannel.ValidHelloMessage(opening), LaterFailures["a Hello with invalid metadata"])), ["no Hello in time"] = opening => opening.HelloTimedOutAsync(), + ["the first message could not be decoded"] = opening => opening.FirstMessageUnreadableAsync(), }; // The calls that end the handshake of a stream that OpenBeforeNotAfterAsync opens. @@ -109,6 +110,7 @@ private static readonly Dictionary opening.VerifyHelloAsync(hello), ["no Hello in time"] = (opening, _) => opening.HelloTimedOutAsync(), + ["the first message could not be decoded"] = (opening, _) => opening.FirstMessageUnreadableAsync(), }; public static TheoryData SignatureFailureNames => new(SignatureFailures.Keys); @@ -117,11 +119,12 @@ private static readonly Dictionary ScenarioNames => new(Scenarios.Keys); - // No Hello in time, alone and after each closure. + // Each end with no Hello, alone and after each closure. public static TheoryData NoHelloNames => - new(Closures.Keys.Select(WithoutHello).Prepend("no Hello in time")); + new(NoHelloEnds.Keys.SelectMany( + end => Closures.Keys.Select(closure => WithClosure(end, closure)).Prepend(end))); - // Each wrong answer of the registry, to each of the two calls that read it. + // Each wrong answer of the registry, to each of the calls that read it. public static TheoryData WrongRegistryAnswersAndCalls { get @@ -132,6 +135,7 @@ public static TheoryData WrongRegistryAnswersAndCalls { cases.Add(answer, "a valid Hello"); cases.Add(answer, "no Hello in time"); + cases.Add(answer, "the first message could not be decoded"); } return cases; @@ -206,20 +210,30 @@ public static TheoryData WrongRegistryAnswersAndCalls StatusCode.InvalidArgument))) .ToDictionary(); - // No first message arrives, and the host ends the handshake for the timeout. - private static Scenario NoHello => new() { FirstMessage = null }; + // The ways a handshake ends with no Hello to verify, by name. + private static Dictionary NoHelloEnds => new() + { + // No first message arrives, and the host ends the handshake for the timeout. + ["no Hello in time"] = new() { FirstMessage = null }, + + // Step 4: a first message that cannot be decoded is not a Hello, since no proof can be read from it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + ["the first message could not be decoded"] = new() { FirstMessageUnreadable = true }, + }; // Every failure by name: each early failure, each message failure, - // and the timeout, alone with signature_invalid (§7.3 step 4) and after each closure, which §7.4 applies first. + // and each end with no Hello, alone with signature_invalid (§7.3 step 4) and after each closure, + // which §7.4 applies first. private static Dictionary Failures => EarlyFailures .Concat(MessageFailures) - .Append(KeyValuePair.Create( - "no Hello in time", - new Failure(NoHello, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated))) - .Concat(Closures.Select(closure => KeyValuePair.Create( - WithoutHello(closure.Key), - closure.Value with { Scenario = closure.Value.Scenario.And(NoHello) }))) + .Concat(NoHelloEnds.SelectMany(end => Closures + .Select(closure => KeyValuePair.Create( + WithClosure(end.Key, closure.Key), + closure.Value with { Scenario = closure.Value.Scenario.And(end.Value) })) + .Prepend(KeyValuePair.Create( + end.Key, + new Failure(end.Value, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated))))) .ToDictionary(); // Every scenario by name: a valid handshake and each failure. @@ -231,7 +245,7 @@ public static TheoryData WrongRegistryAnswersAndCalls // Each pair of failures that the handshake checks one after the other, by name, with the scenario failing both: // a message breaking two rules of §7.3 step 4, a failure decided before the message with each failure checked - // after it, and a closure of a stream without Hello with each closure checked after it and with the timeout. + // after it, and for each end with no Hello, a closure with each closure checked after it and with that end alone. private static Dictionary<(string Earlier, string Later), Scenario> FailurePairs { get @@ -245,7 +259,12 @@ public static TheoryData WrongRegistryAnswersAndCalls }); AddOrderedPairs(pairs, [.. EarlyFailures.Keys], MessageFailures.Keys); - AddOrderedPairs(pairs, [.. Closures.Keys.Select(WithoutHello)], ["no Hello in time"]); + + foreach (string end in NoHelloEnds.Keys) + { + AddOrderedPairs(pairs, [.. Closures.Keys.Select(closure => WithClosure(end, closure))], [end]); + } + return pairs; } } @@ -672,6 +691,10 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() [InlineData("no Hello in time", 0, AgentIdentityErrorCode.SignatureInvalid)] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData("no Hello in time", 1, AgentIdentityErrorCode.CertificateExpired)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + [InlineData("the first message could not be decoded", 0, AgentIdentityErrorCode.SignatureInvalid)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + [InlineData("the first message could not be decoded", 1, AgentIdentityErrorCode.CertificateExpired)] public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( string end, long ticksAfterNotAfter, @@ -685,7 +708,8 @@ public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( AssertFailed(result, expectedCode, StatusCode.Unauthenticated); } - // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid. + // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid, + // and so does a first message that cannot be decoded, which is not a Hello. // CONTRACT.agent-identity.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the // failures of §7.3 step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures @@ -693,7 +717,7 @@ public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [Theory] [MemberData(nameof(NoHelloNames))] - public async Task NoHelloInTimeIsSignatureInvalidUnlessAClosureApplies(string noHello) + public async Task NoHelloIsSignatureInvalidUnlessAClosureApplies(string noHello) { (Scenario scenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[noHello]; @@ -774,6 +798,23 @@ public async Task ValidHelloAfterTheHandshakeEndedYieldsNoIdentity(string end) await AssertYieldsNoIdentityAsync(() => opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening))); } + // The first call that ends the handshake takes its single-use slot, + // so a later call to FirstMessageUnreadableAsync refuses instead of returning a result. + // oracle: mutant a call to FirstMessageUnreadableAsync after the handshake ended that returns a result + // instead of refusing + [Theory] + [MemberData(nameof(HandshakeEndNames))] + public async Task FirstMessageUnreadableAfterTheHandshakeEndedYieldsNoResult(string end) + { + ChannelOpening opening = await VectorChannel.OpenAsync(); + await HandshakeEnds[end](opening); + ChannelHandshakeResult? result = null; + + _ = await Record.ExceptionAsync(async () => result = await opening.FirstMessageUnreadableAsync()); + + Assert.Null(result); + } + // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure // and the trailers `error-code: <§5.4 code>` and `server-time: `, which the agent reads. // The contract states no order for the trailers. @@ -783,6 +824,7 @@ public async Task ValidHelloAfterTheHandshakeEndedYieldsNoIdentity(string end) [InlineData("a Hello with the proof of another device", StatusCode.Unauthenticated, "signature_invalid")] [InlineData("a Hello with invalid metadata", StatusCode.InvalidArgument, "invalid_request")] [InlineData("no Hello in time", StatusCode.Unauthenticated, "signature_invalid")] + [InlineData("the first message could not be decoded", StatusCode.Unauthenticated, "signature_invalid")] public async Task FailureClosesTheStreamWithTheContractStatusAndTrailers( string end, StatusCode expectedStatus, @@ -815,7 +857,8 @@ private static AgentIdentityError AssertFailed( Assert.Equal(expectedStatus, result.Error.ToRpcException(DateTimeOffset.UnixEpoch).StatusCode); // oracle: mutant an empty failure detail for a late Hello, a first message that is not a Hello, // a Hello without a correlation_id or correlated to another id, a proof that does not verify, - // no Hello before the timeout, a certificate no longer registered, or a revoked identity + // no Hello before the timeout, a first message that could not be decoded, a certificate no longer registered, + // or a revoked identity Assert.False(string.IsNullOrEmpty(result.FailureDetail)); return result.Error; } @@ -852,10 +895,10 @@ private static void AddOrderedPairs( } } - // The name of the failure of a stream that the closure ends before any message arrives. - private static string WithoutHello(string closure) + // The name of the failure of a stream that ends with no Hello, and that the closure ends first. + private static string WithClosure(string end, string closure) { - return $"no Hello in time, and {closure}"; + return $"{end}, and {closure}"; } // The names of the pairs of failures whose two failures the predicate accepts. @@ -881,7 +924,8 @@ private static Task VerifyAsync(Func HandshakeAsync( Scenario scenario, VectorAuthority? authority = null) @@ -901,6 +945,11 @@ private static async Task HandshakeAsync( VectorChannel.RegisterDevice(authority.Registry, scenario.Status, scenario.IdentityRevoked); } + if (scenario.FirstMessageUnreadable) + { + return await opening.FirstMessageUnreadableAsync(); + } + return scenario.FirstMessage is { } firstMessage ? await opening.VerifyHelloAsync(firstMessage(opening)) : await opening.HelloTimedOutAsync(); @@ -952,6 +1001,9 @@ private sealed record Scenario // When null, no message arrives and the host ends the handshake for the timeout. public Func? FirstMessage { get; init; } = VectorChannel.ValidHelloMessage; + // When true, the first message arrives but cannot be decoded, and FirstMessage is not used. + public bool FirstMessageUnreadable { get; init; } + // This scenario with the changes of a later one to the registry and the clock, and with its first message. // The only clock readings combined are after notAfter and after the timeout, and the first is after both. public Scenario And(Scenario later) @@ -963,6 +1015,7 @@ public Scenario And(Scenario later) Status = Status, HandshakeTime = HandshakeTime ?? later.HandshakeTime, FirstMessage = later.FirstMessage, + FirstMessageUnreadable = later.FirstMessageUnreadable, }; } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs index 68292588b4..98b895035b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs @@ -19,6 +19,7 @@ namespace Devolutions.AgentIdentity; /// and the stream is authenticated only once the agent signs that challenge. /// The identity stays hidden until then, so that nothing acts for it on the strength of the headers alone. /// The host passes the first agent message to , +/// calls when it cannot decode that message, /// or calls when none arrives within . /// public sealed class ChannelOpening @@ -40,7 +41,7 @@ public sealed class ChannelOpening private readonly TimeProvider _timeProvider; private readonly ICertificateRegistry _certificateRegistry; - // Set by the first call to VerifyHelloAsync or HelloTimedOutAsync. + // Set by the first call to VerifyHelloAsync, HelloTimedOutAsync or FirstMessageUnreadableAsync. private int _handshakeEnded; internal ChannelOpening( @@ -141,7 +142,8 @@ internal ChannelOpening( /// Only then is the metadata checked, and metadata that CONTRACT.agent-identity.md §3 rejects fails the handshake /// with . /// The Welcome of a successful handshake carries the time of the clock reading. - /// The first call to this method or to ends the handshake, whatever its outcome, + /// The first call to this method, to + /// or to ends the handshake, whatever its outcome, /// including an exception from the registry, which propagates. /// /// The first message that the agent sent on the stream. @@ -231,7 +233,8 @@ public async ValueTask VerifyHelloAsync( /// . /// Otherwise it fails with . /// The host also calls it when the agent ends the stream without sending a message. - /// The first call to this method or to ends the handshake, whatever its outcome, + /// The first call to this method, to + /// or to ends the handshake, whatever its outcome, /// including an exception from the registry, which propagates. /// /// A token that cancels the registry lookup. @@ -243,12 +246,38 @@ public async ValueTask HelloTimedOutAsync(CancellationTo { EndHandshake(); - (DateTimeOffset now, RegisteredCertificate? registered) = - await ReadClockAndRegistryAsync(cancellationToken).ConfigureAwait(false); + return await FailWithoutHelloAsync("No Hello arrived before the timeout.", cancellationToken) + .ConfigureAwait(false); + } - return IsClosed(registered, now, out ChannelHandshakeResult? closure) - ? closure - : ChannelHandshakeResult.Failure(SignatureInvalid, "No Hello arrived before the timeout."); + /// + /// Ends the handshake of a stream whose first agent message could not be decoded. + /// + /// + /// The host calls it when the transport cannot decode the first AgentMessage, + /// for example because a protobuf string holds invalid UTF-8, so that no message exists to pass to + /// . + /// No proof can be read from such a message, so CONTRACT.agent-identity.md §7.3 step 4 counts it as not a + /// Hello. + /// Like , the method reads the clock once, then the certificate registry once, + /// and fails the handshake for the certificate and its identity as that method does. + /// Otherwise it fails with . + /// The first call to this method, to + /// or to ends the handshake, whatever its outcome, + /// including an exception from the registry, which propagates. + /// + /// A token that cancels the registry lookup. + /// + /// The handshake has already ended, + /// or the registry returned another certificate, or the certificate for another identity. + /// + public async ValueTask FirstMessageUnreadableAsync( + CancellationToken cancellationToken = default) + { + EndHandshake(); + + return await FailWithoutHelloAsync("The first message on the stream could not be decoded.", cancellationToken) + .ConfigureAwait(false); } private void EndHandshake() @@ -284,6 +313,20 @@ private bool HasExpired(DateTimeOffset now) return (now, registered); } + // The outcome of a handshake with no Hello to verify: the closure that applies, if any, + // and otherwise signature_invalid with the detail, as CONTRACT.agent-identity.md §7.3 step 4 specifies. + private async ValueTask FailWithoutHelloAsync( + string detail, + CancellationToken cancellationToken) + { + (DateTimeOffset now, RegisteredCertificate? registered) = + await ReadClockAndRegistryAsync(cancellationToken).ConfigureAwait(false); + + return IsClosed(registered, now, out ChannelHandshakeResult? closure) + ? closure + : ChannelHandshakeResult.Failure(SignatureInvalid, detail); + } + // CONTRACT.agent-identity.md §7.4: a deletion, a revocation and the certificate's notAfter close a stream even // while it awaits its Hello, ahead of every failure of §7.3 step 4, in the order of §6 step 4. // A certificate retired since the opening does not close the stream: diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 6fa9de09b9..198016ccec 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -103,13 +103,14 @@ Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given 2. Send the result's `Opening.Challenge` as the first message on the stream. 3. Pass the first agent message to `Opening.VerifyHelloAsync`. Call `Opening.HelloTimedOutAsync` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. + Call `Opening.FirstMessageUnreadableAsync` instead when the first message cannot be decoded, for example because a protobuf `string` holds invalid UTF-8 (CONTRACT.agent-identity.md [§7.3] step 4). The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. 4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`. 5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.agent-identity.md [§7.3] steps 5 and 7). 6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.agent-identity.md [§7.4]). 7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.agent-identity.md [§7.4]). -`VerifyHelloAsync` and `HelloTimedOutAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.agent-identity.md [§7.4]). +`VerifyHelloAsync`, `HelloTimedOutAsync` and `FirstMessageUnreadableAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.agent-identity.md [§7.4]). A channel opening has no body: `VerifyHelloAsync` then checks the deadline, the message and its proof, and the metadata of the `Hello` last (CONTRACT.agent-identity.md [§7.3] step 4). When an identity's config changes, create its `IdentityConfig` again with a higher `Revision`, and send `ChannelMessages.ConfigUpdate` on each authenticated stream of the identity. From 23057afac417d4f5be4d87da50f8abd110937a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 22 Oct 2026 03:50:50 +0900 Subject: [PATCH 61/93] feat(dotnet-agent-identity): keep every config within one channel envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACT.agent-identity.md §7.1 limits each agent channel envelope to 65,536 bytes of serialized protobuf, and an authority never holds a config whose ConfigUpdate envelope would exceed it. IdentityConfig.Create now throws ArgumentException for productFields when the ServerMessage carrying the config as ConfigUpdate.config_json would be larger. It measures that envelope with a 36-character id, the length of the lowercase hyphenated UUID that ChannelMessages writes, so the outcome never depends on the UUID generated. The README tells the host to set the gRPC MaxReceiveMessageSize and MaxSendMessageSize of the channel service to 65,536 bytes, the send limit being only a backstop. A first message that is over the limit or cannot be decoded goes to ChannelOpening.FirstMessageUnreadableAsync. After the handshake, such a message closes the stream with AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest) .ToRpcException(serverTime), as §7.4 requires. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Bodies/IdentityConfigTests.cs | 59 +++++++++++++++++++ .../Errors/AgentIdentityErrorCodeTests.cs | 2 + .../Errors/AgentIdentityErrorTests.cs | 2 + .../Bodies/IdentityConfig.cs | 41 ++++++++++++- dotnet/AgentIdentity/README.md | 6 +- 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index 5f99ff568b..356493a9c6 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using Google.Protobuf; + namespace Devolutions.AgentIdentity.Tests; public class IdentityConfigTests @@ -287,6 +289,63 @@ public void ConfigOutlivesTheDocumentOfItsProductFields() Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), written)); } + // A `ServerMessage` whose `id` is a lowercase hyphenated UUID and whose `ConfigUpdate` carries J bytes of JSON + // takes J + 46 bytes, for J from 16,384 to 2,097,147, where both lengths below are 3-byte varints: + // 1 byte tag of `id`: field 1, wire type LEN, (1 << 3) | 2 = 10 + // 1 byte length of `id`: 36, the characters of a hyphenated UUID + // 36 bytes `id` + // 1 byte tag of `config_update`: field 15, wire type LEN, (15 << 3) | 2 = 122 + // 3 bytes length of `ConfigUpdate`: J + 4 + // 1 byte tag of `config_json`: field 1, wire type LEN, 10 + // 3 bytes length of `config_json`: J + // J bytes `config_json` + // `correlation_id` is unset, so it takes no bytes. + // An envelope of 65,536 bytes therefore carries 65,490 bytes of JSON. + private const int LargestConfigJsonBytes = 65_490; + + // Each side of the agent channel accepts envelopes up to 65,536 bytes. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-service + [Fact] + public void CreateAcceptsAConfigWhoseConfigUpdateIsExactly64KiB() + { + IdentityConfig config = IdentityConfig.Create(1, null, PaddedProductFields(LargestConfigJsonBytes)); + + // oracle: spec Protocol Buffers Encoding §Length-Delimited Records + Assert.Equal(65_536, ChannelMessages.ConfigUpdate(config).ToByteArray().Length); + } + + // Each side of the agent channel sends no envelope larger than 65,536 bytes, + // so an authority never holds a config whose `ConfigUpdate` would exceed that. + // One byte more JSON than the largest config makes a 65,537-byte envelope. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-service + // oracle: spec Protocol Buffers Encoding §Length-Delimited Records + [Fact] + public void CreateRejectsAConfigWhoseConfigUpdateWouldExceed64KiB() + { + KeyValuePair[] productFields = PaddedProductFields(LargestConfigJsonBytes + 1); + + ArgumentException exception = + Assert.Throws(() => IdentityConfig.Create(1, null, productFields)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("productFields", exception.ParamName); + } + + // Returns one product field, padded with ASCII letters, that makes the JSON of a config with revision 1 and no + // agent channel URL `jsonBytes` long. + // Each letter adds one byte, since UTF-8 encodes an ASCII letter, which JSON need not escape, in one byte. + private static KeyValuePair[] PaddedProductFields(int jsonBytes) + { + int unpaddedBytes = IdentityConfig.Create(1, null, Padding(0)).ToUtf8Json().Length; + + return Padding(jsonBytes - unpaddedBytes); + + static KeyValuePair[] Padding(int letters) + { + return [new("padding", TestJson.Parse($"\"{new string('a', letters)}\""))]; + } + } + private static void AssertRejectsProductFields(KeyValuePair[] productFields) { ArgumentException exception = Assert.Throws( diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index aab95d8820..f72b8f67f7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -75,6 +75,7 @@ public void HttpStatusCodeIsInTheContractClass(AgentIdentityErrorCode code, int // CONTRACT.agent-identity.md §7.4: an authentication failure closes the stream with UNAUTHENTICATED, // or PERMISSION_DENIED for `identity_revoked`. // §7.3 step 4: invalid `Hello` metadata closes it with INVALID_ARGUMENT and `invalid_request`. + // §7.4: so does a message that cannot be decoded on an authenticated stream. // `unavailable` gets UNAVAILABLE, which gRPC defines as a transient condition. public static TheoryData GrpcStatusCodes => new() { @@ -95,6 +96,7 @@ public void HttpStatusCodeIsInTheContractClass(AgentIdentityErrorCode code, int // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 { AgentIdentityErrorCode.ClockSkew, StatusCode.Unauthenticated }, // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-termination { AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument }, // oracle: spec gRPC status codes §Status codes and their use in gRPC { AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable }, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs index 45bbfd9bb6..d3ced3ccff 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs @@ -115,12 +115,14 @@ public void JsonBodyHasExactlyTheContractMembers() // CONTRACT.agent-identity.md §7.4: a refused channel opening carries the code's status. // §7.3 step 4: invalid `Hello` metadata gets INVALID_ARGUMENT. + // §7.4: so does a message that cannot be decoded on an authenticated stream. [Theory] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData(AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated)] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 [InlineData(AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied)] // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-termination [InlineData(AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument)] // oracle: spec gRPC status codes §Status codes and their use in gRPC [InlineData(AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs index 9155827edb..2e5106748f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs @@ -1,6 +1,9 @@ using System.Buffers; +using System.Text; using System.Text.Json; +using Devolutions.Agent.Channel.V1; + namespace Devolutions.AgentIdentity; /// @@ -22,6 +25,9 @@ public sealed class IdentityConfig // The schema of the config; the agent treats a config of another version as a transient failure. private const int SchemaVersion = 1; + // CONTRACT.agent-identity.md §7.1: neither side of the agent channel sends an envelope larger than 64 KiB. + private const int MaxEnvelopeBytes = 64 * 1024; + private readonly byte[] _utf8Json; private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) @@ -62,6 +68,10 @@ private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) /// The members of every object inside a value follow the same rules: /// each has a snake_case name, which no other member of its object has, and a value other than null. /// An array element is no member, so it may be null. + /// CONTRACT.agent-identity.md §7.1 limits each agent channel message to 65,536 bytes of protobuf, + /// so the config must fit one ConfigUpdate message. + /// That message is measured as builds it, with a 36-character id, + /// so the outcome never depends on the UUID generated for it. /// /// The revision of the config. /// @@ -76,7 +86,9 @@ private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) /// or a product-specific field has no name, a name that is not snake_case or that the contract gives a member, /// no value, the value null, or a value that UTF-8 JSON cannot carry, such as a lone surrogate; /// or a member inside a value has a name that is not snake_case or that its object repeats, or the value - /// null. + /// null; + /// or the ConfigUpdate message that carries the config would exceed 65,536 bytes + /// (CONTRACT.agent-identity.md §7.1), an error reported for . /// public static IdentityConfig Create( ulong revision, @@ -87,7 +99,18 @@ public static IdentityConfig Create( ? null : BaseUrl.Normalize(agentChannelUrl, "The agent channel URL", nameof(agentChannelUrl)); - return new IdentityConfig(revision, normalizedUrl, Write(revision, normalizedUrl, productFields ?? [])); + byte[] utf8Json = Write(revision, normalizedUrl, productFields ?? []); + int envelopeBytes = ConfigUpdateBytes(utf8Json); + + if (envelopeBytes > MaxEnvelopeBytes) + { + throw new ArgumentException( + $"The ConfigUpdate message of the config would take {envelopeBytes} bytes, " + + $"more than the {MaxEnvelopeBytes} bytes that the agent channel carries.", + nameof(productFields)); + } + + return new IdentityConfig(revision, normalizedUrl, utf8Json); } /// @@ -126,6 +149,20 @@ private static byte[] Write( return buffer.WrittenSpan.ToArray(); } + // Measures the ConfigUpdate that ChannelMessages builds for the config. + // Its id is a fresh lowercase hyphenated UUID, so a placeholder of the same 36 characters stands in for it, + // and the outcome never depends on the UUID generated. + private static int ConfigUpdateBytes(byte[] utf8Json) + { + ServerMessage envelope = new() + { + Id = Guid.Empty.ToString("D"), + ConfigUpdate = new ConfigUpdate { ConfigJson = Encoding.UTF8.GetString(utf8Json) }, + }; + + return envelope.CalculateSize(); + } + private static void WriteProductFields( Utf8JsonWriter writer, IEnumerable> productFields) diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 198016ccec..c9dad092bf 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -99,16 +99,20 @@ Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given ### Agent channel +Set the gRPC `MaxReceiveMessageSize` and `MaxSendMessageSize` options of the `AgentChannel` service to 65,536 bytes, the message limit of CONTRACT.agent-identity.md [§7.1]. +The send limit is only a backstop, since `IdentityConfig.Create` refuses a config whose `ConfigUpdate` message would exceed it. + 1. Pass the request method and the values of the `signature-input` and `signature` request headers, in received order, to `RequestAuthenticator.AuthenticateChannelOpeningAsync`. 2. Send the result's `Opening.Challenge` as the first message on the stream. 3. Pass the first agent message to `Opening.VerifyHelloAsync`. Call `Opening.HelloTimedOutAsync` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. - Call `Opening.FirstMessageUnreadableAsync` instead when the first message cannot be decoded, for example because a protobuf `string` holds invalid UTF-8 (CONTRACT.agent-identity.md [§7.3] step 4). + Call `Opening.FirstMessageUnreadableAsync` instead when the first message cannot be decoded, for example because it exceeds `MaxReceiveMessageSize` or a protobuf `string` holds invalid UTF-8 (CONTRACT.agent-identity.md [§7.1], [§7.3] step 4). The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. 4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`. 5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.agent-identity.md [§7.3] steps 5 and 7). 6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.agent-identity.md [§7.4]). 7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.agent-identity.md [§7.4]). +8. When a later agent message cannot be decoded or exceeds `MaxReceiveMessageSize`, close the stream with `AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest).ToRpcException(serverTime)` (CONTRACT.agent-identity.md [§7.4]). `VerifyHelloAsync`, `HelloTimedOutAsync` and `FirstMessageUnreadableAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.agent-identity.md [§7.4]). A channel opening has no body: `VerifyHelloAsync` then checks the deadline, the message and its proof, and the metadata of the `Hello` last (CONTRACT.agent-identity.md [§7.3] step 4). From 24021f743249f818c40131088b6cf91fff5db947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 22 Oct 2026 11:26:51 +0900 Subject: [PATCH 62/93] test(dotnet-agent-identity): mark the theories whose rows carry the markers A theory whose rows sit in a shared data member carried its markers only there, outside the test, where a marker check does not look. Each such theory now also names the source its rows share, or its main source when the rows rest on several; the row markers stay. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/AuthenticationTypeTests.cs | 2 ++ .../Certificates/CertificateSigningRequestTests.cs | 4 ++++ .../Errors/AgentIdentityErrorCodeTests.cs | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs index cd49d18736..b168665230 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs @@ -33,6 +33,8 @@ public class AuthenticationTypeTests // A public or protected constructor would let a caller build an identity from any values, // or a subclass of one with its own constructor. // The other types carry an identity, or the opening that becomes one, out of the check. + // Each row's marker names what decides it. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity [Theory] [MemberData(nameof(AuthenticationTypeData))] public void TypeHasNoPublicOrProtectedConstructor(Type type) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 357e898393..b4fecd9072 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -413,6 +413,7 @@ public void RejectsRequestInfoWithoutConstructedAttributes(string attributes) AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); } + // oracle: spec RFC 2986 §4 [Theory] [MemberData(nameof(RequestWithAnExtraElementNames))] public void RejectsExtraElement(string sequence) @@ -503,6 +504,9 @@ public void RejectsPointWithACoordinateOutOfTheField(string coordinate) AssertInvalid(CertificateSigningRequest.Validate(der)); } + // The key needs an uncompressed P-256 point and the request a verified self-signature, else invalid_request; + // each row's marker names the encoding rule that the bit string breaks. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(RequestWithUnusedBitsNames))] public void RejectsBitStringWithUnusedBits(string bitString) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index f72b8f67f7..294bfb962f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -58,6 +58,7 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec { AgentIdentityErrorCode.Unavailable, 500, 599 }, }; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Theory] [MemberData(nameof(HttpStatusCodes))] public void HttpStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, int expected) @@ -65,6 +66,7 @@ public void HttpStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, int e Assert.Equal(expected, code.ToHttpStatusCode()); } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Theory] [MemberData(nameof(HttpStatusClasses))] public void HttpStatusCodeIsInTheContractClass(AgentIdentityErrorCode code, int lowest, int highest) @@ -102,6 +104,8 @@ public void HttpStatusCodeIsInTheContractClass(AgentIdentityErrorCode code, int { AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable }, }; + // Most rows follow the closures of the channel; each row's marker names the source that decides it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] [MemberData(nameof(GrpcStatusCodes))] public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, StatusCode expected) From f80dbb00162c232ba6bd0c2322582ca2ed6cca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 22 Oct 2026 11:31:37 +0900 Subject: [PATCH 63/93] test(dotnet-agent-identity): pin the channel URL slash rule and revocation over expiry Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Bodies/IdentityConfigTests.cs | 44 +++++++++++++++++++ .../Channel/ChannelHandshakeTests.cs | 3 ++ 2 files changed, 47 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index 356493a9c6..d1c8e25f39 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -110,6 +110,22 @@ public void AgentChannelUrlIsWrittenAsGiven(string agentChannelUrl) Assert.Equal(agentChannelUrl, config.AgentChannelUrl); } + // `agent_channel_url` has no trailing slash, and the agent removes trailing slashes from one that has them. + // Create may reject a URL ending in slashes, but a config it creates holds the URL without them. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + [Theory] + [InlineData("https://host/dvls/", "https://host/dvls")] + [InlineData("https://host/dvls//", "https://host/dvls")] + [InlineData("https://host/", "https://host")] + public void CreateRejectsOrRemovesTheTrailingSlashesOfAnAgentChannelUrl( + string agentChannelUrl, + string withoutSlashes) + { + Creation creation = CreateFrom(agentChannelUrl); + + Assert.Contains(creation, (Creation[])[Creation.Holding(withoutSlashes), Creation.Rejection]); + } + // Product-specific config fields, such as a PSU channel URL, may coexist with the others. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 [Fact] @@ -354,4 +370,32 @@ private static void AssertRejectsProductFields(KeyValuePair // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("productFields", exception.ParamName); } + + // What Create makes of the URL: the `agent_channel_url` that the JSON of its config holds, or a rejection. + private static Creation CreateFrom(string agentChannelUrl) + { + IdentityConfig config; + + try + { + config = IdentityConfig.Create(1, agentChannelUrl); + } + catch (ArgumentException) + { + return Creation.Rejection; + } + + return Creation.Holding(TestJson.Members(config.ToUtf8Json())["agent_channel_url"].GetString()); + } + + // What Create makes of an agent channel URL: a rejection, or a config whose JSON holds `AgentChannelUrl`. + private sealed record Creation(bool Rejected, string? AgentChannelUrl) + { + public static Creation Rejection { get; } = new(Rejected: true, AgentChannelUrl: null); + + public static Creation Holding(string? agentChannelUrl) + { + return new Creation(Rejected: false, AgentChannelUrl: agentChannelUrl); + } + } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index 643ae389ef..dad0978872 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -337,12 +337,15 @@ public async Task FailureDetailHoldsNoMetadataKeyOrValue(string entry) // a certificate no longer registered, a revoked identity, an expired certificate, then a late Hello. // A stream that fails one of them is closed for that reason, whatever the message holds, and so is a stream // without Hello, which gets identity_unknown for an identity revoked and then deleted. + // A revocation or a deletion found when the Hello arrives or the wait times out wins over a certificate also past + // its notAfter, whether a Hello arrived, none arrived in time, or the first message could not be decoded. // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. // CONTRACT.agent-identity.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, // so an agent that has not proved the key never learns whether its metadata would pass. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] [MemberData(nameof(PairsOfFailuresWithDifferentCodes))] public async Task EarlierFailureWins(string earlier, string later) From d54ddeaf049e3dee8f65c5a7946a0f90de5daa6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 22 Oct 2026 11:38:32 +0900 Subject: [PATCH 64/93] test(dotnet-agent-identity): cite contract sections by their anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract places an anchor before each section heading, and a marker cites the anchor when the file has one, because it survives a renamed heading or a renumbered section. Every contract marker and contract link under dotnet/ now uses the anchor, such as `§errors` for §5.4, instead of the section number or an earlier prefixed form of the anchor. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RequestAuthenticatorProfileTests.cs | 10 +- .../RequestAuthenticatorTests.cs | 42 +++---- .../RequestAuthenticatorVectorTests.cs | 6 +- .../Authentication/StructuredFieldTests.cs | 2 +- .../Bodies/AgentMetadataTests.cs | 42 +++---- .../Bodies/ConfirmRequestBodyTests.cs | 4 +- .../Bodies/IdentityConfigTests.cs | 60 +++++----- .../Bodies/RequestBodyTests.cs | 82 +++++++------- .../Bodies/ResponseBodiesTests.cs | 50 ++++---- .../CertificateSigningRequestTests.cs | 36 +++--- .../IdentityCertificateIssuerTests.cs | 40 +++---- .../Certificates/RenewalRulesTests.cs | 8 +- .../Channel/ChannelHandshakeTests.cs | 107 +++++++++--------- .../Channel/ChannelHandshakeVectorTests.cs | 4 +- .../Channel/ChannelMessagesTests.cs | 20 ++-- .../Channel/ChannelOpeningTests.cs | 16 +-- .../EnrollmentAuthorizationTests.cs | 34 +++--- .../Enrollment/EnrollmentRulesTests.cs | 26 ++--- .../Enrollment/EnrollmentTokenTests.cs | 34 +++--- .../Enrollment/FriendlyNameFormatTests.cs | 46 ++++---- .../Errors/AgentIdentityErrorCodeTests.cs | 38 +++---- .../Errors/AgentIdentityErrorTests.cs | 32 +++--- .../Protocol/AgentIdentityProtocolTests.cs | 14 +-- .../Protocol/StrictBase64Tests.cs | 16 +-- dotnet/AgentIdentity/README.md | 4 +- 25 files changed, 386 insertions(+), 387 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs index 8aaf7e71f2..0dcfa69d7d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs @@ -253,7 +253,7 @@ public static TheoryData ContentDigestViolationCases // A verifier that keeps the last of repeated keys, decodes leniently, or reads a parameter of another type // accepts a request whose signature data a proxy, a log or another parser can read differently. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(StepOneViolationNames))] public async Task CorrectlySignedRequestBreakingAStepOneRuleIsSignatureInvalid(string violation) @@ -267,7 +267,7 @@ public async Task CorrectlySignedRequestBreakingAStepOneRuleIsSignatureInvalid(s // Step 1: for renew and check-in, Content-Digest holds exactly one member, sha-256, // without parameters, in canonical base64. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(ContentDigestViolationCases))] public async Task CorrectlySignedRequestBreakingAContentDigestRuleIsSignatureInvalid( @@ -284,7 +284,7 @@ public async Task CorrectlySignedRequestBreakingAContentDigestRuleIsSignatureInv // Parameters may appear in any order, and the verifier builds @signature-params // from the received member, so a verifier that copied the received text would reject these. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(AcceptedFormNames))] public async Task RequestInAFormThatRfc8941AllowsIsAccepted(string form) @@ -298,7 +298,7 @@ public async Task RequestInAFormThatRfc8941AllowsIsAccepted(string form) // Step 1: a malformed Signature-Input draws signature_invalid, // as a result rather than an exception, and records no nonce. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(MalformedSignatureInputNames))] public async Task MalformedSignatureInputIsSignatureInvalid(string form) @@ -315,7 +315,7 @@ public async Task MalformedSignatureInputIsSignatureInvalid(string form) // which CONTRACT.agent-identity.md §6 step 2 rejects for renew. // The signature covers the tag renew, which a parser that dropped the escaped character would read. // oracle: spec RFC 8941 §4.2.5 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Fact] public async Task TagWithAnEscapedBackslashIsAnotherTag() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs index 87ea891a89..68c8d164b6 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs @@ -122,7 +122,7 @@ public static TheoryData OperationsWithAccept private static TimeSpan DeviceALifetime => DeviceACertificate.NotAfter - DeviceACertificate.NotBefore; // A body larger than 64 KiB returns 413 invalid_request before any authentication outcome. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Theory] [MemberData(nameof(OversizedRequestNames))] public async Task BodyOverTheLimitIsRejectedWith413First(string request) @@ -139,7 +139,7 @@ public async Task BodyOverTheLimitIsRejectedWith413First(string request) } // Servers accept request bodies up to 64 KiB. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Fact] public async Task BodyOfExactlyTheLimitIsAccepted() { @@ -156,7 +156,7 @@ public async Task BodyOfExactlyTheLimitIsAccepted() // The earliest failing step decides, // so the identity's state is reported even when the signature or the digest would not verify. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [InlineData("revoked identity, wrong key", "identity_revoked")] [InlineData("retired certificate, wrong key", "identity_unknown")] @@ -171,7 +171,7 @@ public async Task StepFourFailureWinsOverAStepFiveFailure(string request, string // CONTRACT.agent-identity.md §6 step 4 resolves the certificate before it reads the identity's revocation, // and reads revocation before validity, so an expired certificate of a revoked identity draws identity_revoked. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [InlineData("retired certificate, revoked identity", "identity_unknown")] [InlineData("expired certificate, revoked identity", "identity_revoked")] @@ -183,7 +183,7 @@ public async Task StepFourReturnsItsEarliestFailingCheck(string request, string } // Table: every operation accepts a current or pending certificate. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(OperationsWithAcceptedStatuses))] public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation, CertificateStatus status) @@ -195,7 +195,7 @@ public async Task PendingOrCurrentCertificateIsAccepted(AgentOperation operation } // Step 4: a certificate that is neither current nor pending draws identity_unknown. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(Operations))] public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) @@ -209,7 +209,7 @@ public async Task RetiredCertificateIsIdentityUnknown(AgentOperation operation) // A certificate is unexpired while now ≤ notAfter, compared to the exact instant. // A clock truncated to the second would still accept the certificate one tick after its notAfter. // Renew has no row one tick after, because its grace still accepts the certificate then. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [InlineData(AgentOperation.Renew, 0, "ok")] [InlineData(AgentOperation.Confirm, 0, "ok")] @@ -229,7 +229,7 @@ public async Task CertificateExpiresAfterTheExactInstantOfItsNotAfter( } // notBefore is not checked, so a certificate dated by a fast authority clock does not lock devices out. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(Operations))] public async Task CertificateIsAcceptedBeforeItsNotBefore(AgentOperation operation) @@ -240,7 +240,7 @@ public async Task CertificateIsAcceptedBeforeItsNotBefore(AgentOperation operati } // Table: renew accepts a certificate while now < notAfter + (notAfter − notBefore). - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [InlineData(CertificateStatus.Pending, "one tick after notAfter", "ok")] [InlineData(CertificateStatus.Current, "one tick after notAfter", "ok")] @@ -263,7 +263,7 @@ public async Task RenewGraceIsTheCertificateLifetimeAfterItsNotAfter( // against the same `now`. // A second reading of the clock between the window and the certificate check // would judge the certificate a day later, after it expired. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Fact] public async Task WindowAndCertificateAreJudgedAtOneReadingOfTheClock() { @@ -274,7 +274,7 @@ public async Task WindowAndCertificateAreJudgedAtOneReadingOfTheClock() } // Step 3: 0 < expires − created ≤ 300. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Fact] public async Task SignatureLifetimeOf300SecondsIsAccepted() { @@ -290,7 +290,7 @@ public async Task SignatureLifetimeOf300SecondsIsAccepted() // RFC 8941 §3.3.1: an integer may be negative, and RFC 9421 §2.3 serializes it with its sign. // At the Unix epoch, all three inequalities of CONTRACT.agent-identity.md §6 step 3 hold, // and the certificate of device-a, whose notBefore is later, is accepted because §6 does not check notBefore. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures // oracle: spec RFC 8941 §3.3.1 // oracle: spec RFC 9421 §2.3 [Fact] @@ -307,7 +307,7 @@ public async Task CreatedBeforeTheUnixEpochIsAccepted() // CONTRACT.agent-identity.md §6: the earliest failing step decides. // RFC 8941 §3.3.5 allows a byte sequence of no bytes, in its canonical base64 of no characters, // so the signature passes step 1 and its length fails only step 5, after the window of step 3. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures // oracle: spec RFC 8941 §3.3.5 [Fact] public async Task SignatureOfNoBytesIsJudgedAfterTheWindow() @@ -324,7 +324,7 @@ public async Task SignatureOfNoBytesIsJudgedAfterTheWindow() } // Step 6: a pair the store already holds fails, whichever request recorded it. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(Operations))] public async Task NonceAlreadyRecordedIsSignatureInvalid(AgentOperation operation) @@ -335,7 +335,7 @@ public async Task NonceAlreadyRecordedIsSignatureInvalid(AgentOperation operatio } // A header the operation does not cover, such as Content-Digest on confirm, is ignored. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Fact] public async Task ConfirmIgnoresContentDigest() { @@ -352,7 +352,7 @@ public async Task ConfirmIgnoresContentDigest() // A method is a token, and anything else could put a line break of its own into the signature base. // oracle: spec RFC 9110 §9.1 // oracle: spec RFC 9110 §5.6.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [InlineData("")] [InlineData("PO ST")] @@ -369,7 +369,7 @@ public async Task MethodThatIsNotATokenIsSignatureInvalid(string method) // A method that is not a token gives @method no value, so no signature base exists to verify, // not even an empty one: RFC 9421 §2.5 ends every base with its @signature-params line. // oracle: spec RFC 9421 §2.5 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Fact] public async Task MethodThatIsNotATokenCannotVerifyASignatureOverNoBytes() { @@ -392,7 +392,7 @@ public async Task MethodThatIsNotATokenCannotVerifyASignatureOverNoBytes() // A token may hold any of these punctuation characters, besides letters and digits. // oracle: spec RFC 9110 §5.6.2 // oracle: spec RFC 9110 §9.1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Fact] public async Task MethodWithEveryTokenPunctuationCharacterIsAccepted() { @@ -427,7 +427,7 @@ await Assert.ThrowsAsync( // The registry answers by thumbprint, so another certificate is a host bug, // which must fail as a server error rather than draw an outcome the agent acts on. // CONTRACT.agent-identity.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors // oracle: spec Framework Design Guidelines §Exception Throwing // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Fact] @@ -447,8 +447,8 @@ await Assert.ThrowsAsync( // so any other registered bytes are a host bug. // CONTRACT.agent-identity.md §5.4: identity_unknown is terminal for the agent, so a host bug must not draw it. // The keyid is the thumbprint of those bytes, so the registry answers with what it was asked for. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors // oracle: spec Framework Design Guidelines §Exception Throwing // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs index 6c20a0d821..1c9be424df 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs @@ -100,7 +100,7 @@ public void Rfc9421B24VerifiesWithTheLibraryPrimitive() // Nonces are committed only after the signature verifies, so garbage requests cannot burn nonces. // With an empty nonce store, every failing case fails before step 6. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(FailingCaseNames))] public async Task FailingCaseRecordsNoNonce(string name) @@ -137,7 +137,7 @@ public async Task FailingCaseDetailHoldsNoRequestData(string name) // CONTRACT.agent-identity.md §6 step 6 and the shared intent: the authority keeps each accepted nonce // until 60 s after its signature expires, the last moment the window could accept the signature again. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Request authentication [Theory] [MemberData(nameof(OkCaseNames))] @@ -161,7 +161,7 @@ public async Task OkCaseRecordsItsNonceOnceUntilExpiresPlus60Seconds(string name // such as the status that tells confirm whether to promote the certificate. // The registered certificates of the vector file state them, in Unix seconds, independently of any time zone. // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] [MemberData(nameof(OkSignedRequestCaseNames))] public async Task OkCaseIdentityIsThatOfTheSigningCertificate(string name) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs index 6c43bc4811..8326833e92 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs @@ -93,7 +93,7 @@ public void ParsesADictionary(string input, string expected) // oracle: spec RFC 8941 §4.2.3.2 [InlineData("sig=(\"@method\");")] // CONTRACT.agent-identity.md §6 step 1 rejects a repeated key, of which RFC 8941 §4.2.2 would keep the last. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [InlineData("sig=1, sig=2")] // A comma separates members, and none follows the last. // oracle: spec RFC 8941 §4.2.2 diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs index 1ac99b3916..149e153fd3 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs @@ -30,7 +30,7 @@ .. Enumerable.Range(0, 17) public static TheoryData FailureNames => new(Failures.Keys); // Metadata may be an empty object. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void AcceptsNoEntries() { @@ -40,7 +40,7 @@ public void AcceptsNoEntries() } // Metadata is a JSON object of string to string, and keeps each entry received. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void KeepsEveryEntryReceived() { @@ -89,7 +89,7 @@ public void ContainsOnlyTheKeysReceived() } // At most 32 keys. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void Accepts32Keys() { @@ -98,7 +98,7 @@ public void Accepts32Keys() Assert.Equal(32, metadata.Count); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void Rejects33Keys() { @@ -106,7 +106,7 @@ public void Rejects33Keys() } // A key matches ^[a-z][a-z0-9_]{0,63}$. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData("a")] [InlineData("hostname")] @@ -118,14 +118,14 @@ public void AcceptsKeyMatchingThePattern(string key) AssertValid(AgentMetadata.Validate([new(key, "value")])); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void AcceptsKeyOf64Characters() { AssertValid(AgentMetadata.Validate([new("k" + new string('0', 63), "value")])); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void RejectsKeyOf65Characters() { @@ -134,7 +134,7 @@ public void RejectsKeyOf65Characters() // A case-insensitive match would take the Kelvin sign for 'k', // and a .NET regular expression ending in $ also matches before a final line feed. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData("empty", "")] [InlineData("uppercase first letter", "Hostname")] @@ -154,7 +154,7 @@ public void RejectsKeyNotMatchingThePattern(string form, string key) // A value has at most 1024 UTF-8 bytes. // Characters of two, three and four bytes count by their bytes, not as one character or UTF-16 code unit. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData("a", 1024, "")] [InlineData("\u00E9", 512, "")] @@ -165,7 +165,7 @@ public void AcceptsValueOf1024Utf8Bytes(string unit, int count, string suffix) AssertValid(AgentMetadata.Validate([new("hostname", Repeat(unit, count) + suffix)])); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData("a", 1025, "")] [InlineData("\u00E9", 512, "a")] @@ -177,7 +177,7 @@ public void RejectsValueOf1025Utf8Bytes(string unit, int count, string suffix) } // CONTRACT.agent-identity.md §3 gives a value no minimum length. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void AcceptsEmptyValue() { @@ -188,7 +188,7 @@ public void AcceptsEmptyValue() public static TheoryData ControlCharacters => new(Enumerable.Range(0x00, 0x20).Append(0x7F).Concat(Enumerable.Range(0x80, 0x20))); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [MemberData(nameof(ControlCharacters))] public void RejectsValueWithControlCharacter(int codePoint) @@ -197,7 +197,7 @@ public void RejectsValueWithControlCharacter(int codePoint) } // The neighbors of the control ranges, including U+00A0, the first character after C1. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData(0x20)] [InlineData(0x7E)] @@ -209,7 +209,7 @@ public void AcceptsValueWithCharacterNextToAControlRange(int codePoint) // CONTRACT.agent-identity.md §3 measures a value in UTF-8 bytes. // A string holding an unpaired surrogate is not Unicode text, so it has no UTF-8 encoding to measure or store. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata // oracle: spec RFC 3629 §3 [Theory] [InlineData("lone high surrogate", 0x61, 0xD800, 0x62)] @@ -225,7 +225,7 @@ public void RejectsValueWithUnpairedSurrogate(string form, params int[] codeUnit // The whole object has at most 16 KiB, the sum of the UTF-8 byte lengths of all keys and values. // Each value has 1021 UTF-8 bytes but 511 characters, so a count of characters would stay far below the limit. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void AcceptsObjectOf16KiB() { @@ -233,7 +233,7 @@ public void AcceptsObjectOf16KiB() AssertValid(AgentMetadata.Validate(EntriesOf1024Bytes(lastKey: "k15"))); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void RejectsObjectOf16KiBAndOneByte() { @@ -243,7 +243,7 @@ public void RejectsObjectOf16KiBAndOneByte() // CONTRACT.agent-identity.md §1 makes one metadata key sent twice invalid_request. // A later entry with the same key would silently replace the earlier one. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void RejectsRepeatedKey() { @@ -259,8 +259,8 @@ public void FailureDetailHoldsNoKeyOrValue(string failure) { MetadataValidationResult result = AgentMetadata.Validate(Failures[failure]); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata // oracle: spec RFC 3629 §3 AssertInvalid(result, failure); Assert.DoesNotContain(LeakedKey, result.FailureDetail); @@ -277,7 +277,7 @@ public void FailureDetailDoesNotNameTheControlCharacter(int codePoint) MetadataValidationResult result = AgentMetadata.Validate([Entry("hostname", $"host{(char)codePoint}name")]); string[] renderings = [$"U+{codePoint:X4}", $"0x{codePoint:X2}", $"0x{codePoint:X4}", $"{codePoint:X4}"]; - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata AssertInvalid(result); Assert.DoesNotContain(((char)codePoint).ToString(), result.FailureDetail, StringComparison.Ordinal); Assert.All( @@ -306,7 +306,7 @@ private static AgentMetadata AssertValid(MetadataValidationResult result) private static void AssertInvalid(MetadataValidationResult result, string? form = null) { Assert.False(result.Succeeded, form); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); Assert.Equal(400, result.Error.HttpStatusCode); // oracle: mutant an empty failure detail for a repeated key or for metadata over 16 KiB diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs index 1dedce84ea..37c33d174d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs @@ -9,7 +9,7 @@ public class ConfirmRequestBodyTests private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; // A confirm request carries no body. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm [Fact] public void AcceptsEmptyBody() { @@ -20,7 +20,7 @@ public void AcceptsEmptyBody() } // A body holding only white space, a byte order mark, an empty JSON object or a NUL byte is not empty. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm [Theory] [InlineData("empty JSON object", "{}")] [InlineData("JSON null", "null")] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index d1c8e25f39..e14cc3f75f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -8,7 +8,7 @@ public class IdentityConfigTests { // `config` holds `version`, which is 1, `revision` and `agent_channel_url`. // An agent reads both numbers as integers, so 1.0 or 3e0 would not do. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Fact] public void JsonHasTheContractMembers() { @@ -27,8 +27,8 @@ public void JsonHasTheContractMembers() // CONTRACT.agent-identity.md §5.2: `agent_channel_url` is absent when the server does not offer the channel, // and §1 omits an absent field rather than writing null. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Fact] public void JsonOmitsAnAbsentAgentChannelUrl() { @@ -42,7 +42,7 @@ public void JsonOmitsAnAbsentAgentChannelUrl() // `revision` is an unsigned 64-bit integer, with no starting value the agent assumes. // A double holds no more than 53 bits, so it would round the largest revisions. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Theory] [InlineData(0UL, "0")] [InlineData(9007199254740993UL, "9007199254740993")] @@ -60,26 +60,26 @@ public void JsonWritesEveryRevisionExactly(ulong revision, string expected) // CONTRACT.agent-identity.md §5.2: `agent_channel_url` is an absolute `https` URL without a query or fragment. // RFC 9110 §4.2.4: a sender does not generate user information in an `https` URI. [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [InlineData("")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [InlineData("/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [InlineData("host/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [InlineData("http://host/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [InlineData("wss://host/dvls")] // oracle: spec RFC 9110 §4.2.2 [InlineData("https:///dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [InlineData("https://host/dvls?tenant=1")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll // oracle: spec RFC 3986 §3.4 [InlineData("https://host/dvls?")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [InlineData("https://host/dvls#top")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll // oracle: spec RFC 3986 §3.5 [InlineData("https://host/dvls#")] // oracle: spec RFC 9110 §4.2.4 @@ -98,7 +98,7 @@ public void CreateRejectsAnotherAgentChannelUrl(string agentChannelUrl) // `agent_channel_url` is an absolute `https` URL without a query or fragment, which the config carries as the host // gave it. // Whether a URL ending in a slash is rejected or written without the slash is the library's choice. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Theory] [InlineData("https://host/dvls")] [InlineData("https://host")] @@ -127,7 +127,7 @@ public void CreateRejectsOrRemovesTheTrailingSlashesOfAnAgentChannelUrl( } // Product-specific config fields, such as a PSU channel URL, may coexist with the others. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Fact] public void JsonHasTheProductFieldsBesideTheContractMembers() { @@ -149,7 +149,7 @@ public void JsonHasTheProductFieldsBesideTheContractMembers() // JSON keys are snake_case, lowercase words of letters and digits joined by single underscores. // zz09 starts and goes on with the last letter, and holds the first and last digits. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData("a")] [InlineData("psu_channel_url")] @@ -164,17 +164,17 @@ public void CreateAcceptsASnakeCaseProductFieldName(string name) } [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("Channel")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("psuChannel")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("psu-channel")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("psu channel")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("psu\n")] public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) { @@ -182,7 +182,7 @@ public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name) } // CONTRACT.agent-identity.md §5.2 defines these members, so a product field of one of these names would repeat it. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll // oracle: spec RFC 8259 §4 [Theory] [InlineData("version")] @@ -213,7 +213,7 @@ public void CreateRejectsAProductFieldWithoutAName() // RFC 8259 §8.1: JSON text exchanged between systems is UTF-8, which cannot encode an unpaired surrogate. // Failing here keeps every later serialization of the config from failing. [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("""{"\ud800": 1}""")] public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value) { @@ -222,7 +222,7 @@ public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value) // JSON keys are snake_case, so the members inside a product field's value are too, // whether an object or an array holds them. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData("""{"maxSessions": 4}""")] [InlineData("""{"limits": {"max_sessions": 4, "Max_paths": 2}}""")] @@ -260,7 +260,7 @@ public void CreateRejectsARepeatedMemberName(string form) } // An absent field is omitted, never written as null, at any depth. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData("psu_channel_url", "null")] [InlineData("policy_limits", """{"proxy": null}""")] @@ -272,8 +272,8 @@ public void CreateRejectsANullFieldOrMember(string name, string value) // RFC 8259 §4 asks for unique names within an object, and §5 makes array elements values rather than members. // A name repeats only within one object, and an array element is no field, so it may be null. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll // oracle: spec RFC 8259 §4 // oracle: spec RFC 8259 §5 [Fact] @@ -320,7 +320,7 @@ public void ConfigOutlivesTheDocumentOfItsProductFields() private const int LargestConfigJsonBytes = 65_490; // Each side of the agent channel accepts envelopes up to 65,536 bytes. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-service + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service [Fact] public void CreateAcceptsAConfigWhoseConfigUpdateIsExactly64KiB() { @@ -333,7 +333,7 @@ public void CreateAcceptsAConfigWhoseConfigUpdateIsExactly64KiB() // Each side of the agent channel sends no envelope larger than 65,536 bytes, // so an authority never holds a config whose `ConfigUpdate` would exceed that. // One byte more JSON than the largest config makes a 65,537-byte envelope. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-service + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service // oracle: spec Protocol Buffers Encoding §Length-Delimited Records [Fact] public void CreateRejectsAConfigWhoseConfigUpdateWouldExceed64KiB() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs index 259f749bfb..9c398d8f1e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs @@ -35,10 +35,10 @@ public abstract class RequestBodyTests protected abstract RequestBodyOutcome Parse(byte[] utf8Json); // CONTRACT.agent-identity.md §5.2, §5.3 and §5.6 define the bodies, and §3 their metadata. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in [Fact] public void AcceptsValidBody() { @@ -49,7 +49,7 @@ public void AcceptsValidBody() // Receivers ignore unknown JSON fields. // A name repeated in different objects is not a repeated member. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData(""" "extra": 1""")] [InlineData(""" "extra": {"nested": [1, 2.5e3, true, false, null, {"a": "b"}]}""")] @@ -76,7 +76,7 @@ public void ReturnsTheMetadataUnescaped() } // Metadata may be an empty object. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public void AcceptsEmptyMetadata() { @@ -84,8 +84,8 @@ public void AcceptsEmptyMetadata() } // CONTRACT.agent-identity.md §3: metadata is required, and §1 reads a null member as an absent one. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData(null)] [InlineData("null")] @@ -95,7 +95,7 @@ public void RejectsMissingMetadata(string? metadata) } // Metadata is a JSON object of string to string. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData(""" "host" """)] [InlineData("[]")] @@ -107,7 +107,7 @@ public void RejectsMetadataThatIsNotAnObject(string metadata) AssertInvalid(ParseText(Body(metadata, ""))); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [InlineData("""{"hostname": 1}""")] [InlineData("""{"hostname": true}""")] @@ -131,7 +131,7 @@ public void RejectsMetadataValueThatIsNotAString(string metadata) { "value of 1025 UTF-8 bytes", $$"""{"hostname": "{{string.Concat(Enumerable.Repeat("\u00E9", 512))}}a"}""" }, }; - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Theory] [MemberData(nameof(MetadataBeyondTheLimits))] public void RejectsMetadataBeyondTheLimits(string form, string metadata) @@ -162,8 +162,8 @@ public void FailureDetailHoldsNoMetadataKeyOrValue(string form, string metadata) { RequestBodyOutcome outcome = ParseText(Body(metadata, "")); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata AssertInvalid(outcome, form); Assert.DoesNotContain(LeakedKey, outcome.FailureDetail); Assert.DoesNotContain(LeakedValue, outcome.FailureDetail); @@ -171,7 +171,7 @@ public void FailureDetailHoldsNoMetadataKeyOrValue(string form, string metadata) // A body that repeats a member name at any depth, such as one metadata key twice, // is invalid, whether the names are written alike or only unescape alike. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData("metadata", ValidMetadata, """ "metadata": {}""")] // oracle: spec RFC 8259 §7 @@ -198,9 +198,9 @@ public void RejectsRepeatedMemberName(string form, string metadata, string extra // oracle: spec RFC 8259 §2 // oracle: spec RFC 8259 §8.1 [InlineData("byte order mark", "\uFEFF", "")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in [InlineData("object inside an array", "[", "]")] public void RejectsTextAroundTheObject(string form, string prefix, string suffix) { @@ -243,9 +243,9 @@ public void RejectsTruncatedBody() // CONTRACT.agent-identity.md §5.2, §5.3 and §5.6: each body is a JSON object; // RFC 8259 §2: a JSON text holds one value. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in [Theory] [InlineData("[]")] [InlineData(""" "body" """)] @@ -262,7 +262,7 @@ public void RejectsBodyThatIsNotAnObject(string json) // A string that is not valid Unicode, such as an escaped lone surrogate, in any member, including an unknown one, // is invalid, since parsers disagree on what it reads as. // A member name is a string too. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData("lone high surrogate", """ "extra": "\ud800" """)] [InlineData("lone low surrogate", """ "extra": "\udc00" """)] @@ -275,7 +275,7 @@ public void RejectsEscapedStringThatIsNotUnicode(string form, string extraMember // CONTRACT.agent-identity.md §1 and RFC 8259 §8.1: JSON bodies are UTF-8. // The bytes replace the character of a string in an unknown member. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: spec RFC 3629 §4 [Theory] [InlineData(new byte[] { 0xFF })] @@ -292,14 +292,14 @@ public void RejectsStringThatIsNotUtf8(byte[] bytes) // A body with more than 64 nested objects and arrays, counting the top-level object, is invalid. // The body object is the first, so 63 nested arrays make 64 and 64 make 65. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void AcceptsNestingOf64Levels() { AssertValid(ParseText(Body(ValidMetadata, NestedArrays(63)))); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void RejectsNestingOf65Levels() { @@ -324,7 +324,7 @@ protected RequestBodyOutcome ParseText(string json) protected static RequestBodyOutcome AssertValid(RequestBodyOutcome outcome) { Assert.True(outcome.Succeeded, outcome.FailureDetail); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata Assert.NotNull(outcome.Metadata); return outcome; @@ -334,7 +334,7 @@ protected static RequestBodyOutcome AssertValid(RequestBodyOutcome outcome) protected static void AssertInvalid(RequestBodyOutcome outcome, string? form = null) { Assert.False(outcome.Succeeded, form); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors Assert.NotNull(outcome.Error); Assert.Equal(AgentIdentityErrorCode.InvalidRequest, outcome.Error.Code); Assert.Equal(400, outcome.Error.HttpStatusCode); @@ -376,14 +376,14 @@ public void ReturnsTheValidatedCsr() CertificateSigningRequest csr = AssertValid(ParseText(Body(ValidMetadata, ""))).Csr!; Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray()); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules Assert.Equal(SHA256.HashData(expected), csr.SubjectPublicKeyInfoSha256.ToArray()); } // CONTRACT.agent-identity.md §5.2 and §5.3: csr is required, and §1 reads a null member as an absent one. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew [Theory] [InlineData(null)] [InlineData("null")] @@ -393,7 +393,7 @@ public void RejectsMissingCsr(string? csr) } // A CSR is DER encoded base64 in a JSON string. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData("1")] [InlineData("{}")] @@ -409,8 +409,8 @@ public void RejectsCsrThatIsNotAString(string csr) // into a form that a lenient decoder would still read as that request. // The requests are built here rather than read from the vector file, // so that each form applies to them whatever request the file holds. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData(1)] [InlineData(2)] @@ -424,7 +424,7 @@ public void RejectsCsrWithoutPadding(int octetsInFinalQuantum) // The pad bits of the final quantum, four after one octet and two after two octets, are zero. // The character before the padding holds them, so setting its lowest bit sets one pad bit. // oracle: spec RFC 4648 §3.5 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData(1)] [InlineData(2)] @@ -439,7 +439,7 @@ public void RejectsCsrWithNonZeroPadBit(int octetsInFinalQuantum) } // A lenient decoder skips white space, such as the line breaks that RFC 7468 places every 64 characters. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: spec RFC 4648 §3.3 [Theory] [InlineData("\n")] @@ -452,8 +452,8 @@ public void RejectsCsrWithWhiteSpace(string whiteSpace) } // RFC 4648 §5: the base64url alphabet writes - and _ for the + and / of base64. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void RejectsCsrInTheBase64UrlAlphabet() { @@ -481,7 +481,7 @@ public void RejectsEmptyCsr() } // CONTRACT.agent-identity.md §1 makes csr sent twice invalid_request. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void RejectsRepeatedCsr() { @@ -517,7 +517,7 @@ private static string CanonicalCsr(int octetsInFinalQuantum) private void AssertOnlyCanonicalCsrIsValid(string canonical, string altered) { - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates AssertValid(ParseText(BodyWithCsr(Quote(canonical), ValidMetadata))); AssertInvalid(ParseText(BodyWithCsr(Quote(altered), ValidMetadata))); @@ -563,8 +563,8 @@ protected override RequestBodyOutcome Parse(byte[] utf8Json) public class CheckInRequestBodyTests : RequestBodyTests { // A check-in body has no csr, so a csr member is an unknown member. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void IgnoresCsrMember() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs index 2ff83d3c37..008ee77d60 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs @@ -52,7 +52,7 @@ public class ResponseBodiesTests public static TheoryData ConfigBodyNames => new(ConfigBodies.Keys); // Each root has `certificate`, `thumbprint`, `not_before` and `not_after`, all strings. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor [Fact] public void TrustAnchorRootHasExactlyTheContractMembers() { @@ -67,7 +67,7 @@ public void TrustAnchorRootHasExactlyTheContractMembers() // CONTRACT.agent-identity.md §1: a certificate is its DER in canonical base64, // and its thumbprint is base64url(SHA-256(DER)), which the vector file gives for its root. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: reference docs/agent-identity/test-vectors.json root [Fact] public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() @@ -81,7 +81,7 @@ public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot() // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`. // The validity is given here in offsets other than UTC, and the test machine's own time zone may be another. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: spec RFC 3339 §5.6 [Fact] public void TrustAnchorGivesTheValidityOfTheRootInUtc() @@ -98,7 +98,7 @@ public void TrustAnchorGivesTheValidityOfTheRootInUtc() } // The response lists one root normally and two during a rotation. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor [Fact] public void TrustAnchorListsEveryRoot() { @@ -116,7 +116,7 @@ .. Roots(body).Select(root => TestJson.CanonicalBase64(TestJson.Members(root)["c } // The response always lists a root, and two during a rotation. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor [Fact] public void TrustAnchorRejectsNoRoots() { @@ -127,7 +127,7 @@ public void TrustAnchorRejectsNoRoots() } // Each root has a certificate, which a null root lacks. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor [Fact] public void TrustAnchorRejectsANullRoot() { @@ -141,8 +141,8 @@ public void TrustAnchorRejectsANullRoot() // CONTRACT.agent-identity.md §5.2: `authority_id`, `identity_id`, `certificate_chain` and `config`, // every one of them required. // §1: IDs are lowercase hyphenated UUIDs. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Fact] public void EnrollHasExactlyTheContractMembers() { @@ -161,7 +161,7 @@ public void EnrollHasExactlyTheContractMembers() // RFC 9562 §5.9: the nil UUID, with all 128 bits zero, stands for the absence of a UUID, // and CONTRACT.agent-identity.md §5.2 requires both IDs. // oracle: spec RFC 9562 §5.9 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Theory] [InlineData(NilIdText, IdentityIdText, "authorityId")] [InlineData(AuthorityIdText, NilIdText, "identityId")] @@ -175,7 +175,7 @@ public void EnrollRejectsANilId(string authorityId, string identityId, string pa } // `200`: `{ "certificate_chain": [ ... ] }`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew [Fact] public void RenewHasExactlyTheContractMembers() { @@ -187,7 +187,7 @@ public void RenewHasExactlyTheContractMembers() } // `config` and `renewal_requested`, the identity's request-renewal flag as a JSON boolean. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in [Theory] [InlineData(true, JsonValueKind.True)] [InlineData(false, JsonValueKind.False)] @@ -202,8 +202,8 @@ public void CheckInHasExactlyTheContractMembers(bool renewalRequested, JsonValue } // CONTRACT.agent-identity.md §4: chains are leaf-first; §1: each certificate is its DER in canonical base64. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainIsWrittenLeafFirst(string endpoint) @@ -217,8 +217,8 @@ public void ChainIsWrittenLeafFirst(string endpoint) } // A chain has any length, so the leaf alone is a chain too. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainOfTheLeafAloneIsWritten(string endpoint) @@ -232,7 +232,7 @@ public void ChainOfTheLeafAloneIsWritten(string endpoint) // A CA's subject is encoded exactly as the issuer of each certificate it issues, // so in a leaf-first chain the issuer of each certificate is the subject of the next. // oracle: spec RFC 5280 §4.1.2.6 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithTheRootFirstIsRejected(string endpoint) @@ -246,7 +246,7 @@ public void ChainWithTheRootFirstIsRejected(string endpoint) // The other root has another subject than the vector root, which issued the leaf. // oracle: spec RFC 5280 §4.1.2.6 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithACertificateThatDidNotIssueThePreviousOneIsRejected(string endpoint) @@ -261,9 +261,9 @@ public void ChainWithACertificateThatDidNotIssueThePreviousOneIsRejected(string } // CONTRACT.agent-identity.md §4 and §5.2: a chain starts with the leaf, so it holds at least one certificate. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew [Theory] [MemberData(nameof(ChainBodyNames))] public void EmptyChainIsRejected(string endpoint) @@ -275,7 +275,7 @@ public void EmptyChainIsRejected(string endpoint) } // Each certificate of a chain is written as its DER, which a null certificate lacks. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [MemberData(nameof(ChainBodyNames))] public void ChainWithANullCertificateIsRejected(string endpoint) @@ -288,8 +288,8 @@ public void ChainWithANullCertificateIsRejected(string endpoint) } // CONTRACT.agent-identity.md §5.2 and §5.6: `config` is the identity's config, in the shape of §5.2. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in [Theory] [MemberData(nameof(ConfigBodyNames))] public void BodyCarriesTheConfig(string endpoint) @@ -307,8 +307,8 @@ public void BodyCarriesTheConfig(string endpoint) // CONTRACT.agent-identity.md §5.2 and §5.6: `config` is the identity's config, // and IdentityConfig.Create sets no depth limit. // The value nests 64 levels, so the body nests 66, beyond the 64 that a System.Text.Json reader allows by default. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.6 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in [Theory] [MemberData(nameof(ConfigBodyNames))] public void BodyCarriesADeeplyNestedConfig(string endpoint) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index b4fecd9072..1662ab78b0 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -37,14 +37,14 @@ public class CertificateSigningRequestTests }, // The agent's CSR carries an empty attribute set, A0 00. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates ["empty attribute set"] = key => TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: [0xA0, 0x00]), key), // CONTRACT.agent-identity.md §4: the server ignores an extensionRequest with an empty Extensions list, // which common CSR builders emit although RFC 5280 §4.1 gives Extensions SIZE (1..MAX). // The attribute is the PKCS #9 extensionRequest (RFC 2985 §5.4.2) with one value, an empty SEQUENCE. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates ["extensionRequest with empty Extensions"] = key => { byte[] extensionRequest = TestCsr.Sequence( @@ -59,7 +59,7 @@ public class CertificateSigningRequestTests // CONTRACT.agent-identity.md §4: attributes need no "canonical DER form, // such as a sorted `SET OF`" (X.690 §11.6). // Their encodings first differ in the last arc of the OIDs, 04 then 03, so the set descends. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates ["attributes out of DER order"] = key => { static byte[] Attribute(string oid) => @@ -138,7 +138,7 @@ static byte[] Attribute(string oid) => private static readonly Dictionary> RequestsForAKeyNotNamedP256 = new() { // The key is a P-256 key. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates ["P-384 key"] = () => { using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP384); @@ -146,7 +146,7 @@ static byte[] Attribute(string oid) => }, // secp256k1 (SEC 2 §2.4.1) points also take 65 octets uncompressed, so only the curve name tells them apart. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates ["P-256 point named secp256k1"] = () => { using ECDsa key = NewKey(); @@ -268,7 +268,7 @@ static byte[] Attribute(string oid) => // test-vectors.json csr..expected, the outcome CONTRACT.agent-identity.md §4 gives each request. // oracle: reference docs/agent-identity/test-vectors.json csr - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(VectorData.CsrNames), MemberType = typeof(VectorData))] public void CsrVectorHasItsExpectedOutcome(string name) @@ -293,13 +293,13 @@ public void ValidVectorYieldsTheSubjectPublicKeyInfoOfItsKey() CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(vector.Csr)); Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray()); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules Assert.Equal(SHA256.HashData(expected), csr.SubjectPublicKeyInfoSha256.ToArray()); } // A request written by .NET rather than by the test builder. // A P-256 key and ecdsa-with-SHA256, the profile .NET writes for this key. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void AcceptsRequestWrittenByDotNet() { @@ -311,7 +311,7 @@ public void AcceptsRequestWrittenByDotNet() Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(RequestWithIgnoredContentsNames))] public void AcceptsAnySubjectAndAttributes(string request) @@ -338,7 +338,7 @@ public void RejectsBytesThatAreNotARequest(string hex) // A CSR is DER, the encoding of one request. // Bytes after the request would give one request many encodings. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void RejectsTrailingBytes() { @@ -423,7 +423,7 @@ public void RejectsExtraElement(string sequence) AssertInvalid(CertificateSigningRequest.Validate(RequestsWithAnExtraElement[sequence](key))); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(RequestForAKeyNotNamedP256Names))] public void RejectsKeyNotNamedP256(string key) @@ -433,7 +433,7 @@ public void RejectsKeyNotNamedP256(string key) // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; CONTRACT.agent-identity.md §4 needs a signing key. // oracle: spec RFC 5480 §2.1.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void RejectsKeyRestrictedToKeyAgreement() { @@ -455,7 +455,7 @@ public void RejectsKeyWithoutANamedCurve(string parameters) AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] [MemberData(nameof(PointNotUncompressedNames))] public void RejectsPointNotUncompressed(string point) @@ -469,7 +469,7 @@ public void RejectsPointNotUncompressed(string point) // Flipping the low bit of y leaves the point off the curve, // since the only other point with the same x has y' = p - y, which is y + 1 or y - 1 only for y = (p ± 1) / 2. // No private key exists for such a point, so the key whose point was altered signs the request. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void RejectsPointOffTheCurve() { @@ -515,7 +515,7 @@ public void RejectsBitStringWithUnusedBits(string bitString) } // The signature algorithm is ecdsa-with-SHA256, even when the signature verifies under it. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void RejectsAnotherSignatureAlgorithmOverASha256Signature() { @@ -540,7 +540,7 @@ public void RejectsSignatureAlgorithmWithNullParameters() } // Security invariant 1: the self-signature is the proof of possession of the request's key. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void RejectsRequestSignedByAnotherKey() { @@ -606,9 +606,9 @@ private static CertificateSigningRequest AssertValid(CsrValidationResult result) private static void AssertInvalid(CsrValidationResult result) { Assert.False(result.Succeeded); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates Assert.Equal(AgentIdentityErrorCode.InvalidRequest, result.Error.Code); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors Assert.Equal(400, result.Error.HttpStatusCode); // oracle: mutant an empty failure detail for a CSR that is followed by other bytes, // that has a version other than 0, or whose key is not an id-ecPublicKey key diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 1a3aadb153..bb802631ed 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -65,7 +65,7 @@ public void Dispose() } // The certificate is for the P-256 key of the CSR. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void CertifiesTheKeyOfTheCsr() { @@ -75,8 +75,8 @@ public void CertifiesTheKeyOfTheCsr() } // CN=, and the root's subject as the issuer. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void SubjectIsTheCommonNameOfTheIdentity() { @@ -92,8 +92,8 @@ public void SubjectIsTheCommonNameOfTheIdentity() // CONTRACT.agent-identity.md §4: SAN URI urn:uuid:. // RFC 5280 §4.2.1.6: GeneralNames is a SEQUENCE OF GeneralName, and a URI is [6] IA5String; // with a subject present, the extension is not critical. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: spec RFC 5280 §4.2.1.6 [Fact] public void AlternativeNameIsTheUrnUuidOfTheIdentity() @@ -111,7 +111,7 @@ public void AlternativeNameIsTheUrnUuidOfTheIdentity() } // EKU clientAuth. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void ExtendedKeyUsageIsClientAuthenticationOnly() { @@ -123,7 +123,7 @@ public void ExtendedKeyUsageIsClientAuthenticationOnly() } // CONTRACT.agent-identity.md §4: KeyUsage digitalSignature; RFC 5280 §4.2.1.3: marked critical. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void KeyUsageIsCriticalDigitalSignatureOnly() { @@ -165,7 +165,7 @@ public void SubjectKeyIdentifierIsNotCritical() // The leaf carries SAN, EKU and KeyUsage, and no other extension except an authority key identifier and a subject // key identifier, so no basicConstraints. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void HasExactlyTheProfileExtensions() { @@ -215,7 +215,7 @@ public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() } // The lifetime is a server setting. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void NotAfterIsNotBeforePlusTheLifetime() { @@ -225,7 +225,7 @@ public void NotAfterIsNotBeforePlusTheLifetime() } // The lifetime is capped at the issuing root's notAfter. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void NotAfterIsCappedAtTheNotAfterOfTheIssuer() { @@ -237,7 +237,7 @@ public void NotAfterIsCappedAtTheNotAfterOfTheIssuer() } // The lifetime is a server setting with a default of 90 days. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void DefaultLifetimeIs90Days() { @@ -246,7 +246,7 @@ public void DefaultLifetimeIs90Days() // The lifetime is a server setting, // and reading a moving clock twice would give the certificate a validity other than its lifetime. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void ReadsTheClockOnce() { @@ -258,7 +258,7 @@ public void ReadsTheClockOnce() // The certificate chains to the root that issued it, for client authentication. // RFC 5280 §6 path validation, as X509Chain runs it, with the CONTRACT.agent-identity.md §4 EKU clientAuth. // oracle: spec RFC 5280 §6 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void ChainsToTheIssuer() { @@ -277,8 +277,8 @@ public void ChainsToTheIssuer() } // Security invariant 2: the authority mints the subject, and a CSR supplies only a verified public key. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void IgnoresTheSubjectAndExtensionsOfTheCsr() { @@ -292,7 +292,7 @@ public void IgnoresTheSubjectAndExtensionsOfTheCsr() Assert.Equal($"CN={IdentityIdText}", leaf.Subject); // The leaf carries no basicConstraints, so a leaf can never act as a CA. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates Assert.Null(leaf.Extensions[BasicConstraintsOid]); X509EnhancedKeyUsageExtension usage = Assert.IsType(leaf.Extensions[ExtendedKeyUsageOid]); @@ -312,7 +312,7 @@ public void ThrowsForLifetimeShorterThanOneSecond(int milliseconds) } // notAfter is at most the issuer's notAfter, so an issuer that expires by notBefore leaves no validity. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [InlineData(0)] @@ -339,8 +339,8 @@ public void ThrowsWhenTheIssuerHasNoSubjectKeyIdentifier() // Code that acts for an identity takes the identity that the signature check produced. // The renew request is signed by device-a, whose identity ID the vector file gives. // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public async Task RenewalCertifiesTheKeyOfTheCsrForTheIdentityThatSignedTheRenewRequest() { @@ -358,7 +358,7 @@ public async Task RenewalCertifiesTheKeyOfTheCsrForTheIdentityThatSignedTheRenew // and only a renew request proves the identity that a renewal acts for. // An identity that proved its key for another operation never had a renew request accepted for it. // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] [InlineData(nameof(IdentityCertificateIssuer), AgentOperation.Confirm)] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs index ee42996dc1..0021dbf652 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs @@ -35,7 +35,7 @@ public class RenewalRulesTests // If the CSR key is the key of the identity's pending certificate, // the server returns 200 with that pending chain, a retry after a lost response. // A renew request presents no enrollment token, so whatever the host says about one does not count. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew [Theory] [InlineData(false)] [InlineData(true)] @@ -54,7 +54,7 @@ public async Task ReturnsThePendingCertificateForThePendingKeyOfTheIdentity(bool Assert.Null(decision.Error); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew [Theory] [MemberData(nameof(OtherIssuedKeyNames))] public async Task RejectsAnyOtherIssuedKeyWithInvalidRequest(string key) @@ -65,7 +65,7 @@ public async Task RejectsAnyOtherIssuedKeyWithInvalidRequest(string key) Assert.Equal(RenewalOutcome.Reject, decision.Outcome); Assert.Equal(AgentIdentityErrorCode.InvalidRequest, decision.Error?.Code); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors Assert.Equal(400, decision.Error?.HttpStatusCode); // oracle: mutant an empty failure detail for the CSR key of a deleted identity, of another identity, // or of a current or retired certificate of the renewing identity @@ -74,7 +74,7 @@ public async Task RejectsAnyOtherIssuedKeyWithInvalidRequest(string key) // If the CSR key was never issued, the server issues a new pending certificate // and retires the identity's previous pending certificate, if any. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew [Fact] public async Task IssuesAPendingCertificateForAKeyNeverIssued() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index dad0978872..7e8bc703d4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -217,7 +217,7 @@ public static TheoryData WrongRegistryAnswersAndCalls ["no Hello in time"] = new() { FirstMessage = null }, // Step 4: a first message that cannot be decoded is not a Hello, since no proof can be read from it. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence ["the first message could not be decoded"] = new() { FirstMessageUnreadable = true }, }; @@ -282,8 +282,8 @@ public static TheoryData WrongRegistryAnswersAndCalls TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter; // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] [MemberData(nameof(SignatureFailureNames))] public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string failure) @@ -295,8 +295,8 @@ public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string fail // CONTRACT.agent-identity.md §7.3 step 4: a Hello that proves the key but carries metadata violating §3 is // INVALID_ARGUMENT with error-code invalid_request. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public async Task HelloWithInvalidMetadataIsInvalidRequest() { @@ -323,8 +323,8 @@ public async Task FailureDetailHoldsNoMetadataKeyOrValue(string entry) ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata AssertFailed(result, AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument); Assert.DoesNotContain(LeakedKey, result.FailureDetail); Assert.DoesNotContain(LeakedValue, result.FailureDetail); @@ -342,9 +342,8 @@ public async Task FailureDetailHoldsNoMetadataKeyOrValue(string entry) // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. // CONTRACT.agent-identity.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, // so an agent that has not proved the key never learns whether its metadata would pass. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] [MemberData(nameof(PairsOfFailuresWithDifferentCodes))] @@ -358,7 +357,7 @@ public async Task EarlierFailureWins(string earlier, string later) } // Two failures that both close the stream with signature_invalid close it with signature_invalid together. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Theory] [MemberData(nameof(PairsOfFailuresWithTheSameCode))] public async Task TwoSignatureFailuresAreSignatureInvalid(string earlier, string later) @@ -374,7 +373,7 @@ public async Task TwoSignatureFailuresAreSignatureInvalid(string earlier, string // the host cannot know yet. // CONTRACT.agent-identity.md §7.4: PERMISSION_DENIED // with the trailers `error-code: identity_revoked` and `server-time`, in no stated order. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Fact] public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() { @@ -384,7 +383,7 @@ public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() AgentIdentityError error = AssertFailed(result, AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions Assert.Equal( [("error-code", "identity_revoked"), ("server-time", "2026-09-24T12:00:00Z")], error.ToRpcException(serverTime).Trailers @@ -393,8 +392,8 @@ public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() } // Step 4: a certificate that is no longer registered, or whose identity was deleted, is identity_unknown. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Fact] public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() { @@ -411,12 +410,12 @@ public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() // so the result reports the retirement. // Failing it would send a healthy agent the terminal identity_unknown. [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [InlineData(CertificateStatus.Pending, CertificateStatus.Pending)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm [InlineData(CertificateStatus.Pending, CertificateStatus.Current)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [InlineData(CertificateStatus.Current, CertificateStatus.Retired)] public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake( CertificateStatus opened, @@ -435,7 +434,7 @@ public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake( // A retired certificate passes the handshake, but the revocation of its identity still blocks it. // CONTRACT.agent-identity.md §7.4: revocation closes a stream still awaiting its Hello, with PERMISSION_DENIED and // identity_revoked. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Fact] public async Task RevocationWinsOverARetiredCertificate() { @@ -448,7 +447,7 @@ public async Task RevocationWinsOverARetiredCertificate() // CONTRACT.agent-identity.md §7.4 applies a deletion or a revocation since the opening to a stream awaiting its // Hello, so whatever the outcome, the handshake reads the registry again // for the certificate that opened the stream. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] [MemberData(nameof(ScenarioNames))] public async Task HandshakeReadsTheRegistryForTheOpeningCertificate(string scenario) @@ -485,8 +484,8 @@ public async Task RegistryAnsweringForAnotherCertificateOrIdentityThrows(string // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both. // A Hello timer that fires while the registry answers the Hello comes after the Hello, // so it does not fail the Hello. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Fact] public async Task HelloDecidesTheStreamWhenTheTimeoutFiresDuringItsLookup() { @@ -508,8 +507,8 @@ public async Task HelloDecidesTheStreamWhenTheTimeoutFiresDuringItsLookup() // and closes it for the timeout with signature_invalid. // A Hello that arrives while the registry answers the timeout comes after it, // so it cannot authenticate a stream that is being closed. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Fact] public async Task TimeoutDecidesTheStreamWhenAHelloArrivesDuringItsLookup() { @@ -530,7 +529,7 @@ public async Task TimeoutDecidesTheStreamWhenAHelloArrivesDuringItsLookup() // CONTRACT.agent-identity.md §6 reads the clock before the registry, and so does the handshake, // so a Hello that arrives in time stays in time however long the registry takes to answer. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task SlowRegistryDoesNotMakeAHelloLate() { @@ -553,7 +552,7 @@ public async Task SlowRegistryDoesNotMakeAHelloLate() // Step 4: the server verifies the proof with the key of the certificate that authenticated the opening request, // so the stream is authenticated for that certificate and its identity. // The vector file states the facts of the certificate, in Unix seconds, independently of any time zone. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a [Fact] public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() @@ -571,13 +570,13 @@ public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate() Assert.Equal(registered.CertificateStatus, identity.CertificateStatus.ToString().ToLowerInvariant()); Assert.Equal(registered.NotBefore, identity.CertificateNotBefore); Assert.Equal(registered.NotAfter, identity.CertificateNotAfter); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures Assert.Equal(AgentOperation.Connect, identity.Operation); } // The server replaces the identity's metadata with the metadata of each authenticated Hello. // Metadata is an object of string to string, whose entries have no order. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public async Task ValidHelloYieldsItsMetadata() { @@ -594,7 +593,7 @@ public async Task ValidHelloYieldsItsMetadata() // Metadata may be an empty object. // A protobuf map without entries is also how a Hello without metadata arrives, // so the rule that rejects a JSON body without metadata cannot apply here. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [Fact] public async Task HelloWithoutMetadataSucceeds() { @@ -611,8 +610,8 @@ public async Task HelloWithoutMetadataSucceeds() // CONTRACT.agent-identity.md §7.3 step 4: Welcome has correlation_id = Hello id. // CONTRACT.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. // The Hello id is in uppercase, so a Welcome that normalized it would no longer match what the agent sent. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public async Task WelcomeAnswersTheHello() { @@ -634,8 +633,8 @@ public async Task WelcomeAnswersTheHello() // Step 4: the server replies Welcome when it verifies the Hello, and the schema's Welcome carries server_time. // The clock stands still at an instant after the opening, with a fraction of a second, // so whenever the handshake reads it, that instant is the server's time. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task WelcomeCarriesTheServerTime() { @@ -653,7 +652,7 @@ public async Task WelcomeCarriesTheServerTime() // Step 3: the Hello answers the Challenge id, with a proof over the challenge bytes. // The agent answers the Challenge it received, so the handshake holds the Hello to the id and bytes as sent, // even if the host later edits the message object it sent. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task HandshakeChecksTheChallengeAsSent() { @@ -669,7 +668,7 @@ public async Task HandshakeChecksTheChallengeAsSent() // A certificate is unexpired while now ≤ notAfter, compared to the exact instant. // The Welcome shows that the clock reading that judged the certificate is the one at notAfter. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Fact] public async Task HandshakeSucceedsAtTheCertificateNotAfter() { @@ -678,7 +677,7 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); Assert.True(result.Succeeded, result.FailureDetail); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service Assert.Equal(Timestamp.FromDateTimeOffset(DeviceANotAfter), result.Welcome.Welcome.ServerTime); } @@ -686,17 +685,17 @@ public async Task HandshakeSucceedsAtTheCertificateNotAfter() // CONTRACT.agent-identity.md §7.4 and the shared intent, security invariant 8: a stream closes no later than the // notAfter of the certificate that opened it, with UNAUTHENTICATED and error-code certificate_expired, // even while it waits for its Hello, so the timeout fails as a timeout only until then. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [InlineData("a valid Hello", 1, AgentIdentityErrorCode.CertificateExpired)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [InlineData("no Hello in time", 0, AgentIdentityErrorCode.SignatureInvalid)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [InlineData("no Hello in time", 1, AgentIdentityErrorCode.CertificateExpired)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [InlineData("the first message could not be decoded", 0, AgentIdentityErrorCode.SignatureInvalid)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [InlineData("the first message could not be decoded", 1, AgentIdentityErrorCode.CertificateExpired)] public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( string end, @@ -715,9 +714,9 @@ public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( // and so does a first message that cannot be decoded, which is not a Hello. // CONTRACT.agent-identity.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the // failures of §7.3 step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] [MemberData(nameof(NoHelloNames))] public async Task NoHelloIsSignatureInvalidUnlessAClosureApplies(string noHello) @@ -730,7 +729,7 @@ public async Task NoHelloIsSignatureInvalidUnlessAClosureApplies(string noHello) } // Step 4: the server waits 10 s for the Hello. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public void HelloTimeoutIs10Seconds() { @@ -738,7 +737,7 @@ public void HelloTimeoutIs10Seconds() } // Step 4: the Hello must arrive within 10 s of the opening, 10 s included. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task HelloExactlyAtTheTimeoutSucceeds() { @@ -754,7 +753,7 @@ public async Task HelloExactlyAtTheTimeoutSucceeds() // Step 4: no Hello within 10 s closes the stream with signature_invalid. // A Hello that the host passes on after the timeout fails, so a late host timer cannot let it through. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() { @@ -772,7 +771,7 @@ public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() // The agent can answer only once the opening is verified and its challenge sent, // so the 10 s run from the end of verification, not from the earlier reading that judged the request. // Here the registry and the nonce store take 5 s, and the Hello comes 10 s after them. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task HelloTimeoutRunsFromTheEndOfTheOpening() { @@ -790,7 +789,7 @@ public async Task HelloTimeoutRunsFromTheEndOfTheOpening() // and a late Hello could authenticate a stream that already timed out. // CONTRACT.agent-identity.md §7.3 step 4 decides a stream by its first agent message or by the timeout, not both, // so a valid Hello after the handshake ended yields no identity, whether the call throws or returns. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Theory] [MemberData(nameof(HandshakeEndNames))] public async Task ValidHelloAfterTheHandshakeEndedYieldsNoIdentity(string end) @@ -821,8 +820,8 @@ public async Task FirstMessageUnreadableAfterTheHandshakeEndedYieldsNoResult(str // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: the server closes the stream with the gRPC status of the failure // and the trailers `error-code: <§5.4 code>` and `server-time: `, which the agent reads. // The contract states no order for the trailers. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] [InlineData("a Hello with the proof of another device", StatusCode.Unauthenticated, "signature_invalid")] [InlineData("a Hello with invalid metadata", StatusCode.InvalidArgument, "invalid_request")] @@ -841,7 +840,7 @@ public async Task FailureClosesTheStreamWithTheContractStatusAndTrailers( RpcException exception = result.Error.ToRpcException(serverTime); Assert.Equal(expectedStatus, exception.StatusCode); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions Assert.Equal( [("error-code", expectedCode), ("server-time", "2026-09-24T12:00:00Z")], exception.Trailers diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs index 53f9dc019b..1e2547da9a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs @@ -10,7 +10,7 @@ public class ChannelHandshakeVectorTests // Step 4: a proof that does not verify closes the stream with signature_invalid. // Each failing case changes the key, one part of the signed bytes, or the encoding of the proof. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: reference docs/agent-identity/test-vectors.json channel_proof [Theory] [MemberData(nameof(VectorData.ChannelProofCaseNames), MemberType = typeof(VectorData))] @@ -28,7 +28,7 @@ public async Task CaseMatchesItsExpectedValidity(string name) // Step 3: the proof signs the domain, a zero byte, the challenge and the connect nonce. // A proof input that differs from the vector's in any byte fails every agent's proof. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: reference docs/agent-identity/test-vectors.json channel_proof.message_hex [Fact] public void ProofInputIsTheVectorMessage() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs index cb81cc51b7..e8a3bdd457 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs @@ -30,8 +30,8 @@ public class ChannelMessagesTests public static TheoryData MessageNames => new(Messages.Keys); // CONTRACT.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Theory] [MemberData(nameof(MessageNames))] public void MessageHasALowercaseUuidId(string message) @@ -42,8 +42,8 @@ public void MessageHasALowercaseUuidId(string message) } // Steps 5 and 7: the agent acknowledges a message by its id, so no two messages share one. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [MemberData(nameof(MessageNames))] public void EachMessageHasItsOwnId(string message) @@ -53,9 +53,9 @@ public void EachMessageHasItsOwnId(string message) // CONTRACT.agent-identity.md §7.3 step 7 and channel.proto: ConfigUpdate carries the config, in the shape of §5.2, // as a UTF-8 JSON object. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §5.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Fact] public void ConfigUpdateCarriesTheConfigJson() { @@ -72,10 +72,10 @@ public void ConfigUpdateCarriesTheConfigJson() // channel.proto: the reason of RenewRequested is "admin" or "rotation"; // CONTRACT.agent-identity.md §8.3 sends "rotation" at the start of a root rotation. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service [Theory] [InlineData(RenewalRequestReason.Admin, "admin")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §8.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation [InlineData(RenewalRequestReason.Rotation, "rotation")] public void RenewRequestedCarriesItsReason(RenewalRequestReason reason, string expected) { @@ -86,7 +86,7 @@ public void RenewRequestedCarriesItsReason(RenewalRequestReason reason, string e } // The streams of a retired certificate get Reconnect{reason:"certificate_rotated"}. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Fact] public void ReconnectCarriesItsReason() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index 09073505e8..06f11b2674 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -18,8 +18,8 @@ public class ChannelOpeningTests // CONTRACT.agent-identity.md §7.3 step 2: the first message is Challenge, // with 32 random bytes fresh for this stream. // CONTRACT.agent-identity.md §1 and §7.3: ids are lowercase hyphenated UUIDs. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public async Task ChallengeHolds32Bytes() { @@ -35,7 +35,7 @@ public async Task ChallengeHolds32Bytes() // Step 2: the challenge holds 32 random bytes fresh for this stream. // A challenge reused across streams would let a proof captured on one stream authenticate another. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task EachOpeningHasItsOwnChallenge() { @@ -44,9 +44,9 @@ public async Task EachOpeningHasItsOwnChallenge() ChannelOpening first = await OpenAsync(authority, TestRequest.DefaultNonce); ChannelOpening second = await OpenAsync(authority, "EBESExQVFhcYGRobHB0eHw"); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions Assert.NotEqual(first.Challenge.Id, second.Challenge.Id); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence Assert.Equal(32, second.Challenge.Challenge.Challenge_.Length); Assert.NotEqual( first.Challenge.Challenge.Challenge_.ToByteArray(), @@ -56,7 +56,7 @@ public async Task EachOpeningHasItsOwnChallenge() // Steps 3 and 4: the proof covers the challenge as sent and the connect nonce, // and is verified with the key of the certificate that authenticated the opening, // so the opening keeps the nonce and that certificate for the handshake. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: reference docs/agent-identity/test-vectors.json channel_proof // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a [Fact] @@ -88,7 +88,7 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() // Steps 3 and 4: the agent signs the challenge it received, and the server verifies that proof. // The handshake checks the proof against the challenge as sent, // so a host that edits the message object after sending it must not change what the agent has to sign. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() { @@ -107,7 +107,7 @@ public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() // A stream closes no later than its certificate's notAfter, // even while it waits for its Hello, so the opening gives the host that time. // The vector file states it in Unix seconds, independently of any time zone. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a [Fact] public async Task OpeningGivesTheNotAfterOfItsCertificate() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs index 0052246e0a..0de9a30285 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs @@ -20,18 +20,18 @@ private static Dictionary FieldLinesWithoutABearerToken Dictionary fieldLines = new() { // (1): `token_invalid` when the `Authorization` header is missing. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules ["no field"] = [], // (1): `token_invalid` when the `Authorization` header is repeated, // even when every field line holds the same valid token. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules ["the same token twice"] = ["Bearer " + Token, "Bearer " + Token], ["a token, then an empty field"] = ["Bearer " + Token, ""], ["an empty field, then a token"] = ["", "Bearer " + Token], // (1): `token_invalid` when the `Authorization` header is not `Bearer`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules ["Basic"] = ["Basic " + Token], ["DPoP"] = ["DPoP " + Token], ["Bearers"] = ["Bearers " + Token], @@ -48,7 +48,7 @@ private static Dictionary FieldLinesWithoutABearerToken }; // (1): a field that holds no token gets `token_invalid`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules foreach ((string name, string token) in MalformedTokens) { fieldLines.Add(name, ["Bearer " + token]); @@ -60,7 +60,7 @@ private static Dictionary FieldLinesWithoutABearerToken // A complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`. // The pattern covers the whole value, so a line terminator after the token is not part of a match. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token private static Dictionary MalformedTokens => new() { ["another version"] = "dvaet2." + Bag + "." + Secret, @@ -88,8 +88,8 @@ private static Dictionary FieldLinesWithoutABearerToken // CONTRACT.agent-identity.md §2.1: the authority finds the token whose SHA-256(secret) matches, // and §2 makes it match the secret text exactly. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [Theory] [InlineData("a bearer token", "Bearer " + Token)] // The authentication scheme is case-insensitive. @@ -105,8 +105,8 @@ private static Dictionary FieldLinesWithoutABearerToken // and §2 makes the bag routing data that the authority never trusts, so it is not decoded. // The rows are a bag of an impossible base64url length, one that decodes to bytes that are not JSON, // and the bag of {"u":"https://other/psu"}. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [InlineData("a one-character bag", "Bearer dvaet1.A." + Secret)] [InlineData("a bag that is not JSON", "Bearer dvaet1._-_-." + Secret)] [InlineData("the bag of another URL", "Bearer dvaet1.eyJ1IjoiaHR0cHM6Ly9vdGhlci9wc3UifQ." + Secret)] @@ -124,7 +124,7 @@ public void ReadsTheSecretHashOfABearerToken(string name, string fieldValue) Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); } - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [MemberData(nameof(FieldLinesWithoutABearerTokenNames))] public void RejectsFieldLinesWithoutABearerToken(string fieldLines) @@ -137,7 +137,7 @@ public void RejectsFieldLinesWithoutABearerToken(string fieldLines) // A token is at most 4096 bytes. // The bag is not decoded, so a bag of any length in the alphabet sets the token length. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [Fact] public void AcceptsATokenOf4096Bytes() { @@ -152,8 +152,8 @@ public void AcceptsATokenOf4096Bytes() // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes; // §2.1 (1): a field that holds no token gets `token_invalid`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Fact] public void RejectsATokenLongerThan4096Bytes() { @@ -168,8 +168,8 @@ public void RejectsATokenLongerThan4096Bytes() // The authority matches the `secret` text exactly. // Ending in '9' (sextet 61) instead of '8' (sextet 60) sets a pad bit, // so a lenient decoder reads the same 32 bytes, but the text, and so its hash, differs. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Fact] public void HashesANonCanonicalSecretAsItsText() { @@ -206,7 +206,7 @@ public void FailureRevealsNoPartOfTheToken(string[] fieldLines) { EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(fieldLines); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules Assert.False(result.Succeeded); EnrollmentTokenAssert.RevealsNoPart(result.FailureDetail, Bag, Secret); EnrollmentTokenAssert.RevealsNoPart(result.Error.Message, Bag, Secret); @@ -230,7 +230,7 @@ private static void AssertTokenInvalid(EnrollmentAuthorizationResult result) { Assert.False(result.Succeeded); Assert.Equal(AgentIdentityErrorCode.TokenInvalid, result.Error.Code); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors Assert.Equal(401, result.Error.HttpStatusCode); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index 488e70cb2a..7de8b3ccf5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -24,11 +24,11 @@ public class EnrollmentRulesTests // A token creates an identity when it exists, has not been deleted, // has not expired, and fewer than `max_uses` identities have been created with it; (6) a new identity. // The rows are a usable token, one that expires one tick after now, and one that can create one more identity. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [InlineData(TimeSpan.TicksPerDay, 3)] // `token_expired` only when `now ≥ expires_at`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [InlineData(1L, 3)] [InlineData(TimeSpan.TicksPerDay, 9)] public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int identitiesCreated) @@ -44,25 +44,25 @@ public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int // When several outcomes apply, the earliest wins: // (4) `token_invalid` for a deleted token, then `token_expired` for an expired one; (5) `token_exhausted`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] // `token_expired` when `now ≥ expires_at`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [InlineData(false, 0L, 10, 3, AgentIdentityErrorCode.TokenExpired)] // A token creates an identity only while fewer than `max_uses` identities have been created with it. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [InlineData(false, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 10, 11, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 0, 0, AgentIdentityErrorCode.TokenExhausted)] // A deleted token authenticates only for an idempotent replay; // every other use returns `token_invalid`, whether or not the token has also expired or been exhausted. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [InlineData(true, TimeSpan.TicksPerDay, 10, 3, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, 0L, 10, 3, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenInvalid)] [InlineData(true, 0L, 10, 10, AgentIdentityErrorCode.TokenInvalid)] // (4) `token_expired` comes before (5) `token_exhausted`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [InlineData(false, 0L, 10, 10, AgentIdentityErrorCode.TokenExpired)] public void RejectsANewKeyWithTheEarliestTokenOutcome( bool deleted, @@ -82,7 +82,7 @@ public void RejectsANewKeyWithTheEarliestTokenOutcome( // same token → 200 with that identity, without counting a use. // The key lookup (3) comes before (4) and (5), and a deleted token authenticates for this replay, // so the replay wins whether the token is deleted, expired or exhausted. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [InlineData(false, false, false)] [InlineData(true, false, false)] @@ -106,7 +106,7 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, // and instead of the replay, which needs a non-revoked identity; // the key of a pending or retired certificate of a non-revoked identity → `invalid_request`, // instead of the replay, which needs the current certificate. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [InlineData(false, false, CertificateStatus.Current, false, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(false, false, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.InvalidRequest)] @@ -118,7 +118,7 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, [InlineData(true, false, CertificateStatus.Pending, false, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(true, false, CertificateStatus.Retired, false, false, AgentIdentityErrorCode.InvalidRequest)] // (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [InlineData(false, false, CertificateStatus.Current, true, false, AgentIdentityErrorCode.InvalidRequest)] [InlineData(false, false, CertificateStatus.Current, false, true, AgentIdentityErrorCode.InvalidRequest)] [InlineData(true, true, CertificateStatus.Current, true, false, AgentIdentityErrorCode.IdentityRevoked)] @@ -145,7 +145,7 @@ public void RejectsAnIssuedKeyByTheFirstMatchingRule( // and (3) the result of the key lookup comes before (4) `token_expired` and (5) `token_exhausted`. // The authority keeps only the SPKI SHA-256 and issuance time of a deleted identity's keys, // so no other key rule applies to them. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [InlineData(false, false)] [InlineData(true, true)] @@ -161,7 +161,7 @@ public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, boo // (3): a deleted token gets `token_invalid` for anything but an idempotent replay, // whatever the CSR public key matches. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [MemberData(nameof(KeysButTheReplayNames))] public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(string key) @@ -189,7 +189,7 @@ public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, // Statuses start at 1, so a status that the host never set is not a valid one, // and a value after the last status is not one either. // Framework Design Guidelines: validate enum arguments, and throw the most derived ArgumentException naming them. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions // oracle: spec Framework Design Guidelines §Validating Arguments // oracle: spec Framework Design Guidelines §Using Standard Exception Types [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index e916ee2843..e0383739b1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -12,7 +12,7 @@ public class EnrollmentTokenTests private const string SecretText = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; // `..`, where `secret` is base64url of 32 random bytes. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [Fact] public void CreateEncodesTheGeneratorBytesAsTheSecret() { @@ -24,8 +24,8 @@ public void CreateEncodesTheGeneratorBytesAsTheSecret() // The authority matches the `secret` text exactly, // so it hashes the 43 characters rather than the bytes they encode. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Fact] public void SecretHashIsTheSha256OfTheSecretText() { @@ -37,8 +37,8 @@ public void SecretHashIsTheSha256OfTheSecretText() // A complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`, // and its secret is the canonical base64url encoding of the 32 random bytes. // The random values are seeded, so a failure reproduces. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void CreateWritesEverySecretInTheContractPattern() { @@ -62,8 +62,8 @@ public void CreateWritesEverySecretInTheContractPattern() // and finds the token of an enroll request by the hash that ReadBearerToken returns. // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly, // so both hashes are the SHA-256 of the 43 ASCII characters of the token's secret. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §2.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Fact] public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() { @@ -90,7 +90,7 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() // `bag` is the base64url encoding of a UTF-8 JSON object whose `u` is the product base URL, // so a URL already in the form of `u` is written as given. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [Theory] [InlineData("https://host/dvls")] [InlineData("https://host")] @@ -107,11 +107,11 @@ public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl) // but a token it creates holds in `u` the form that the source of the row decides. [Theory] // `u` has no trailing slash, and the agent removes trailing slashes, so `https://host/` names `https://host`. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [InlineData("https://host/dvls/", "https://host/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [InlineData("https://host/dvls//", "https://host/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [InlineData("https://host/", "https://host")] // White space around a URI is not part of it. // oracle: spec RFC 3986 §Appendix C @@ -130,7 +130,7 @@ public void CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. private static readonly string[] UnacceptableUrls = [ - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token "", "/dvls", "host/dvls", @@ -138,7 +138,7 @@ public void CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, "wss://host/dvls", // oracle: spec RFC 9110 §4.2.2 "https:///dvls", - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token "https://host/dvls?tenant=1", "https://host/dvls?", "https://host/dvls#top", @@ -150,7 +150,7 @@ public void CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, // The unacceptable URLs, and a URL one byte longer than the longest that fits in a token // (CONTRACT.agent-identity.md §2). - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls, UrlOfLength(3026)]); // oracle: spec Framework Design Guidelines §Validating Arguments @@ -169,7 +169,7 @@ public void CreateRejectsAnotherUrl(string productBaseUrl) // A token's bag holds `u` alone, without insignificant white space, which fixes the longest URL that fits. // A 3025-byte URL makes a 3033-byte bag object, a 4044-character bag and a 4095-byte token. // One more byte makes a 4046-character bag and a 4097-byte token, since no base64url text has 4045 characters. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token // oracle: intent dotnet/AgentIdentity/INTENT.md §Enrollment tokens [Fact] public void CreateAcceptsTheLongestUrlThatFits() @@ -251,10 +251,10 @@ private static Creation CreateFrom(string productBaseUrl) private static (string Name, string? Value)[] BagMembers(EnrollmentToken token) { string bag = token.Value.Split('.')[1]; - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token Assert.True(Base64Url.IsValid(bag)); byte[] json = Base64Url.DecodeFromChars(bag); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions Assert.Equal(bag, Base64Url.EncodeToString(json)); using JsonDocument document = JsonDocument.Parse(json); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs index cb061dba33..8afeae643e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs @@ -38,21 +38,21 @@ public class FriendlyNameFormatTests // the enrollment token's name; CONTRACT.agent-identity.md §3 lists the known metadata keys. // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{hostname}", "pc-042")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{fqdn}", "pc-042.corp.example")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{domain}", "corp.example")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{os_name}", "Windows")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{os_version}", "10.0.26100")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{arch}", "x86_64")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{agent_version}", "2026.3.0")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{machine_id}", "4c4c4544-0042-3510-8052-b4c04f4a4e32")] [InlineData("{token_name}", "Fleet")] // `{{` and `}}` escape braces, and other text is literal. @@ -61,11 +61,11 @@ public class FriendlyNameFormatTests [InlineData("}}", "}")] [InlineData("}}{{", "}{")] [InlineData("{{hostname}}", "{hostname}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{{{hostname}}}", "{pc-042}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("Laptop {hostname} ({os_name}, {arch})", "Laptop pc-042 (Windows, x86_64)")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{token_name}/{hostname}{hostname}", "Fleet/pc-042pc-042")] public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expected) { @@ -86,25 +86,25 @@ public void EvaluatesThePlaceholdersAndTheLiteralText(string format, string expe [InlineData("{{hostname}}}")] [InlineData("{{}")] [InlineData("}{")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{Hostname}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{HOSTNAME}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{ hostname }")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{hostname }")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{host{name}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{token}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{name}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{custom_key}")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §3 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata [InlineData("{0}")] public void TryParseRejectsAnUnpairedBraceOrAnUnknownPlaceholder(string format) { @@ -294,7 +294,7 @@ public void TruncatesTo255ScalarValuesBetweenTwoTrims(string tokenName) // INTENT.agent-identity.md, "Enrollment": an empty result falls back to the identity ID, // which CONTRACT.agent-identity.md §1 writes as a lowercase hyphenated UUID. // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [InlineData("{fqdn}", TokenName)] [InlineData("", TokenName)] @@ -317,7 +317,7 @@ public void DefaultFormatIsTheHostname() AgentMetadata withoutHostname = Validated(new Dictionary { ["fqdn"] = "pc-042.corp.example" }); Assert.Equal("pc-042", FriendlyNameFormat.Default.Evaluate(Metadata, TokenName, IdentityId)); - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions Assert.Equal( "0f8fad5b-d9cb-469f-a165-70867728950e", FriendlyNameFormat.Default.Evaluate(withoutHostname, TokenName, IdentityId)); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index 294bfb962f..cf83b09dde 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -19,7 +19,7 @@ public class AgentIdentityErrorCodeTests { AgentIdentityErrorCode.Unavailable, "unavailable" }, }; - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Theory] [MemberData(nameof(WireValues))] public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expected) @@ -30,23 +30,23 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec // CONTRACT.agent-identity.md §5.4, column "HTTP", for the codes to which it gives one status. public static TheoryData HttpStatusCodes => new() { - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.TokenInvalid, 401 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.TokenExhausted, 403 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.TokenExpired, 401 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.IdentityRevoked, 403 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.IdentityUnknown, 401 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.CertificateExpired, 401 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.SignatureInvalid, 401 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.ClockSkew, 401 }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.InvalidRequest, 400 }, }; @@ -54,7 +54,7 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec // with the lowest and the highest status of the class: `unavailable` is 5xx. public static TheoryData HttpStatusClasses => new() { - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.Unavailable, 500, 599 }, }; @@ -87,18 +87,18 @@ public void HttpStatusCodeIsInTheContractClass(AgentIdentityErrorCode code, int { AgentIdentityErrorCode.TokenExhausted, StatusCode.Unauthenticated }, // oracle: mutant the gRPC mapping throws for token_expired as for an undefined code { AgentIdentityErrorCode.TokenExpired, StatusCode.Unauthenticated }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination { AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination { AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination { AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination { AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination { AgentIdentityErrorCode.ClockSkew, StatusCode.Unauthenticated }, - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination { AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument }, // oracle: spec gRPC status codes §Status codes and their use in gRPC { AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable }, @@ -115,7 +115,7 @@ public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, Statu // CONTRACT.agent-identity.md §5.4 lists exactly these ten codes. // A code added without a contract row would otherwise go untested in every table above. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Fact] public void EveryCodeHasAContractRow() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs index d3ced3ccff..c353cd3336 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs @@ -38,7 +38,7 @@ public void FromCarriesTheCodeWithItsContractStatus(AgentIdentityErrorCode code, AgentIdentityError error = AgentIdentityError.From(code); // The body's `error` is the code. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors Assert.Equal(code, error.Code); Assert.Equal(expectedStatus, error.HttpStatusCode); } @@ -57,17 +57,17 @@ public void FromRejectsAnUndefinedCode() // unknown route → 404 invalid_request; wrong method → 405 invalid_request; body too large → 413 invalid_request; // an unexpected server failure → 500 unavailable; overload or a missing dependency → 503 unavailable. [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [InlineData(nameof(AgentIdentityError.MalformedBody), 400, AgentIdentityErrorCode.InvalidRequest)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [InlineData(nameof(AgentIdentityError.UnknownRoute), 404, AgentIdentityErrorCode.InvalidRequest)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [InlineData(nameof(AgentIdentityError.MethodNotAllowed), 405, AgentIdentityErrorCode.InvalidRequest)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [InlineData(nameof(AgentIdentityError.BodyTooLarge), 413, AgentIdentityErrorCode.InvalidRequest)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [InlineData(nameof(AgentIdentityError.UnexpectedFailure), 500, AgentIdentityErrorCode.Unavailable)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors // oracle: spec RFC 9110 §15.6.4 [InlineData(nameof(AgentIdentityError.ServiceUnavailable), 503, AgentIdentityErrorCode.Unavailable)] public void FrameworkLevelErrorHasItsContractStatusAndCode( @@ -83,7 +83,7 @@ public void FrameworkLevelErrorHasItsContractStatusAndCode( // Every error body, framework-level failures included, carries a `message` of human text. // The contract leaves its wording free, so only its presence is checked. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Theory] [MemberData(nameof(PublicErrorNames))] public void ErrorHasAHumanMessage(string name) @@ -95,8 +95,8 @@ public void ErrorHasAHumanMessage(string name) // CONTRACT.agent-identity.md §5.4 body, with the snake_case keys and the timestamp format of §1. // Parsing the raw bytes also rejects a byte order mark, which RFC 8259 §8.1 forbids in a JSON text. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: spec RFC 8259 §8.1 [Fact] public void JsonBodyHasExactlyTheContractMembers() @@ -117,12 +117,12 @@ public void JsonBodyHasExactlyTheContractMembers() // §7.3 step 4: invalid `Hello` metadata gets INVALID_ARGUMENT. // §7.4: so does a message that cannot be decoded on an authenticated stream. [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [InlineData(AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [InlineData(AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied)] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.3 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [InlineData(AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument)] // oracle: spec gRPC status codes §Status codes and their use in gRPC [InlineData(AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable)] @@ -134,8 +134,8 @@ public void RpcExceptionHasTheChannelStatus(AgentIdentityErrorCode code, StatusC } // Trailers `error-code: <§5.4 code>` and `server-time: `, in no stated order. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §7.4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Fact] public void RpcExceptionHasExactlyTheContractTrailers() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs index f7b9a792f0..ead2631a82 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs @@ -17,11 +17,11 @@ .. TestVectors.Instance.HttpSignature.Registered.Select( // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("2026-09-24T12:00:00+00:00", "2026-09-24T12:00:00Z")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("2026-09-24T14:00:00+02:00", "2026-09-24T12:00:00Z")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [InlineData("2026-09-24T23:30:00-01:00", "2026-09-25T00:30:00Z")] public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected) { @@ -32,7 +32,7 @@ public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected // RFC 3339 dates are Gregorian, and §5.6 writes them in ASCII digits with fixed separators, whatever the culture. // A culture-sensitive format would write the Thai Buddhist year 2569 and the time separator of this culture. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: spec RFC 3339 §1 // oracle: spec RFC 3339 §5.6 [Fact] @@ -58,8 +58,8 @@ public void FormatTimestampIgnoresTheCurrentCulture() // CONTRACT.agent-identity.md §1: a thumbprint is base64url(SHA-256(certificate DER)). // CONTRACT.agent-identity.md §6: a request's `keyid` is the thumbprint of the certificate that signs it. // The vector file gives each certificate with its thumbprint, or a registered certificate with its keyid. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures // oracle: reference docs/agent-identity/test-vectors.json [Theory] [MemberData(nameof(VectorCertificateNames))] @@ -71,7 +71,7 @@ public void CertificateHasTheVectorThumbprint(string name) } // Servers accept request bodies up to 64 KiB. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-identity-errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Fact] public void MaxRequestBodyBytesIs64KiB() { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs index 5fc85bf0a2..ad06888389 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs @@ -55,7 +55,7 @@ public class StrictBase64Tests { "-_8", [0xFB, 0xFF] }, }; - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions // oracle: spec RFC 4648 §10 // oracle: spec RFC 4648 §4 // oracle: spec RFC 4648 §5 @@ -69,8 +69,8 @@ public void DecodesTheCanonicalForm(string encoding, string encoded, byte[] expe // Each row is a form that RFC 4648 §4, or §5 without padding, does not produce for any byte string, // so accepting it would give one value two encodings. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions // oracle: spec RFC 4648 §4 // oracle: spec RFC 4648 §5 [Theory] @@ -101,7 +101,7 @@ public void RejectsANonCanonicalForm(string encoding, string form, string encode // RFC 4648 §10 vectors with the number of bytes they encode; padding characters encode none. // oracle: spec RFC 4648 §10 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions [Theory] [InlineData("base64", "", 0)] [InlineData("base64", "Zg==", 1)] @@ -139,7 +139,7 @@ public void RejectsAnotherDecodedLength(string encoding, string encoded, int len } // CONTRACT.agent-identity.md Definitions: base64url is RFC 4648 §5 without padding. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions // oracle: spec RFC 4648 §5 // oracle: spec RFC 4648 §10 [Theory] @@ -154,7 +154,7 @@ public void Base64UrlEncodesWithoutPadding(string expected, byte[] value) // Random values of every length up to 96 bytes end in each final-quantum shape many times. // oracle: spec RFC 4648 §4 // oracle: spec RFC 4648 §5 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions [Fact] public void DecodesAndEncodesEveryLengthLikeTheBcl() { @@ -179,8 +179,8 @@ public void DecodesAndEncodesEveryLengthLikeTheBcl() // so trying every final quantum of one or two bytes checks that each value has exactly one accepted encoding. // oracle: spec RFC 4648 §3.5 // oracle: spec RFC 4648 §4 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §Definitions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions [Theory] [InlineData("base64")] [InlineData("base64url")] diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index c9dad092bf..39060f8177 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -171,10 +171,10 @@ Implement the `AgentChannel` service with that package; a feed that serves this [§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#51-get-trust-anchor [§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#52-post-enroll [§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#53-post-renew -[§5.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#agent-identity-errors +[§5.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#errors [§5.5]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#55-post-confirm [§5.6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#56-post-check-in-http-fallback -[§6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#agent-identity-signatures +[§6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#signatures [§7.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#71-service [§7.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#73-sequence [§7.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#74-termination From d766d85ce936bf1de5e04851a1e1303c047517d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 22 Oct 2026 11:58:26 +0900 Subject: [PATCH 65/93] docs(dotnet-agent-identity): link contract sections by their anchors The README linked most contract sections through fragments derived from their headings, which break when a heading is renamed or renumbered. Every contract link now uses the anchor the contract places before the section. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/AgentIdentity/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 39060f8177..9d62c47d8a 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -167,18 +167,18 @@ Implement the `AgentChannel` service with that package; a feed that serves this [contract]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md [repo]: https://github.com/Devolutions/devolutions-gateway -[§2.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#21-authority-behavior -[§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#51-get-trust-anchor -[§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#52-post-enroll -[§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#53-post-renew +[§2.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#token-rules +[§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#trust-anchor +[§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#enroll +[§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#renew [§5.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#errors -[§5.5]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#55-post-confirm -[§5.6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#56-post-check-in-http-fallback +[§5.5]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#confirm +[§5.6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#check-in [§6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#signatures -[§7.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#71-service -[§7.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#73-sequence -[§7.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#74-termination -[§8.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#82-server-certificate-state -[§8.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#83-root-rotation +[§7.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#channel-service +[§7.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#channel-sequence +[§7.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#channel-termination +[§8.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#server-certificate-state +[§8.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#root-rotation [invariants]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/INTENT.agent-identity.md#security-invariants [errors]: #errors From 4392418a013179474b3b0a0ba31637afae2ddb88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 02:33:35 +0900 Subject: [PATCH 66/93] fix(dotnet-agent-identity): accept any encoding of the CSR subject The authority ignores the CSR's subject and attributes, and the contract does not require them in canonical DER form, since nothing it decides depends on them. The parser still read the subject and the attributes element as DER, so a subject or an attribute set whose length took more octets than DER allows failed the request with invalid_request. The subject is now read as a BER SEQUENCE and the attributes as a BER constructed [0], neither parsed further. The rest stays DER: a CSR is DER, the self-signature covers the request info as received, and each key must have a single SubjectPublicKeyInfo. Tests now also pin that the request info, the version and the SubjectPublicKeyInfo stay DER. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CertificateSigningRequestTests.cs | 58 +++++++++++++++++-- .../Certificates/CertificateSigningRequest.cs | 36 +++++++----- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 1662ab78b0..0032f359c4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -69,6 +69,31 @@ static byte[] Attribute(string oid) => attributes: TestCsr.Tlv(0xA0, Attribute("1.2.4"), Attribute("1.2.3"))); return TestCsr.Request(info, key); }, + + // The subject need not be DER, which writes its length in the fewest octets (X.690 §10.1). + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["subject with a longer length"] = key => + { + byte[] subject = TestCsr.TlvWithLongerLength(0x30, []); + return TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject), key); + }, + + // Nor need anything inside it, here a relative distinguished name CN=a whose length takes one octet more. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["subject with a longer length inside"] = key => + { + byte[] commonName = TestCsr.Sequence(TestCsr.Oid("2.5.4.3"), TestCsr.Tlv(0x0C, "a"u8.ToArray())); + byte[] subject = TestCsr.Sequence(TestCsr.TlvWithLongerLength(0x31, commonName)); + return TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject), key); + }, + + // Nor need the attributes, here an empty attribute set whose length takes one octet more. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["attributes with a longer length"] = key => + { + byte[] attributes = TestCsr.TlvWithLongerLength(0xA0, []); + return TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: attributes), key); + }, }; // X.690 §10.1: DER writes each length in the fewest octets. @@ -77,14 +102,37 @@ static byte[] Attribute(string oid) => // The self-signature covers only the request info, so the request still verifies with a longer outer length. ["CertificationRequest"] = () => TestCsr.TlvWithLongerLength(0x30, ValidVectorContents()), - // The same rule inside the request info, which a test can only change by signing the request again. - ["subject"] = () => + // The same for the request info and its version, which a test can only change by signing the request again. + ["CertificationRequestInfo"] = () => { using ECDsa key = NewKey(); - byte[] subject = TestCsr.TlvWithLongerLength(0x30, []); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject); + byte[] spki = key.ExportSubjectPublicKeyInfo(); + byte[] info = TestCsr.TlvWithLongerLength( + 0x30, + [.. TestCsr.Integer(0), .. TestCsr.EmptySubject, .. spki, .. TestCsr.EmptyAttributes]); return TestCsr.Request(info, key); }, + ["version"] = () => + { + using ECDsa key = NewKey(); + byte[] info = TestCsr.Sequence( + TestCsr.TlvWithLongerLength(0x02, [0x00]), + TestCsr.EmptySubject, + key.ExportSubjectPublicKeyInfo(), + TestCsr.EmptyAttributes); + return TestCsr.Request(info, key); + }, + + // Another encoding of the key's SubjectPublicKeyInfo would give the key a second SHA-256. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["SubjectPublicKeyInfo"] = () => + { + using ECDsa key = NewKey(); + byte[] spki = TestCsr.TlvWithLongerLength( + 0x30, + [.. TestCsr.P256Algorithm(), .. TestCsr.BitString(TestCsr.UncompressedPoint(key))]); + return TestCsr.Request(TestCsr.RequestInfo(spki), key); + }, }; // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker, @@ -347,7 +395,9 @@ public void RejectsTrailingBytes() AssertInvalid(CertificateSigningRequest.Validate([.. valid, 0x00])); } + // A CSR is DER, except for the subject and the attributes, which the server ignores. // oracle: spec X.690 §10.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions [Theory] [MemberData(nameof(RequestWithALongerLengthNames))] public void RejectsLengthInMoreOctetsThanNeeded(string element) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs index 839c7c655f..6735b52d8f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs @@ -11,7 +11,9 @@ namespace Devolutions.AgentIdentity; /// Only and the request body parsers create instances, /// so an instance always holds an uncompressed P-256 key that signed its request. /// The request's subject, attributes and extensions are ignored and not kept, -/// since an identity is never derived from claims in a CSR. +/// since an identity is never derived from claims in a CSR, +/// so the subject and the attributes need only be BER elements of their type; +/// the rest of the request is DER, as CONTRACT.agent-identity.md §1 requires of a CSR. /// public sealed class CertificateSigningRequest { @@ -49,15 +51,16 @@ private CertificateSigningRequest(byte[] subjectPublicKeyInfo) public ReadOnlyMemory SubjectPublicKeyInfoSha256 { get; } /// - /// Validates a DER-encoded PKCS #10 certificate signing request (RFC 2986). + /// Validates a PKCS #10 certificate signing request (RFC 2986). /// /// - /// A valid request is DER without trailing bytes and has version 0. + /// A valid request has version 0 and no trailing bytes, and is DER except for the subject and the attributes, + /// which the server ignores: each need only be a BER element of its type, a SEQUENCE and a constructed [0]. /// Its key is id-ecPublicKey on the named curve P-256, as an uncompressed point on the curve (RFC 5480). /// Its signature algorithm is ecdsa-with-SHA256 without parameters (RFC 5758), /// and its signature verifies under its key over the exact bytes of its certificationRequestInfo. /// - /// The DER encoding of the request. + /// The encoding of the request, DER except for its subject and attributes. public static CsrValidationResult Validate(ReadOnlySpan der) { try @@ -83,24 +86,31 @@ private static CsrValidationResult Parse(byte[] der) } ReadOnlyMemory requestInfo = request.PeekEncodedValue(); - AsnReader info = request.ReadSequence(); + ReadOnlyMemory fields = request.PeekContentBytes(); + _ = request.ReadSequence(); AsnReader signatureAlgorithm = request.ReadSequence(); byte[] signature = request.ReadBitString(out int signatureUnusedBits); request.ThrowIfNotEmpty(); - if (!info.TryReadInt32(out int version) || version != 0) + if (!AsnDecoder.TryReadInt32(fields.Span, AsnEncodingRules.DER, out int version, out int length) || version != 0) { return CsrValidationResult.Failure("The CSR version is not 0."); } - // The subject and the attributes are read only as DER elements of their type, and never parsed, - // because the server ignores them; the self-signature still covers their bytes. - _ = info.ReadSequence(); - ReadOnlyMemory subjectPublicKeyInfo = info.PeekEncodedValue(); - AsnReader subjectPublicKeyInfoReader = info.ReadSequence(); - _ = info.ReadSetOf(skipSortOrderValidation: true, AttributesTag); - info.ThrowIfNotEmpty(); + // The subject and the attributes are read only as BER elements of their type, and never parsed, + // because nothing the server decides depends on them; the self-signature still covers their bytes. + fields = fields[length..]; + AsnDecoder.ReadSequence(fields.Span, AsnEncodingRules.BER, out _, out _, out length); + fields = fields[length..]; + AsnDecoder.ReadSequence(fields.Span, AsnEncodingRules.DER, out _, out _, out length); + ReadOnlyMemory subjectPublicKeyInfo = fields[..length]; + + AsnReader attributes = new(fields[length..], AsnEncodingRules.BER); + _ = attributes.ReadSetOf(AttributesTag); + attributes.ThrowIfNotEmpty(); + + AsnReader subjectPublicKeyInfoReader = new AsnReader(subjectPublicKeyInfo, AsnEncodingRules.DER).ReadSequence(); if (ReadP256Point(subjectPublicKeyInfoReader, out ECPoint point) is { } keyFailure) { return CsrValidationResult.Failure(keyFailure); From 8ae6f6947824207dd02652c6640356fdf18aaea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 02:49:16 +0900 Subject: [PATCH 67/93] test(dotnet-agent-identity): build the specified-curve key by hand The specified-curve row of RejectsKeyWithoutANamedCurve took its key from ExportExplicitParameters, which throws PlatformNotSupportedException on macOS, so the suite could not pass there. The row now writes the ECParameters of RFC 3279 section 2.3.5 itself, with the secp256r1 values of SEC 2 section 2.4.2; these are the bytes .NET writes on Windows. The request is still signed by the key whose point it carries, so it fails only for its curve parameters. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CertificateSigningRequestTests.cs | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 0032f359c4..1248329ae0 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -216,11 +216,9 @@ static byte[] Attribute(string oid) => // The specified form of the P-256 domain parameters, // which .NET writes for a key imported with explicit parameters. - ["specifiedCurve"] = key => - { - using ECDsa explicitKey = ECDsa.Create(key.ExportExplicitParameters(includePrivateParameters: false)); - return explicitKey.ExportSubjectPublicKeyInfo(); - }, + ["specifiedCurve"] = key => TestCsr.SubjectPublicKeyInfo( + TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), P256SpecifiedDomain()), + TestCsr.UncompressedPoint(key)), }; // CONTRACT.agent-identity.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. @@ -719,6 +717,50 @@ private static byte[] RequestWithPoint(ECDsa signer, byte[] point) return TestCsr.Request(TestCsr.RequestInfo(spki), signer); } + // The ECParameters of P-256 (RFC 3279 §2.3.5), which SEC 1 §C.2 calls SpecifiedECDomain, + // written by hand because not every platform exports a named curve's explicit parameters. + // Each value is that of secp256r1 in SEC 2 §2.4.2. + private static byte[] P256SpecifiedDomain() + { + AsnWriter writer = new(AsnEncodingRules.DER); + using (writer.PushSequence()) + { + // version: ecpVer1. + writer.WriteInteger(1); + + // fieldID: prime-field, { id-fieldType 1 } (RFC 3279 §2.3.5), whose parameters are the prime p. + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier("1.2.840.10045.1.1"); + writer.WriteIntegerUnsigned( + Convert.FromHexString("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF")); + } + + // curve: the coefficients a and b, each a field element of 32 octets (SEC 1 §2.3.5), and no seed. + using (writer.PushSequence()) + { + writer.WriteOctetString( + Convert.FromHexString("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC")); + writer.WriteOctetString( + Convert.FromHexString("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B")); + } + + // base: the base point G in uncompressed form (SEC 1 §2.3.3). + writer.WriteOctetString(Convert.FromHexString( + "046B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296" + + "4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5")); + + // order: n. + writer.WriteIntegerUnsigned( + Convert.FromHexString("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551")); + + // cofactor: h. + writer.WriteInteger(1); + } + + return writer.Encode(); + } + // Each attempt signs a request info with another subject, so that the search does not rely on random signatures. private static (byte[] Info, byte[] Signature) SignedInfoWhere(ECDsa key, Func condition) { From cf46488b2f37a7de078b620e6b8c928a3039af0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 02:54:25 +0900 Subject: [PATCH 68/93] feat(dotnet-agent-identity)!: issue a new identity's certificate only from its enrollment decision Issuing a new identity's certificate for a caller-supplied Guid let a host certify a key for an identity that no enrollment created. A new identity's certificate is now issued only from the EnrollmentDecision that creates it, and no public issuance path takes an identity ID. EnrollmentRules.Decide mints a random identity ID for each decision to create an identity and exposes it as EnrollmentDecision.NewIdentityId, which is null for every other outcome. The new Issue overload takes that decision and throws ArgumentException for a decision of another outcome. The Guid overload becomes the private implementation that the enrollment and renewal overloads share. Only the library constructs an EnrollmentDecision, so a host cannot forge one for another ID. BREAKING CHANGE: IdentityCertificateIssuer.Issue(Guid, ...) is removed; issue a new identity's certificate with IdentityCertificateIssuer.Issue(EnrollmentDecision, ...), passing the CreateIdentity decision of EnrollmentRules.Decide, and create the identity under its NewIdentityId. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/AuthenticationTypeTests.cs | 15 +++- .../IdentityCertificateIssuerTests.cs | 90 +++++++++++++++++-- .../Enrollment/EnrollmentRulesTests.cs | 47 ++++++++++ .../Certificates/IdentityCertificateIssuer.cs | 44 +++++++-- .../Enrollment/EnrollmentDecision.cs | 28 +++++- .../Enrollment/EnrollmentOutcome.cs | 2 +- .../Enrollment/EnrollmentRules.cs | 4 +- dotnet/AgentIdentity/README.md | 8 +- 8 files changed, 210 insertions(+), 28 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs index b168665230..c20b9ba2a7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs @@ -3,7 +3,7 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// The types that only request authentication and the channel handshake create. +/// The types that only request authentication, the channel handshake and the enrollment rules create. /// /// /// INTENT.md: code that acts for an identity takes it as a type only the signature check can create, @@ -19,6 +19,10 @@ public class AuthenticationTypeTests // so an opening built by a caller would yield an identity that no signature check produced. // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity typeof(ChannelOpening), + // A new identity's certificate is issued from the decision that creates it, + // so a decision built by a caller would certify a key for an identity that no enrollment created. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + typeof(EnrollmentDecision), // oracle: mutant a public AuthenticationResult constructor typeof(AuthenticationResult), // oracle: mutant a public ChannelOpeningResult constructor @@ -32,7 +36,8 @@ public class AuthenticationTypeTests // Code that acts for an identity takes it as a type only the signature check can create. // A public or protected constructor would let a caller build an identity from any values, // or a subclass of one with its own constructor. - // The other types carry an identity, or the opening that becomes one, out of the check. + // The other types carry an identity, the opening that becomes one, or the decision that creates one, + // out of a check. // Each row's marker names what decides it. // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity [Theory] @@ -46,10 +51,11 @@ public void TypeHasNoPublicOrProtectedConstructor(Type type) Assert.Empty(reachable); } - // A new public member producing one of these types could be a factory that bypasses the signature check. + // A new public member producing one of these types could be a factory that bypasses the check that creates it. // The intent forbids creating an identity outside that check but not forwarding one that it created, // so the list of members allowed to produce one is the library's own. - // oracle: mutant a public member that builds an AuthenticatedIdentity outside the signature check + // oracle: mutant a public member that builds an AuthenticatedIdentity outside the signature check, + // or an EnrollmentDecision outside the enrollment rules [Fact] public void NoOtherPublicMemberProducesTheseTypes() { @@ -75,6 +81,7 @@ public void NoOtherPublicMemberProducesTheseTypes() "ChannelOpening.HelloTimedOutAsync", "ChannelOpening.VerifyHelloAsync", "ChannelOpeningResult.get_Opening", + "EnrollmentRules.Decide", "RequestAuthenticator.AuthenticateAsync", "RequestAuthenticator.AuthenticateChannelOpeningAsync", ], diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index bb802631ed..b4f0be3a04 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -1,5 +1,6 @@ using System.Formats.Asn1; using System.Numerics; +using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -23,8 +24,6 @@ public sealed class IdentityCertificateIssuerTests : IDisposable private const string AuthorityKeyIdentifierOid = "2.5.29.35"; private const string ExtendedKeyUsageOid = "2.5.29.37"; - private static readonly Guid IdentityId = Guid.Parse(IdentityIdText.ToUpperInvariant()); - // 12:00:00.750 UTC, whose fraction a certificate cannot hold. private static readonly DateTimeOffset NowInSeconds = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Now = NowInSeconds.AddMilliseconds(750); @@ -42,12 +41,33 @@ public sealed class IdentityCertificateIssuerTests : IDisposable public static TheoryData SerialNumberDrawNames => new(SerialNumberDraws.Keys); + // A token that can create an identity. + private static EnrollmentTokenState UsableToken => new(false, Now.AddDays(1), maxUses: 10, identitiesCreated: 0); + + // A decision of each outcome but the creation of an identity: an expired token's refusal and a replay. + private static Dictionary DecisionsButCreateIdentity => new() + { + [EnrollmentOutcome.Reject] = + EnrollmentRules.Decide(new(false, Now, maxUses: 10, identitiesCreated: 0), null, Now), + [EnrollmentOutcome.ReturnExistingIdentity] = EnrollmentRules.Decide( + UsableToken, + IssuedKeyMatch.ForIdentity(Guid.Parse(IdentityIdText), true, false, CertificateStatus.Current), + Now), + }; + private readonly ECDsa _rootKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); private readonly ECDsa _agentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); private readonly RandomNumberGenerator _random = RandomNumberGenerator.Create(); private readonly X509Certificate2 _root; private readonly CertificateSigningRequest _csr; + // The decision that creates the identity whose certificate each test issues, unless the test passes another. + private readonly EnrollmentDecision _creating = EnrollmentRules.Decide(UsableToken, null, Now); + + // The ID that the decision minted, as CONTRACT.agent-identity.md §1 writes IDs: + // Guid.ToString("D") writes a UUID lowercase and hyphenated. + private string NewIdentityIdText => Assert.NotNull(_creating.NewIdentityId).ToString("D"); + public IdentityCertificateIssuerTests() { _root = CreateRoot(Now.AddDays(-1), Now.AddYears(10)); @@ -84,7 +104,7 @@ public void SubjectIsTheCommonNameOfTheIdentity() X500RelativeDistinguishedName name = Assert.Single(leaf.SubjectName.EnumerateRelativeDistinguishedNames()); Assert.Equal("2.5.4.3", name.GetSingleElementType().Value); - Assert.Equal(IdentityIdText, name.GetSingleElementValue()); + Assert.Equal(NewIdentityIdText, name.GetSingleElementValue()); // oracle: spec RFC 5280 §4.1.2.4 Assert.Equal(_root.SubjectName.RawData, leaf.IssuerName.RawData); } @@ -105,7 +125,7 @@ public void AlternativeNameIsTheUrnUuidOfTheIdentity() AsnReader names = new AsnReader(extension.RawData, AsnEncodingRules.DER).ReadSequence(); string uri = names.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)); - Assert.Equal($"urn:uuid:{IdentityIdText}", uri); + Assert.Equal($"urn:uuid:{NewIdentityIdText}", uri); Assert.False(names.HasData); Assert.False(extension.Critical); } @@ -290,7 +310,7 @@ public void IgnoresTheSubjectAndExtensionsOfTheCsr() using X509Certificate2 leaf = Issue(csr: csr); - Assert.Equal($"CN={IdentityIdText}", leaf.Subject); + Assert.Equal($"CN={NewIdentityIdText}", leaf.Subject); // The leaf carries no basicConstraints, so a leaf can never act as a CA. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates Assert.Null(leaf.Extensions[BasicConstraintsOid]); @@ -336,6 +356,63 @@ public void ThrowsWhenTheIssuerHasNoSubjectKeyIdentifier() Assert.Throws(() => Issue(root: root)); } + // Code that acts for an identity takes it as a type only the library's checks can create; + // for a new identity, that is the enrollment decision that creates it. + // A refusal creates no identity, and a replay returns the identity's current chain rather than a new certificate. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: spec Framework Design Guidelines §Validating Arguments + [Theory] + [InlineData(EnrollmentOutcome.Reject)] + [InlineData(EnrollmentOutcome.ReturnExistingIdentity)] + public void ThrowsForADecisionThatCreatesNoIdentity(EnrollmentOutcome outcome) + { + ArgumentException exception = + Assert.Throws(() => Issue(creating: DecisionsButCreateIdentity[outcome])); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("creating", exception.ParamName); + } + + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments + [Fact] + public void ThrowsForANullDecision() + { + ArgumentNullException exception = Assert.Throws(() => IdentityCertificateIssuer.Issue( + (EnrollmentDecision)null!, + _csr, + new TestRootSigner(_root, _rootKey), + Lifetime, + new FixedTimeProvider(Now), + _random)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("creating", exception.ParamName); + } + + // An identity is never taken from a value that a caller sent, + // so no public member that issues a certificate takes an identity ID. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + [Fact] + public void NoPublicMemberIssuesACertificateForAnIdentityId() + { + const BindingFlags declared = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + Type[] identityIds = [typeof(Guid), typeof(Guid?)]; + + IEnumerable issuers = typeof(IdentityCertificateIssuer).Assembly + .GetExportedTypes() + .SelectMany(type => type.GetMethods(declared) + .Where(method => method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly) + .Where(method => method.ReturnType == typeof(X509Certificate2)) + .Where(method => method.GetParameters() + .Any(parameter => identityIds.Contains(parameter.ParameterType))) + .Select(method => $"{type.Name}.{method.Name}")); + + Assert.Empty(issuers); + } + // Code that acts for an identity takes the identity that the signature check produced. // The renew request is signed by device-a, whose identity ID the vector file gives. // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity @@ -413,6 +490,7 @@ private X509Certificate2 IssueForRenewal(AuthenticatedIdentity renewing) } private X509Certificate2 Issue( + EnrollmentDecision? creating = null, TimeSpan? lifetime = null, TimeProvider? clock = null, RandomNumberGenerator? random = null, @@ -420,7 +498,7 @@ private X509Certificate2 Issue( CertificateSigningRequest? csr = null) { return IdentityCertificateIssuer.Issue( - IdentityId, + creating ?? _creating, csr ?? _csr, new TestRootSigner(root ?? _root, _rootKey), lifetime ?? Lifetime, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index 7de8b3ccf5..8422ce37e8 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -21,6 +21,16 @@ public class EnrollmentRulesTests ["the key of a deleted identity"] = IssuedKeyMatch.ForDeletedIdentity(), }; + // A decision of each outcome but the creation of an identity: an expired token's refusal and a replay. + private static Dictionary DecisionsButCreateIdentity => new() + { + [EnrollmentOutcome.Reject] = EnrollmentRules.Decide(Token(expired: true), null, Now), + [EnrollmentOutcome.ReturnExistingIdentity] = EnrollmentRules.Decide( + Token(), + IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current), + Now), + }; + // A token creates an identity when it exists, has not been deleted, // has not expired, and fewer than `max_uses` identities have been created with it; (6) a new identity. // The rows are a usable token, one that expires one tick after now, and one that can create one more identity. @@ -42,6 +52,43 @@ public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int Assert.Null(decision.Error); } + // Security invariant 2: the authority mints the identity's UUID, + // so a decision to create an identity carries one, not the nil UUID that an unset Guid holds. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants + [Fact] + public void CreateIdentityCarriesAnIdentityId() + { + EnrollmentDecision decision = EnrollmentRules.Decide(Token(), null, Now); + + Guid identityId = Assert.NotNull(decision.NewIdentityId); + Assert.NotEqual(Guid.Empty, identityId); + } + + // Security invariant 2: the authority mints the identity's UUID, and an identity is never derived from the + // enrollment token, so two identities that one token creates get different IDs. + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants + [Fact] + public void EachCreateIdentityCarriesADifferentIdentityId() + { + EnrollmentDecision first = EnrollmentRules.Decide(Token(), null, Now); + EnrollmentDecision second = EnrollmentRules.Decide(Token(), null, Now); + + Assert.NotEqual(first.NewIdentityId, second.NewIdentityId); + } + + // A refusal creates no identity, and a replay returns the identity that the key already belongs to. + [Theory] + // oracle: mutant a refusal that carries a new identity ID + [InlineData(EnrollmentOutcome.Reject)] + // oracle: mutant a replay that carries a new identity ID + [InlineData(EnrollmentOutcome.ReturnExistingIdentity)] + public void CarriesNoNewIdentityIdWhenCreatingNone(EnrollmentOutcome outcome) + { + EnrollmentDecision decision = DecisionsButCreateIdentity[outcome]; + + Assert.Null(decision.NewIdentityId); + } + // When several outcomes apply, the earliest wins: // (4) `token_invalid` for a deleted token, then `token_expired` for an expired one; (5) `token_exhausted`. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs index 242fb078bf..1a7030ad66 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs @@ -20,8 +20,7 @@ public static class IdentityCertificateIssuer public static TimeSpan DefaultLifetime { get; } = TimeSpan.FromDays(90); /// - /// Issues the certificate of a new identity, whose ID the authority mints at enrollment, - /// for the key of a validated CSR. + /// Issues the certificate of the new identity that an enrollment decision creates, for the key of a validated CSR. /// /// /// @@ -29,7 +28,8 @@ public static class IdentityCertificateIssuer /// the subject alternative name URI urn:uuid:<identity ID>, the extended key usage clientAuth, /// the critical key usage digitalSignature, a subject key identifier, /// an authority key identifier equal to the issuer's subject key identifier, and no basic constraints. - /// The identity ID is written lowercase and hyphenated, and nothing else from the CSR reaches the certificate. + /// The identity ID is , written lowercase and hyphenated, + /// and nothing else from the CSR reaches the certificate. /// /// /// It is valid from the current time, truncated to the second, for , @@ -38,23 +38,50 @@ public static class IdentityCertificateIssuer /// and signs it with SHA-256. /// /// + /// Taking the decision rather than an ID keeps the authority from certifying a key for an identity + /// that no enrollment created. /// A renewal certifies a key for an existing identity, /// so it uses the overload that takes the identity that the renew request proved. /// /// - /// The ID that the authority minted for the new identity. + /// + /// The decision of to create the identity, + /// whose is . + /// /// The validated CSR whose key the certificate is for. /// The root that signs the certificate. /// The certificate lifetime, usually . /// The clock, read once. /// The source of the serial number. + /// An argument is null. + /// + /// does not create an identity; + /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second. + /// /// /// is shorter than one second. /// - /// - /// The issuer certificate has no subject key identifier, or its notAfter is not after the current second. - /// public static X509Certificate2 Issue( + EnrollmentDecision creating, + CertificateSigningRequest csr, + IRootSigner issuer, + TimeSpan lifetime, + TimeProvider timeProvider, + RandomNumberGenerator random) + { + ArgumentNullException.ThrowIfNull(creating); + + if (creating.Outcome != EnrollmentOutcome.CreateIdentity) + { + throw new ArgumentException("The enrollment decision does not create an identity.", nameof(creating)); + } + + // A decision to create an identity always carries the ID minted for it. + return Issue(creating.NewIdentityId!.Value, csr, issuer, lifetime, timeProvider, random); + } + + // Private so that a certificate is issued only for an identity ID taken from a proof of that identity. + private static X509Certificate2 Issue( Guid identityId, CertificateSigningRequest csr, IRootSigner issuer, @@ -126,7 +153,8 @@ public static X509Certificate2 Issue( /// Issues the pending certificate of a renewal, for the identity that signed the renew request. /// /// - /// The certificate is the one that the other overload issues for . + /// The certificate is the one that the other overload issues, with + /// as the identity ID. /// Taking the identity that the signature check produced, rather than an ID, /// keeps a renewal from certifying a key for an identity that a request only named. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs index ed453872be..7128985744 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs @@ -7,11 +7,13 @@ public sealed class EnrollmentDecision { private EnrollmentDecision( EnrollmentOutcome outcome, + Guid? newIdentityId, Guid? existingIdentityId, AgentIdentityError? error, string? failureDetail) { Outcome = outcome; + NewIdentityId = newIdentityId; ExistingIdentityId = existingIdentityId; Error = error; FailureDetail = failureDetail; @@ -22,6 +24,17 @@ private EnrollmentDecision( /// public EnrollmentOutcome Outcome { get; } + /// + /// The ID that the library minted for the new identity, a random UUID unique to this decision, + /// when is ; otherwise null. + /// + /// + /// The authority creates the identity under this ID and answers the request with it. + /// It issues the identity's certificate by passing this decision to the + /// overload that takes it, so that no certificate is issued for an identity that no enrollment created. + /// + public Guid? NewIdentityId { get; } + /// /// The identity whose enrollment the request replays, /// when is ; otherwise null. @@ -40,16 +53,23 @@ private EnrollmentDecision( /// public string? FailureDetail { get; } - internal static EnrollmentDecision CreateIdentity { get; } = - new(EnrollmentOutcome.CreateIdentity, null, null, null); + internal static EnrollmentDecision CreateIdentity(Guid newIdentityId) + { + return new EnrollmentDecision(EnrollmentOutcome.CreateIdentity, newIdentityId, null, null, null); + } internal static EnrollmentDecision ReturnExistingIdentity(Guid identityId) { - return new EnrollmentDecision(EnrollmentOutcome.ReturnExistingIdentity, identityId, null, null); + return new EnrollmentDecision(EnrollmentOutcome.ReturnExistingIdentity, null, identityId, null, null); } internal static EnrollmentDecision Reject(AgentIdentityErrorCode code, string failureDetail) { - return new EnrollmentDecision(EnrollmentOutcome.Reject, null, AgentIdentityError.From(code), failureDetail); + return new EnrollmentDecision( + EnrollmentOutcome.Reject, + null, + null, + AgentIdentityError.From(code), + failureDetail); } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs index 2cb5e36541..c360ba9af2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs @@ -11,7 +11,7 @@ public enum EnrollmentOutcome // values start at 1 so that an uninitialized outcome is not a valid one. /// - /// Create a new identity, which counts one use of the token. + /// Create a new identity under , which counts one use of the token. /// CreateIdentity = 1, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs index aca931e6e8..98cb928d4d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs @@ -19,7 +19,7 @@ public static class EnrollmentRules /// token_invalid for a deleted token, /// then token_expired once reaches its expiry; /// token_exhausted once it has created identities; - /// a new identity. + /// a new identity, whose ID, , is minted at random for this decision. /// /// /// The CSR key lookup takes the first matching rule: @@ -79,7 +79,7 @@ public static EnrollmentDecision Decide(EnrollmentTokenState token, IssuedKeyMat "The token has created its maximum number of identities."); } - return EnrollmentDecision.CreateIdentity; + return EnrollmentDecision.CreateIdentity(Guid.NewGuid()); } // The key rules after the replay, in their order; a deleted identity matches only its own rule. diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 9d62c47d8a..5204bf552f 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -10,7 +10,7 @@ The contract is `docs/agent-identity/CONTRACT.agent-identity.md` in the [devolut - Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.agent-identity.md [§6]). - Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`), and `ConfirmRequestBody` checks that a `confirm` body is empty. - Decisions: `EnrollmentRules.Decide` decides an enrollment and `RenewalRules.Decide` a renewal, given the issued certificate that holds the CSR key. -- Certificates: `IdentityCertificateIssuer.Issue` issues the certificate of a new identity, or of the identity that a renew request proved. +- Certificates: `IdentityCertificateIssuer.Issue` issues the certificate of the new identity that an enrollment decision creates, or of the identity that a renew request proved. - Responses: `IdentityConfig` holds an identity's config, and `ResponseBodies` writes the bodies of the `trust-anchor`, `enroll`, `renew` and `check-in` responses. - Agent channel: `ChannelOpening` verifies the handshake that authenticates a stream, and `ChannelMessages` builds the `ConfigUpdate`, `RenewRequested` and `Reconnect` messages that the authority pushes. - Errors: `AgentIdentityError` gives each error its code, HTTP status and message, and writes its JSON body and gRPC exception. @@ -61,9 +61,9 @@ Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normall 5. Pass the token's `EnrollmentTokenState`, the match and the current time to `EnrollmentRules.Decide`, and act on its `Outcome`: - `Reject`: answer with `Error`. - `ReturnExistingIdentity`: take identity `ExistingIdentityId`, and change nothing. - - `CreateIdentity`: mint an identity ID, issue its certificate with `IdentityCertificateIssuer.Issue`, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata and the key's `SubjectPublicKeyInfoSha256`. + - `CreateIdentity`: take `NewIdentityId`, which the library minted, as the identity's ID, issue its certificate with the `IdentityCertificateIssuer.Issue` overload that takes the decision, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata and the key's `SubjectPublicKeyInfoSha256`. Create the identity and count the token use atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent enrollments. -6. Answer with `ResponseBodies.Enroll`, given the authority ID and the identity's ID, current certificate chain, leaf first, and config (CONTRACT.agent-identity.md [§5.2]). +6. Answer with `ResponseBodies.Enroll`, given the authority ID and the ID taken in step 5, with that identity's current certificate chain, leaf first, and config (CONTRACT.agent-identity.md [§5.2]). ### Signed requests @@ -147,6 +147,8 @@ Log `FailureDetail` beside the error: it names the rule that the request broke a Only the signature check, `RequestAuthenticator.AuthenticateAsync`, and the channel handshake, `ChannelOpening.VerifyHelloAsync`, create an `AuthenticatedIdentity`, which has no public constructor. Code that acts for an identity should take one, so that it acts for the identity a signature proved, never for an identity ID that the agent or a caller sent. The library does so itself: `RenewalRules.Decide` and the renewal overload of `IdentityCertificateIssuer.Issue` take the renewing `AuthenticatedIdentity`, and throw `ArgumentException` for one authenticated for another operation. +The certificate of a new identity is issued from the `EnrollmentDecision` that creates it, which only `EnrollmentRules.Decide` produces, under the `NewIdentityId` that it minted; the overload that takes the decision throws `ArgumentException` for one of another outcome. +No `IdentityCertificateIssuer.Issue` overload takes an identity ID. A verified channel opening yields only a `ChannelOpening`, whose identity stays hidden until the agent signs the stream's challenge, since signed opening headers are not tied to one stream and can be captured by a TLS-terminating proxy, TLS inspection or a header log (security invariant 7 of [INTENT.agent-identity.md][invariants]). For the same reason, `AuthenticateAsync` throws `ArgumentOutOfRangeException` for `AgentOperation.Connect`. From 5657a80c01682d85b0cf00783db89ef15b94e3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 02:26:21 +0900 Subject: [PATCH 69/93] docs(dotnet-agent-identity): guide hosts on handshake races, last_seen_at and load A revocation that commits after the handshake's registry read, but before the host tracks the stream, left the stream authenticated until its certificate expired. The host now adds the stream to the live streams its revocation and deletion paths close before it ends the handshake, so a revocation is caught either by that read or by the revocation path. The stream is keyed by `ChannelOpening.CertificateThumbprint` until the handshake reveals the identity, and that property's doc says so. Each section now says when to set `last_seen_at`, as the shared intent defines it, and the host dependencies state the registry and nonce-store load per request and per stream, with the caching and indexing that keep revocation effective from the next request. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Channel/ChannelOpening.cs | 8 +++- dotnet/AgentIdentity/README.md | 42 ++++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs index 98b895035b..b9af275468 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs @@ -73,8 +73,14 @@ internal ChannelOpening( public ServerMessage Challenge { get; } /// - /// The thumbprint of the certificate that signed the opening request, for logs. + /// The thumbprint of the certificate that signed the opening request, + /// for logs and to key the stream among the host's live streams until the handshake reveals the identity. /// + /// + /// The host adds the stream under this thumbprint to the live streams that its revocation and deletion paths + /// close before it ends the handshake, so that a revocation that commits after the handshake's registry read + /// still finds and closes the stream. + /// public string CertificateThumbprint => Certificate.Thumbprint; /// diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 5204bf552f..86537b8554 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -38,6 +38,21 @@ The host provides these dependencies, with the guarantees the library relies on: Take the `now` of `EnrollmentRules.Decide` and the `serverTime` of every error from the same clock, since the agent corrects its own clock from `server_time`. - `RandomNumberGenerator` must be cryptographically secure, such as `RandomNumberGenerator.Create()`, since it supplies channel challenges, token secrets and certificate serial numbers. +The library puts this load on the registry and the nonce store: + +- Each signed request that passes parsing, the tag and the time window costs one `FindByThumbprintAsync`, and only one whose signature verifies also costs one `TryRecordAsync` (CONTRACT.agent-identity.md [§6]). + The thumbprint comes from the request, so an unauthenticated client can drive lookups with random `keyid`s. +- Each channel stream whose opening verifies costs two registry reads, one for the opening and one when the handshake ends, as CONTRACT.agent-identity.md [§7.4] requires, and one nonce write. + +To bear that load, it is safe to: + +- look certificates up through an index on their thumbprint; +- cache a certificate's DER by thumbprint, since it never changes; +- answer misses from a bounded negative cache, or rate-limit them per client; +- index the nonce store by `keyid` and `nonce`, and expire each entry at its `retainUntil`. + +Never cache `Status` or `IdentityRevoked` beyond one request, nor whether the identity still exists: a revocation, a deletion or a retirement takes effect from the next request only if every lookup reads them afresh. + `RequestAuthenticator` keeps no state between requests, so one instance can serve them all when its dependencies accept concurrent calls. Exceptions from the registry and the nonce store propagate to the caller. @@ -60,8 +75,8 @@ Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normall Describe the match with `IssuedKeyMatch.ForIdentity` or `IssuedKeyMatch.ForDeletedIdentity`, or use null when there is none. 5. Pass the token's `EnrollmentTokenState`, the match and the current time to `EnrollmentRules.Decide`, and act on its `Outcome`: - `Reject`: answer with `Error`. - - `ReturnExistingIdentity`: take identity `ExistingIdentityId`, and change nothing. - - `CreateIdentity`: take `NewIdentityId`, which the library minted, as the identity's ID, issue its certificate with the `IdentityCertificateIssuer.Issue` overload that takes the decision, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata and the key's `SubjectPublicKeyInfoSha256`. + - `ReturnExistingIdentity`: take identity `ExistingIdentityId`, and change nothing, not even its `last_seen_at`, since a replay proves no current possession of the device key. + - `CreateIdentity`: take `NewIdentityId`, which the library minted, as the identity's ID, issue its certificate with the `IdentityCertificateIssuer.Issue` overload that takes the decision, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata, the key's `SubjectPublicKeyInfoSha256`, and the current time as its `last_seen_at`. Create the identity and count the token use atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent enrollments. 6. Answer with `ResponseBodies.Enroll`, given the authority ID and the ID taken in step 5, with that identity's current certificate chain, leaf first, and config (CONTRACT.agent-identity.md [§5.2]). @@ -84,18 +99,18 @@ Each then acts for the result's `Identity` only. - `ReturnPendingCertificate`: keep the identity's pending certificate, which holds the key. - `IssuePendingCertificate`: issue a certificate with the `IdentityCertificateIssuer.Issue` overload that takes the `AuthenticatedIdentity`, and store it as the identity's pending certificate, retiring the previous one. Store it atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent renewals. -3. Replace the identity's metadata, and answer with `ResponseBodies.Renew`, given the pending certificate's chain (CONTRACT.agent-identity.md [§5.3]). +3. Replace the identity's metadata, set its `last_seen_at` to the current time, and answer with `ResponseBodies.Renew`, given the pending certificate's chain (CONTRACT.agent-identity.md [§5.3]). ### `confirm` Parse the body with `ConfirmRequestBody.Parse`, and answer a non-empty body with the result's `Error` before changing any state (CONTRACT.agent-identity.md [§5.5]). When `Identity.CertificateStatus` is `CertificateStatus.Pending`, make the certificate current, retire the previous current certificate, and clear the request-renewal flag if it was set before the certificate was issued (CONTRACT.agent-identity.md [§8.2]). Then send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` on each stream that the retired certificate opened, and close those streams with `OK` 60 s later (CONTRACT.agent-identity.md [§7.4]). -Answer `204`, also when the certificate was already current (CONTRACT.agent-identity.md [§5.5]). +Set the identity's `last_seen_at` to the current time and answer `204`, also when the certificate was already current (CONTRACT.agent-identity.md [§5.5]). ### `check-in` -Replace the identity's metadata, and answer with `ResponseBodies.CheckIn`, given the identity's config and its request-renewal flag (CONTRACT.agent-identity.md [§5.6], [§8.2]). +Replace the identity's metadata, set its `last_seen_at` to the current time, and answer with `ResponseBodies.CheckIn`, given the identity's config and its request-renewal flag (CONTRACT.agent-identity.md [§5.6], [§8.2]). ### Agent channel @@ -104,17 +119,21 @@ The send limit is only a backstop, since `IdentityConfig.Create` refuses a confi 1. Pass the request method and the values of the `signature-input` and `signature` request headers, in received order, to `RequestAuthenticator.AuthenticateChannelOpeningAsync`. 2. Send the result's `Opening.Challenge` as the first message on the stream. -3. Pass the first agent message to `Opening.VerifyHelloAsync`. +3. Before any call of step 4, add the stream to the live streams that the revocation and deletion paths close, under `Opening.CertificateThumbprint` since its identity is still hidden, and remove it if the handshake fails. +4. Pass the first agent message to `Opening.VerifyHelloAsync`. Call `Opening.HelloTimedOutAsync` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. Call `Opening.FirstMessageUnreadableAsync` instead when the first message cannot be decoded, for example because it exceeds `MaxReceiveMessageSize` or a protobuf `string` holds invalid UTF-8 (CONTRACT.agent-identity.md [§7.1], [§7.3] step 4). The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. -4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`. -5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.agent-identity.md [§7.3] steps 5 and 7). -6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.agent-identity.md [§7.4]). -7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.agent-identity.md [§7.4]). -8. When a later agent message cannot be decoded or exceeds `MaxReceiveMessageSize`, close the stream with `AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest).ToRpcException(serverTime)` (CONTRACT.agent-identity.md [§7.4]). +5. On success, replace the identity's metadata with `Metadata`, set its `last_seen_at` to the current time, send `Welcome`, and only then treat the stream as authenticated for `Identity`. + Set `last_seen_at` again on every later agent message, but not when the stream closes, since a closure proves no possession of the device key (Configuration and metadata in [INTENT.agent-identity.md][last-seen-at]). +6. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.agent-identity.md [§7.3] steps 5 and 7). +7. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.agent-identity.md [§7.4]). +8. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.agent-identity.md [§7.4]). +9. When a later agent message cannot be decoded or exceeds `MaxReceiveMessageSize`, close the stream with `AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest).ToRpcException(serverTime)` (CONTRACT.agent-identity.md [§7.4]). `VerifyHelloAsync`, `HelloTimedOutAsync` and `FirstMessageUnreadableAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.agent-identity.md [§7.4]). +Step 3 lets no revocation or deletion slip past that read: one that commits before the read fails the handshake, and one that commits after it finds the stream among the live streams. +Without step 3, a stream whose identity is revoked just after the read would stay authenticated until `Identity.CertificateNotAfter`. A channel opening has no body: `VerifyHelloAsync` then checks the deadline, the message and its proof, and the metadata of the `Hello` last (CONTRACT.agent-identity.md [§7.3] step 4). When an identity's config changes, create its `IdentityConfig` again with a higher `Revision`, and send `ChannelMessages.ConfigUpdate` on each authenticated stream of the identity. @@ -183,4 +202,5 @@ Implement the `AgentChannel` service with that package; a feed that serves this [§8.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#server-certificate-state [§8.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#root-rotation [invariants]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/INTENT.agent-identity.md#security-invariants +[last-seen-at]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/INTENT.agent-identity.md#configuration-and-metadata [errors]: #errors From ce4d20a23ccc3e902647c3a6cc6009f6e7c94039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 02:26:23 +0900 Subject: [PATCH 70/93] test(dotnet-agent-identity): pin the handshake's registry read inside the call A host closes a revocation race by adding the stream to its live streams before it ends the handshake, which works only if the handshake reads the registry within the call rather than reusing the opening's answer. The test sets a flag before each call that ends the handshake and checks that a registry lookup ran after it. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Channel/ChannelHandshakeTests.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index 7e8bc703d4..a76e4d34b6 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -464,6 +464,30 @@ public async Task HandshakeReadsTheRegistryForTheOpeningCertificate(string scena Assert.All(handshakeLookups, thumbprint => Assert.Equal(DeviceA.Thumbprint, thumbprint)); } + // CONTRACT.agent-identity.md §7.4: revocation closes live streams, and a stream awaiting its Hello is checked for + // revocation and deletion when the Hello arrives or the wait times out. + // The host adds the stream to the live streams that its revocation and deletion paths close before it ends the + // handshake, so a revocation that commits before the handshake's registry read fails the handshake, + // and one that commits after that read finds the stream. + // No revocation slips between the two only if that read happens within the call, after the host added the stream, + // rather than at the opening. + // The library cannot check that the host adds the stream, so the test pins only the library's side of the order. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + [Theory] + [InlineData("a valid Hello")] + [InlineData("no Hello in time")] + [InlineData("the first message could not be decoded")] + public async Task HandshakeReadsTheRegistryAfterTheHostAddsTheStream(string end) + { + LiveStreamRecordingRegistry registry = new(); + ChannelOpening opening = await OpenAsync(registry); + registry.StreamAdded = true; + + await HandshakeEnds[end](opening); + + Assert.Contains(true, registry.StreamAddedAtLookups); + } + // The registry answers a thumbprint with the certificate issued under it, for its one identity, // so another answer is a host bug, which must fail as a server error rather than draw an outcome the agent acts on. // CONTRACT.agent-identity.md §5.4: an unexpected server failure is the transient `500 unavailable`, @@ -982,6 +1006,27 @@ private static async Task HandshakeAsync( return (opening, VectorChannel.HelloMessage(opening, proof)); } + // Opens the vector stream as VectorChannel.OpenAsync does, but over the given registry. + private static async Task OpenAsync(ICertificateRegistry registry) + { + ChannelProofVectors vectors = TestVectors.Instance.ChannelProof; + HttpSignatureCaseVector connect = TestVectors.Instance.HttpSignature.Case(vectors.ConnectCase); + RequestAuthenticator authenticator = new( + registry, + new RecordingNonceStore(), + new FixedTimeProvider(VectorChannel.OpeningTime), + new ScriptedRandomNumberGenerator(vectors.Challenge)); + + ChannelOpeningResult result = await authenticator.AuthenticateChannelOpeningAsync( + connect.Method, + VectorAuthority.Lines(connect.Headers.SignatureInput), + VectorAuthority.Lines(connect.Headers.Signature)); + + // oracle: reference docs/agent-identity/test-vectors.json channel_proof.connect_case + Assert.True(result.Succeeded, result.FailureDetail); + return result.Opening; + } + private static string NewId() { return Guid.NewGuid().ToString("D"); @@ -1024,4 +1069,23 @@ public Scenario And(Scenario later) // A scenario that fails the handshake, with the code and the gRPC status that close the stream. private sealed record Failure(Scenario Scenario, AgentIdentityErrorCode Code, StatusCode Status); + + // The registry of the vector authority, which records for each lookup whether the host had already added the + // stream to its live streams. + private sealed class LiveStreamRecordingRegistry : ICertificateRegistry + { + private readonly FakeCertificateRegistry _registry = new VectorAuthority().Registry; + + public bool StreamAdded { get; set; } + + public List StreamAddedAtLookups { get; } = []; + + public ValueTask FindByThumbprintAsync( + string thumbprint, + CancellationToken cancellationToken) + { + StreamAddedAtLookups.Add(StreamAdded); + return _registry.FindByThumbprintAsync(thumbprint, cancellationToken); + } + } } From 537b31bd15708eba70e0c5a5e6fe3c1fa2ba69da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 03:16:49 +0900 Subject: [PATCH 71/93] refactor(dotnet-agent-identity): share one base among the result types Seven public result types each declared the same Error and FailureDetail properties and the same failure annotation on Succeeded. They now derive from AgentIdentityResult, which holds those members once, and each type overrides Succeeded only to name its own value members. The override keeps the host's nullable flow analysis: [MemberNotNullWhen] cannot name an inherited member, but the compiler applies the base property's annotation and the override's together. A compile-only file in the test project dereferences each type's members on both branches, so losing either annotation fails the build. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ResultNullability.cs | 98 +++++++++++++++++++ .../Authentication/AuthenticationResult.cs | 26 ++--- .../Bodies/MetadataValidationResult.cs | 27 ++--- .../Bodies/RequestBodyParseResult.cs | 28 ++---- .../Certificates/CsrValidationResult.cs | 22 ++--- .../Channel/ChannelHandshakeResult.cs | 46 ++++----- .../Channel/ChannelOpeningResult.cs | 27 ++--- .../EnrollmentAuthorizationResult.cs | 39 ++------ .../Errors/AgentIdentityResult.cs | 40 ++++++++ 9 files changed, 199 insertions(+), 154 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResultNullability.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityResult.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResultNullability.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResultNullability.cs new file mode 100644 index 0000000000..3ada056e80 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResultNullability.cs @@ -0,0 +1,98 @@ +namespace Devolutions.AgentIdentity.Tests; + +// Never runs: it compiles only while each result's Succeeded tells the compiler which members are not null, +// so a lost [MemberNotNullWhen] fails the build under TreatWarningsAsErrors. +// It is not a test, because no document decides the annotations and the build alone checks them. +internal static class ResultNullability +{ + internal static void Authentication(AuthenticationResult result) + { + if (result.Succeeded) + { + Consume(result.Identity); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void MetadataValidation(MetadataValidationResult result) + { + if (result.Succeeded) + { + Consume(result.Metadata); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void RequestBodyParse(RequestBodyParseResult result) + { + if (result.Succeeded) + { + Consume(result.Body); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void CsrValidation(CsrValidationResult result) + { + if (result.Succeeded) + { + Consume(result.Csr); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void EnrollmentAuthorization(EnrollmentAuthorizationResult result) + { + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void ChannelOpening(ChannelOpeningResult result) + { + if (result.Succeeded) + { + Consume(result.Opening); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void ChannelHandshake(ChannelHandshakeResult result) + { + if (result.Succeeded) + { + Consume(result.Identity, result.Metadata, result.Welcome); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + private static void Consume(params object[] values) + { + _ = values; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs index 7288687940..5dae52dd53 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs @@ -5,41 +5,29 @@ namespace Devolutions.AgentIdentity; /// /// The outcome of authenticating a signed request. /// -public sealed class AuthenticationResult +/// +/// names the rule that the request broke and the step of +/// CONTRACT.agent-identity.md §6 that checks it. +/// +public sealed class AuthenticationResult : AgentIdentityResult { private AuthenticationResult(AuthenticatedIdentity? identity, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) { - Succeeded = identity is not null; Identity = identity; - Error = error; - FailureDetail = failureDetail; } /// /// Whether the request authenticated. /// [MemberNotNullWhen(true, nameof(Identity))] - [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] - public bool Succeeded { get; } + public override bool Succeeded => Identity is not null; /// /// The identity that the request proved, when it authenticated. /// public AuthenticatedIdentity? Identity { get; } - /// - /// The error to respond with, when the request did not authenticate. - /// - public AgentIdentityError? Error { get; } - - /// - /// The rule that the request broke and the step of CONTRACT.agent-identity.md §6 that checks it, for host logs. - /// - /// - /// It is a fixed sentence, so it never holds a header value, a token or a secret. - /// - public string? FailureDetail { get; } - internal static AuthenticationResult Success(AuthenticatedIdentity identity) { return new AuthenticationResult(identity, null, null); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs index dfdeb1bad7..fce26c32f7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs @@ -5,44 +5,33 @@ namespace Devolutions.AgentIdentity; /// /// The outcome of : valid metadata, or the error that rejects it. /// -public sealed class MetadataValidationResult +/// +/// The error is 400 invalid_request. +/// never repeats a key or value of the metadata: +/// it names an entry by its position. +/// +public sealed class MetadataValidationResult : AgentIdentityResult { private static readonly AgentIdentityError InvalidRequest = AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); private MetadataValidationResult(AgentMetadata? metadata, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) { - Succeeded = metadata is not null; Metadata = metadata; - Error = error; - FailureDetail = failureDetail; } /// /// Whether the metadata is valid. /// [MemberNotNullWhen(true, nameof(Metadata))] - [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] - public bool Succeeded { get; } + public override bool Succeeded => Metadata is not null; /// /// The validated metadata, when is true. /// public AgentMetadata? Metadata { get; } - /// - /// 400 invalid_request, when is false. - /// - public AgentIdentityError? Error { get; } - - /// - /// Why the metadata is invalid, for the host's logs, when is false. - /// - /// - /// The detail never repeats a key or value of the metadata: it names an entry by its position. - /// - public string? FailureDetail { get; } - internal static MetadataValidationResult Success(AgentMetadata metadata) { return new MetadataValidationResult(metadata, null, null); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs index 6309ab876e..4e40c64c86 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs @@ -9,46 +9,34 @@ namespace Devolutions.AgentIdentity; /// The body type: , , /// or . /// -public sealed class RequestBodyParseResult +/// +/// The error is 400 invalid_request. +/// never repeats a value from the body, not even a metadata key: +/// it names a metadata entry by its position. +/// +public sealed class RequestBodyParseResult : AgentIdentityResult where TBody : class { private static readonly AgentIdentityError InvalidRequest = AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); private RequestBodyParseResult(TBody? body, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) { - Succeeded = body is not null; Body = body; - Error = error; - FailureDetail = failureDetail; } /// /// Whether the body is valid. /// [MemberNotNullWhen(true, nameof(Body))] - [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] - public bool Succeeded { get; } + public override bool Succeeded => Body is not null; /// /// The validated body, when is true. /// public TBody? Body { get; } - /// - /// 400 invalid_request, when is false. - /// - public AgentIdentityError? Error { get; } - - /// - /// Why the body is invalid, for the host's logs, when is false. - /// - /// - /// The detail never repeats a value from the body, not even a metadata key: - /// it names a metadata entry by its position. - /// - public string? FailureDetail { get; } - internal static RequestBodyParseResult Success(TBody body) { return new RequestBodyParseResult(body, null, null); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs index 50c386abbb..5f59609f49 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs @@ -5,41 +5,31 @@ namespace Devolutions.AgentIdentity; /// /// The outcome of : a validated request, or the error that rejects it. /// -public sealed class CsrValidationResult +/// +/// The error is 400 invalid_request. +/// +public sealed class CsrValidationResult : AgentIdentityResult { private static readonly AgentIdentityError InvalidRequest = AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); private CsrValidationResult(CertificateSigningRequest? csr, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) { - Succeeded = csr is not null; Csr = csr; - Error = error; - FailureDetail = failureDetail; } /// /// Whether the request is valid. /// [MemberNotNullWhen(true, nameof(Csr))] - [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] - public bool Succeeded { get; } + public override bool Succeeded => Csr is not null; /// /// The validated request, when is true. /// public CertificateSigningRequest? Csr { get; } - /// - /// 400 invalid_request, when is false. - /// - public AgentIdentityError? Error { get; } - - /// - /// Why the request is invalid, for the host's logs, when is false. - /// - public string? FailureDetail { get; } - internal static CsrValidationResult Success(CertificateSigningRequest csr) { return new CsrValidationResult(csr, null, null); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs index edf2f62453..bf7c810df2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs @@ -10,12 +10,24 @@ namespace Devolutions.AgentIdentity; /// or the error that closes the stream. /// /// +/// /// On success, the host replaces the identity's metadata with , sends , /// and only then treats the stream as authenticated. -/// On failure, the host closes the stream with of , -/// and changes no state of the identity. +/// On failure, the host closes the stream with of +/// , and changes no state of the identity. +/// +/// +/// The error is when the certificate that opened the stream is +/// no longer registered or its identity was deleted, when the +/// identity was revoked, when the certificate expired +/// during the handshake, and when the agent proved the key in +/// time but sent invalid metadata. +/// Every other failure is . +/// never repeats a value from the message, not even a metadata key: +/// it names a metadata entry by its position. +/// /// -public sealed class ChannelHandshakeResult +public sealed class ChannelHandshakeResult : AgentIdentityResult { private ChannelHandshakeResult( AuthenticatedIdentity? identity, @@ -23,13 +35,11 @@ private ChannelHandshakeResult( ServerMessage? welcome, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) { - Succeeded = identity is not null; Identity = identity; Metadata = metadata; Welcome = welcome; - Error = error; - FailureDetail = failureDetail; } /// @@ -37,8 +47,7 @@ private ChannelHandshakeResult( /// unrevoked identity, and the agent proved in time that it holds its key and sent valid metadata. /// [MemberNotNullWhen(true, nameof(Identity), nameof(Metadata), nameof(Welcome))] - [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] - public bool Succeeded { get; } + public override bool Succeeded => Identity is not null && Metadata is not null && Welcome is not null; /// /// The identity that the stream is authenticated for, with , @@ -64,27 +73,6 @@ private ChannelHandshakeResult( /// public ServerMessage? Welcome { get; } - /// - /// The error to close the stream with, when the handshake failed. - /// - /// - /// It is when the certificate that opened the stream is no - /// longer registered or its identity was deleted, when the - /// identity was revoked, when the certificate expired - /// during the handshake, and when the agent proved the key in - /// time but sent invalid metadata. - /// Every other failure is . - /// - public AgentIdentityError? Error { get; } - - /// - /// The rule that the handshake failed on, for host logs, when the handshake failed. - /// - /// - /// It never repeats a value from the message, not even a metadata key: it names a metadata entry by its position. - /// - public string? FailureDetail { get; } - internal static ChannelHandshakeResult Success( AuthenticatedIdentity identity, AgentMetadata metadata, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs index cc53b62ead..017d22dfe4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs @@ -7,44 +7,29 @@ namespace Devolutions.AgentIdentity; /// /// /// When the opening fails, the host closes the stream with of -/// . +/// . +/// names the rule that the opening request broke and the step of +/// CONTRACT.agent-identity.md §6 that checks it. /// -public sealed class ChannelOpeningResult +public sealed class ChannelOpeningResult : AgentIdentityResult { private ChannelOpeningResult(ChannelOpening? opening, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) { - Succeeded = opening is not null; Opening = opening; - Error = error; - FailureDetail = failureDetail; } /// /// Whether the opening request authenticated. /// [MemberNotNullWhen(true, nameof(Opening))] - [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] - public bool Succeeded { get; } + public override bool Succeeded => Opening is not null; /// /// The opened stream, waiting for the agent to sign its challenge, when the opening request authenticated. /// public ChannelOpening? Opening { get; } - /// - /// The error to close the stream with, when the opening request did not authenticate. - /// - public AgentIdentityError? Error { get; } - - /// - /// The rule that the opening request broke and the step of CONTRACT.agent-identity.md §6 that checks it, - /// for host logs. - /// - /// - /// It is a fixed sentence, so it never holds a header value, a token or a secret. - /// - public string? FailureDetail { get; } - internal static ChannelOpeningResult Success(ChannelOpening opening) { return new ChannelOpeningResult(opening, null, null); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs index c28ee52a2a..4286ea776a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs @@ -1,54 +1,33 @@ -using System.Diagnostics.CodeAnalysis; - namespace Devolutions.AgentIdentity; /// /// The result of : /// the hash under which the authority finds the enrollment token, or token_invalid. /// -public sealed class EnrollmentAuthorizationResult +/// +/// tells whether the request holds a well-formed token, +/// which still authenticates nothing until the authority finds a stored token by its hash. +/// The error is token_invalid, with HTTP status 401, +/// and is a fixed sentence that never holds any part of the token. +/// +public sealed class EnrollmentAuthorizationResult : AgentIdentityResult { private EnrollmentAuthorizationResult( ReadOnlyMemory secretHash, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) { - Succeeded = error is null; SecretHash = secretHash; - Error = error; - FailureDetail = failureDetail; } - /// - /// Whether the request holds a well-formed token. - /// - /// - /// A well-formed token still authenticates nothing until the authority finds a stored token by its hash. - /// - [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] - public bool Succeeded { get; } - /// /// The SHA-256 of the ASCII text of the token's secret, /// which is the of the stored token; - /// empty when is false. + /// empty when is false. /// public ReadOnlyMemory SecretHash { get; } - /// - /// token_invalid, with HTTP status 401, when is false; otherwise null. - /// - public AgentIdentityError? Error { get; } - - /// - /// Why the request holds no well-formed token, for the host's logs, when is false; - /// otherwise null. - /// - /// - /// The detail is a fixed sentence and never holds any part of the token. - /// - public string? FailureDetail { get; } - internal static EnrollmentAuthorizationResult Success(byte[] secretHash) { return new EnrollmentAuthorizationResult(secretHash, null, null); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityResult.cs new file mode 100644 index 0000000000..4e2ecbb3fc --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityResult.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of a check of the library: success with the values that the derived type names, +/// or the error to respond with and a detail for host logs. +/// +/// +/// After is false, and are not null; +/// after it is true, the values of the derived type are not null. +/// +public abstract class AgentIdentityResult +{ + private protected AgentIdentityResult(AgentIdentityError? error, string? failureDetail) + { + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the check succeeded. + /// + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public virtual bool Succeeded => Error is null; + + /// + /// The error to respond with, when is false; otherwise null. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that the check failed on, for host logs, when is false; otherwise null. + /// + /// + /// It never holds a value taken from the request, such as a metadata key or value, a header, signature data + /// or a token. + /// + public string? FailureDetail { get; } +} From 789d086ef8d17eba917472f45556e99ef3add663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 03:35:52 +0900 Subject: [PATCH 72/93] test(dotnet-agent-identity): reuse the shared clock and generator doubles The certificate issuer tests defined a fixed clock, an advancing clock and a generator of whole draws, and the enrollment token tests a generator of one byte stream. They now use the doubles in `Support/TestDoubles.cs`. The stepping clock repeats its last reading where the advancing one kept moving, and the scripted generator serves one byte stream where the issuer tests' generator returned whole draws, so each test gives the code under test the readings and bytes it gave before. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IdentityCertificateIssuerTests.cs | 48 +++---------------- .../Enrollment/EnrollmentTokenTests.cs | 47 +++++++----------- 2 files changed, 23 insertions(+), 72 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index b4f0be3a04..0827be32fd 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -33,10 +33,10 @@ public sealed class IdentityCertificateIssuerTests : IDisposable // What the injected generator draws for a serial number, by name: // all ones, which as drawn would encode a negative INTEGER, // and the top bit alone, which is zero once that bit is cleared, followed by a second draw. - private static readonly Dictionary SerialNumberDraws = new() + private static readonly Dictionary SerialNumberDraws = new() { - ["all ones"] = [[.. Enumerable.Repeat((byte)0xFF, 16)]], - ["the top bit alone, then all 01"] = [[0x80, .. new byte[15]], [.. Enumerable.Repeat((byte)0x01, 16)]], + ["all ones"] = [.. Enumerable.Repeat((byte)0xFF, 16)], + ["the top bit alone, then all 01"] = [0x80, .. new byte[15], .. Enumerable.Repeat((byte)0x01, 16)], }; public static TheoryData SerialNumberDrawNames => new(SerialNumberDraws.Keys); @@ -212,7 +212,7 @@ public void HasExactlyTheProfileExtensions() [MemberData(nameof(SerialNumberDrawNames))] public void SerialNumberIsAPositiveIntegerOfAtMost20Octets(string draws) { - using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(SerialNumberDraws[draws])); + using X509Certificate2 leaf = Issue(random: new ScriptedRandomNumberGenerator(SerialNumberDraws[draws])); byte[] serialNumber = SerialNumber(leaf); Assert.True(new BigInteger(serialNumber, isUnsigned: false, isBigEndian: true) > 0); @@ -229,7 +229,7 @@ public void SerialNumberIsTheRandomOctetsWithTheTopBitCleared() { byte[] draw = [.. Enumerable.Repeat((byte)0xFF, 16)]; - using X509Certificate2 leaf = Issue(random: new SequenceRandomNumberGenerator(draw)); + using X509Certificate2 leaf = Issue(random: new ScriptedRandomNumberGenerator(draw)); Assert.Equal([0x7F, .. draw[1..]], SerialNumber(leaf)); } @@ -270,7 +270,7 @@ public void DefaultLifetimeIs90Days() [Fact] public void ReadsTheClockOnce() { - using X509Certificate2 leaf = Issue(clock: new AdvancingTimeProvider(Now, TimeSpan.FromHours(1))); + using X509Certificate2 leaf = Issue(clock: new SteppingTimeProvider(Now, Now.AddHours(1))); Assert.Equal(Lifetime, leaf.NotAfter.ToUniversalTime() - leaf.NotBefore.ToUniversalTime()); } @@ -539,40 +539,4 @@ private sealed class TestRootSigner(X509Certificate2 certificate, ECDsa key) : I public X509SignatureGenerator SignatureGenerator => X509SignatureGenerator.CreateForECDsa(key); } - - private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider - { - public override DateTimeOffset GetUtcNow() - { - return now; - } - } - - // Moves forward by one step at each reading. - private sealed class AdvancingTimeProvider(DateTimeOffset start, TimeSpan step) : TimeProvider - { - private int _readings; - - public override DateTimeOffset GetUtcNow() - { - return start + (step * _readings++); - } - } - - // Returns the given draws in order and fails when asked for another. - private sealed class SequenceRandomNumberGenerator(params byte[][] draws) : RandomNumberGenerator - { - private int _next; - - public override void GetBytes(byte[] data) - { - GetBytes(data.AsSpan()); - } - - public override void GetBytes(Span data) - { - byte[] draw = draws[_next++]; - draw.CopyTo(data); - } - } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index e0383739b1..ae1019d8aa 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -16,7 +16,8 @@ public class EnrollmentTokenTests [Fact] public void CreateEncodesTheGeneratorBytesAsTheSecret() { - EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); + EnrollmentToken token = + EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(SecretBytes)); Assert.StartsWith("dvaet1.", token.Value, StringComparison.Ordinal); Assert.EndsWith("." + SecretText, token.Value, StringComparison.Ordinal); @@ -29,7 +30,8 @@ public void CreateEncodesTheGeneratorBytesAsTheSecret() [Fact] public void SecretHashIsTheSha256OfTheSecretText() { - EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); + EnrollmentToken token = + EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(SecretBytes)); Assert.Equal(SHA256.HashData(Encoding.ASCII.GetBytes(SecretText)), token.SecretHash.ToArray()); } @@ -49,7 +51,8 @@ public void CreateWritesEverySecretInTheContractPattern() byte[] bytes = new byte[32]; random.NextBytes(bytes); - EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(bytes)); + EnrollmentToken token = + EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(bytes)); Assert.Matches(@"\Advaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}\z", token.Value); string secret = token.Value.Split('.')[2]; @@ -75,7 +78,8 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() { byte[] bytes = new byte[32]; random.NextBytes(bytes); - EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(bytes)); + EnrollmentToken token = + EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(bytes)); byte[] expected = SHA256.HashData(Encoding.ASCII.GetBytes(token.Value.Split('.')[2])); EnrollmentAuthorizationResult result = @@ -96,7 +100,7 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() [InlineData("https://host")] public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl) { - EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes)); // The bag of a token the library creates holds `u` alone. // oracle: intent dotnet/AgentIdentity/INTENT.md §Enrollment tokens @@ -159,7 +163,7 @@ public void CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, public void CreateRejectsAnotherUrl(string productBaseUrl) { ArgumentException exception = Assert.Throws( - () => EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes))); + () => EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes))); // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("productBaseUrl", exception.ParamName); @@ -174,7 +178,8 @@ public void CreateRejectsAnotherUrl(string productBaseUrl) [Fact] public void CreateAcceptsTheLongestUrlThatFits() { - EnrollmentToken token = EnrollmentToken.Create(UrlOfLength(3025), new ReplayingGenerator(SecretBytes)); + EnrollmentToken token = + EnrollmentToken.Create(UrlOfLength(3025), new ScriptedRandomNumberGenerator(SecretBytes)); Assert.Equal(4095, token.Value.Length); } @@ -185,7 +190,8 @@ public void CreateAcceptsTheLongestUrlThatFits() [Fact] public void ToStringRevealsNoPartOfTheToken() { - EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ReplayingGenerator(SecretBytes)); + EnrollmentToken token = + EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(SecretBytes)); string[] segments = token.Value.Split('.'); EnrollmentTokenAssert.RevealsNoPart(token.ToString(), segments[1], segments[2]); @@ -198,7 +204,7 @@ public void ToStringRevealsNoPartOfTheToken() public void CreateFailureRevealsNoPartOfTheSecret(string productBaseUrl) { ArgumentException exception = Assert.Throws( - () => EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes))); + () => EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes))); EnrollmentTokenAssert.RevealsNoPart(exception.Message, SecretText); } @@ -219,7 +225,7 @@ public void CreateRejectsANullArgument(string parameter) // A creation with one null argument, by the name of the parameter that receives it. private static Dictionary CreationsWithANullArgument => new() { - ["productBaseUrl"] = () => EnrollmentToken.Create(null!, new ReplayingGenerator(SecretBytes)), + ["productBaseUrl"] = () => EnrollmentToken.Create(null!, new ScriptedRandomNumberGenerator(SecretBytes)), ["randomNumberGenerator"] = () => EnrollmentToken.Create("https://host/dvls", null!), }; @@ -237,7 +243,7 @@ private static Creation CreateFrom(string productBaseUrl) try { - token = EnrollmentToken.Create(productBaseUrl, new ReplayingGenerator(SecretBytes)); + token = EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes)); } catch (ArgumentException) { @@ -272,23 +278,4 @@ public static Creation Holding(string? u) return new Creation(Rejected: false, U: u); } } - - /// - /// A generator that returns the given bytes in order, so that a test knows the secret it produces. - /// - private sealed class ReplayingGenerator(byte[] bytes) : RandomNumberGenerator - { - private int _position; - - public override void GetBytes(byte[] data) - { - GetBytes(data.AsSpan()); - } - - public override void GetBytes(Span data) - { - bytes.AsSpan(_position, data.Length).CopyTo(data); - _position += data.Length; - } - } } From 5b8bbd590b75966c495d6b4a9d5ac0c527fb7567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 03:42:44 +0900 Subject: [PATCH 73/93] test(dotnet-agent-identity): merge the CSR rejection tests into one theory About twenty facts and theories each built a malformed request and asserted only that validation rejects it with invalid_request. They are now named rows of one `RejectsInvalidRequest` theory, each keeping the explanation and markers of the rule it breaks. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CertificateSigningRequestTests.cs | 631 ++++++++---------- 1 file changed, 270 insertions(+), 361 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 1248329ae0..ea0f021d3a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -96,51 +96,104 @@ static byte[] Attribute(string oid) => }, }; - // X.690 §10.1: DER writes each length in the fewest octets. - private static readonly Dictionary> RequestsWithALongerLength = new() - { + // Requests that break a rule of the CSR profile, by name. + private static readonly Dictionary> InvalidRequests = new() + { + // None of these bytes is a CertificationRequest. + // oracle: spec RFC 2986 §4 + ["no bytes"] = () => [], + // oracle: spec RFC 2986 §4 + ["a zero octet"] = () => [0x00], + // oracle: spec RFC 2986 §4 + ["an empty SEQUENCE"] = () => [0x30, 0x00], + // oracle: spec RFC 2986 §4 + ["a SEQUENCE of a truncated SEQUENCE"] = () => [0x30, 0x03, 0x30, 0x01, 0x0A], + + // A CSR is DER, the encoding of one request. + // Bytes after the request would give one request many encodings. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + ["trailing bytes"] = () => [.. AcceptedValidVector(), 0x00], + + // X.690 §10.1: DER writes each length in the fewest octets. + // A CSR is DER, except for the subject and the attributes, which the server ignores. // The self-signature covers only the request info, so the request still verifies with a longer outer length. - ["CertificationRequest"] = () => TestCsr.TlvWithLongerLength(0x30, ValidVectorContents()), + // oracle: spec X.690 §10.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + ["CertificationRequest with a longer length"] = () => TestCsr.TlvWithLongerLength(0x30, ValidVectorContents()), // The same for the request info and its version, which a test can only change by signing the request again. - ["CertificationRequestInfo"] = () => + // oracle: spec X.690 §10.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + ["CertificationRequestInfo with a longer length"] = WithNewKey(key => { - using ECDsa key = NewKey(); byte[] spki = key.ExportSubjectPublicKeyInfo(); byte[] info = TestCsr.TlvWithLongerLength( 0x30, [.. TestCsr.Integer(0), .. TestCsr.EmptySubject, .. spki, .. TestCsr.EmptyAttributes]); return TestCsr.Request(info, key); - }, - ["version"] = () => + }), + // oracle: spec X.690 §10.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + ["version with a longer length"] = WithNewKey(key => { - using ECDsa key = NewKey(); byte[] info = TestCsr.Sequence( TestCsr.TlvWithLongerLength(0x02, [0x00]), TestCsr.EmptySubject, key.ExportSubjectPublicKeyInfo(), TestCsr.EmptyAttributes); return TestCsr.Request(info, key); - }, + }), // Another encoding of the key's SubjectPublicKeyInfo would give the key a second SHA-256. + // oracle: spec X.690 §10.1 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - ["SubjectPublicKeyInfo"] = () => + ["SubjectPublicKeyInfo with a longer length"] = WithNewKey(key => { - using ECDsa key = NewKey(); byte[] spki = TestCsr.TlvWithLongerLength( 0x30, [.. TestCsr.P256Algorithm(), .. TestCsr.BitString(TestCsr.UncompressedPoint(key))]); return TestCsr.Request(TestCsr.RequestInfo(spki), key); - }, - }; + }), - // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker, - // and RFC 5480 §2 those of SubjectPublicKeyInfo and of its AlgorithmIdentifier. - private static readonly Dictionary> RequestsWithAnExtraElement = new() - { + // DER uses the definite form of length. + // oracle: spec X.690 §10.1 + ["indefinite length"] = () => [0x30, 0x80, .. ValidVectorContents(), 0x00, 0x00], + + // version is v1, encoded 0. + // oracle: spec RFC 2986 §4.1 + ["version 1"] = WithNewKey(key => + { + byte[] info = TestCsr.Sequence( + TestCsr.Integer(1), + TestCsr.EmptySubject, + key.ExportSubjectPublicKeyInfo(), + TestCsr.EmptyAttributes); + return TestCsr.Request(info, key); + }), + + // The subject is a Name, which is a SEQUENCE. + // oracle: spec RFC 2986 §4.1 + // oracle: spec RFC 5280 §4.1.2.4 + ["subject that is not a SEQUENCE"] = WithNewKey(key => + TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject: [0x31, 0x00]), key)), + + // attributes is a required field, a constructed [0] IMPLICIT SET OF Attribute. // oracle: spec RFC 2986 §4.1 - ["CertificationRequestInfo"] = key => + ["request info without attributes"] = WithNewKey(key => RequestWithAttributes(key, [])), + + // The module of Appendix A uses IMPLICIT TAGS, + // and X.690 8.14.4 a) keeps an implicitly tagged SET OF constructed, + // so the attributes written primitive, 80 00, are not the required field. + // oracle: spec RFC 2986 §4.1 + // oracle: spec RFC 2986 §A + // oracle: spec X.690 §8.14 + ["request info with primitive attributes"] = WithNewKey(key => RequestWithAttributes(key, [0x80, 0x00])), + + // RFC 2986 §4 defines exactly the fields of each SEQUENCE, with no extension marker, + // and RFC 5480 §2 those of SubjectPublicKeyInfo and of its AlgorithmIdentifier. + // oracle: spec RFC 2986 §4.1 + ["CertificationRequestInfo with an extra element"] = WithNewKey(key => { byte[] info = TestCsr.Sequence( TestCsr.Integer(0), @@ -149,9 +202,9 @@ static byte[] Attribute(string oid) => TestCsr.EmptyAttributes, TestCsr.Null); return TestCsr.Request(info, key); - }, + }), // oracle: spec RFC 2986 §4.2 - ["CertificationRequest"] = key => + ["CertificationRequest with an extra element"] = WithNewKey(key => { byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); return TestCsr.Sequence( @@ -159,11 +212,11 @@ static byte[] Attribute(string oid) => TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), TestCsr.BitString(TestCsr.Sign(key, info)), TestCsr.Null); - }, + }), // Another element would give the key a second SubjectPublicKeyInfo, and so a second SHA-256. // oracle: spec RFC 5480 §2 - ["AlgorithmIdentifier of the key"] = key => + ["AlgorithmIdentifier of the key with an extra element"] = WithNewKey(key => { byte[] algorithm = TestCsr.Sequence( TestCsr.Oid(TestCsr.IdEcPublicKey), @@ -171,20 +224,17 @@ static byte[] Attribute(string oid) => TestCsr.Null); byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); return TestCsr.Request(TestCsr.RequestInfo(spki), key); - }, + }), // oracle: spec RFC 5480 §2 - ["SubjectPublicKeyInfo"] = key => + ["SubjectPublicKeyInfo with an extra element"] = WithNewKey(key => { byte[] spki = TestCsr.Sequence( TestCsr.P256Algorithm(), TestCsr.BitString(TestCsr.UncompressedPoint(key)), TestCsr.Null); return TestCsr.Request(TestCsr.RequestInfo(spki), key); - }, - }; + }), - private static readonly Dictionary> RequestsForAKeyNotNamedP256 = new() - { // The key is a P-256 key. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates ["P-384 key"] = () => @@ -195,68 +245,112 @@ static byte[] Attribute(string oid) => // secp256k1 (SEC 2 §2.4.1) points also take 65 octets uncompressed, so only the curve name tells them apart. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - ["P-256 point named secp256k1"] = () => + ["P-256 point named secp256k1"] = WithNewKey(key => { - using ECDsa key = NewKey(); byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Oid("1.3.132.0.10")); byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); return TestCsr.Request(TestCsr.RequestInfo(spki), key); - }, - }; + }), - // RFC 5480 §2.1.1: the parameters of id-ecPublicKey MUST be present, and only namedCurve is allowed. - private static readonly Dictionary> KeysWithoutANamedCurve = new() - { - ["no parameters"] = key => TestCsr.SubjectPublicKeyInfo( - TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey)), - TestCsr.UncompressedPoint(key)), - ["implicitCurve"] = key => TestCsr.SubjectPublicKeyInfo( - TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Null), - TestCsr.UncompressedPoint(key)), + // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; + // CONTRACT.agent-identity.md §4 needs a signing key. + // oracle: spec RFC 5480 §2.1.2 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["key restricted to key agreement"] = WithNewKey(key => + { + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid("1.3.132.1.12"), TestCsr.Oid(TestCsr.Secp256r1)); + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + return TestCsr.Request(TestCsr.RequestInfo(spki), key); + }), + + // RFC 5480 §2.1.1: the parameters of id-ecPublicKey MUST be present, and only namedCurve is allowed. + // oracle: spec RFC 5480 §2.1.1 + ["key without curve parameters"] = WithNewKey(key => RequestWithKeyAlgorithm( + key, + TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey)))), + // oracle: spec RFC 5480 §2.1.1 + ["key with implicitCurve"] = WithNewKey(key => RequestWithKeyAlgorithm( + key, + TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), TestCsr.Null))), // The specified form of the P-256 domain parameters, // which .NET writes for a key imported with explicit parameters. - ["specifiedCurve"] = key => TestCsr.SubjectPublicKeyInfo( - TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), P256SpecifiedDomain()), - TestCsr.UncompressedPoint(key)), - }; + // oracle: spec RFC 5480 §2.1.1 + ["key with specifiedCurve"] = WithNewKey(key => RequestWithKeyAlgorithm( + key, + TestCsr.Sequence(TestCsr.Oid(TestCsr.IdEcPublicKey), P256SpecifiedDomain()))), - // CONTRACT.agent-identity.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. - private static readonly Dictionary> PointsNotUncompressed = new() - { + // CONTRACT.agent-identity.md §4: an uncompressed point, so that each key has exactly one SubjectPublicKeyInfo. // The compressed form is 02 for an even y or 03 for an odd y, then x. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates // oracle: spec SEC 1 §2.3.3 - ["compressed"] = key => + ["compressed point"] = WithNewKey(key => { ECParameters parameters = key.ExportParameters(includePrivateParameters: false); - return [(byte)(0x02 | (parameters.Q.Y![^1] & 1)), .. parameters.Q.X!]; - }, + return RequestWithPoint(key, [(byte)(0x02 | (parameters.Q.Y![^1] & 1)), .. parameters.Q.X!]); + }), // ANSI X9.62 also defines a hybrid form, 06 or 07 then both coordinates; RFC 5480 §2.2 rejects it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates // oracle: spec RFC 5480 §2.2 - ["hybrid"] = key => + ["hybrid point"] = WithNewKey(key => { byte[] point = TestCsr.UncompressedPoint(key); point[0] = (byte)(0x06 | (point[^1] & 1)); - return point; - }, + return RequestWithPoint(key, point); + }), // The uncompressed form is 04, then x and y in 32 octets each, 65 octets in all. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates // oracle: spec SEC 1 §2.3.3 - ["without its form octet"] = key => TestCsr.UncompressedPoint(key)[1..], - ["with an extra octet"] = key => [.. TestCsr.UncompressedPoint(key), 0x00], + ["point without its form octet"] = WithNewKey(key => + RequestWithPoint(key, TestCsr.UncompressedPoint(key)[1..])), + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: spec SEC 1 §2.3.3 + ["point with an extra octet"] = WithNewKey(key => + RequestWithPoint(key, [.. TestCsr.UncompressedPoint(key), 0x00])), // The point at infinity is the single octet 00. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates // oracle: spec SEC 1 §2.3.3 - ["point at infinity"] = _ => [0x00], - }; + ["point at infinity"] = WithNewKey(key => RequestWithPoint(key, [0x00])), - // X.690 §11.2.1: DER requires an unused bit to be zero, so each BIT STRING is one whose last octet is even. - private static readonly Dictionary> RequestsWithUnusedBits = new() - { + // The key is a point on P-256. + // Flipping the low bit of y leaves the point off the curve, + // since the only other point with the same x has y' = p - y, which is y + 1 or y - 1 only for y = (p ± 1) / 2. + // No private key exists for such a point, so the key whose point was altered signs the request. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["point off the curve"] = () => + { + using ECDsa key = ECDsa.Create(); + key.ImportPkcs8PrivateKey(TestVectors.Instance.Key("device-a").PrivateKeyPkcs8, out _); + byte[] point = TestCsr.UncompressedPoint(key); + point[^1] ^= 0x01; + return RequestWithPoint(key, point); + }, + + // SEC 1 §3.2.2.1: the coordinates of a public key are integers below p, + // and CONTRACT.agent-identity.md §4 gives a key that is not a P-256 point invalid_request, not an exception. + // Each point is on the curve modulo p, so only the range of its coordinates rejects it; + // a platform's key import throws an exception of a platform-specific type for such a point. + // x = p, which is 0 modulo p, and y a square root of b modulo p. + // oracle: spec SEC 1 §3.2.2.1 + ["point with x out of the field"] = () => RequestWithPointOnP256ModuloP( + "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", + "66485C780E2F83D72433BD5D84A06BB6541C2AF31DAE871728BF856A174F93F4"), + + // y = p + 1, which is 1 modulo p, and x a root of x³ - 3x + b - 1 modulo p. + // oracle: spec SEC 1 §3.2.2.1 + ["point with y out of the field"] = () => RequestWithPointOnP256ModuloP( + "09E78D4EF60D05F750F6636209092BC43CBDD6B47E11A9DE20A9FEB2A50BB96C", + "FFFFFFFF00000001000000000000000000000001000000000000000000000000"), + + // X.690 §11.2.1: DER requires an unused bit to be zero, so each BIT STRING is one whose last octet is even. + // The key needs an uncompressed P-256 point, else invalid_request. // The octets of the point become the bits of the BIT STRING, so none is unused. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates // oracle: spec RFC 5480 §2.2 - ["subjectPublicKey"] = () => + ["subjectPublicKey with unused bits"] = () => { using ECDsa key = NewKeyWhere(point => (point[^1] & 1) == 0); byte[] spki = TestCsr.SubjectPublicKeyInfo( @@ -266,52 +360,86 @@ static byte[] Attribute(string oid) => return TestCsr.Request(TestCsr.RequestInfo(spki), key); }, + // The request needs a verified self-signature, else invalid_request. // The BIT STRING holds the octets of the DER Ecdsa-Sig-Value, so none is unused. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates // oracle: spec RFC 3279 §2.2 // oracle: spec RFC 3279 §2.2.3 - ["signature"] = () => + ["signature with unused bits"] = WithNewKey(key => { - using ECDsa key = NewKey(); (byte[] info, byte[] signature) = SignedInfoWhere(key, (_, s) => (s[^1] & 1) == 0); return TestCsr.Sequence( info, TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), TestCsr.BitString(signature, unusedBits: 1)); - }, - }; + }), - // Uncompressed points with a coordinate of p or more, each another encoding of a point on P-256, - // which would give its key a second SubjectPublicKeyInfo SHA-256. - private static readonly Dictionary PointsWithACoordinateOutOfTheField = new() - { - // x = p, which is 0 modulo p, and y a square root of b modulo p. - ["x"] = ( - "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", - "66485C780E2F83D72433BD5D84A06BB6541C2AF31DAE871728BF856A174F93F4"), - - // y = p + 1, which is 1 modulo p, and x a root of x³ - 3x + b - 1 modulo p. - ["y"] = ( - "09E78D4EF60D05F750F6636209092BC43CBDD6B47E11A9DE20A9FEB2A50BB96C", - "FFFFFFFF00000001000000000000000000000001000000000000000000000000"), - }; - - public static TheoryData RequestWithIgnoredContentsNames => new(RequestsWithIgnoredContents.Keys); - - public static TheoryData RequestWithALongerLengthNames => new(RequestsWithALongerLength.Keys); - - public static TheoryData RequestWithAnExtraElementNames => new(RequestsWithAnExtraElement.Keys); - - public static TheoryData RequestForAKeyNotNamedP256Names => new(RequestsForAKeyNotNamedP256.Keys); + // The signature algorithm is ecdsa-with-SHA256, even when the signature verifies under it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["another signature algorithm over a SHA-256 signature"] = WithNewKey(key => + { + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + byte[] algorithm = TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha384); + return TestCsr.Request(info, key, algorithm, HashAlgorithmName.SHA256); + }), - public static TheoryData KeyWithoutANamedCurveNames => new(KeysWithoutANamedCurve.Keys); + // The AlgorithmIdentifier of ecdsa-with-SHA256 MUST omit the parameters. + // oracle: spec RFC 5758 §3.2 + ["signature algorithm with NULL parameters"] = WithNewKey(key => + { + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.EcdsaWithSha256), TestCsr.Null); + return TestCsr.Request(info, key, algorithm); + }), - public static TheoryData PointNotUncompressedNames => new(PointsNotUncompressed.Keys); + // Security invariant 1: the self-signature is the proof of possession of the request's key. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + ["request signed by another key"] = WithNewKey(key => + { + using ECDsa other = NewKey(); + return TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()), other); + }), - public static TheoryData RequestWithUnusedBitsNames => new(RequestsWithUnusedBits.Keys); + // The signature is a DER ECDSA-Sig-Value, not the 64-octet r‖s form that request signatures use. + // oracle: spec RFC 5758 §3.2 + // oracle: spec RFC 3279 §2.2.3 + ["signature in fixed-field form"] = WithNewKey(key => + { + byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); + byte[] signature = key.SignData( + info, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(signature)); + }), + + // SEC 1 4.1.4 step 1: a verifier outputs "invalid" unless r and s are both in [1, n - 1], so both are positive. + // An r whose top bit is set needs a leading zero octet; without it, DER reads a negative number, + // which the BCL verifier accepts as the unsigned value, so only the library's own check rejects it. + // An r that would start FF after the zero octet is skipped, since its negative form would not be minimal DER. + // oracle: spec SEC 1 §4.1.4 + // oracle: spec X.690 §8.3 + ["signature with a negative r"] = WithNewKey(key => + { + (byte[] info, byte[] signature) = SignedInfoWhere(key, (r, _) => r.Length == 33 && r[1] != 0xFF); + (byte[] r, byte[] s) = ReadSignature(signature); + byte[] negative = TestCsr.Sequence(TestCsr.Tlv(0x02, r[1..]), TestCsr.Tlv(0x02, s)); + // oracle: reference .NET ECDsa.VerifyData + Assert.True( + key.VerifyData(info, negative, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + return TestCsr.Sequence( + info, + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(negative)); + }), + }; - public static TheoryData PointWithACoordinateOutOfTheFieldNames => - new(PointsWithACoordinateOutOfTheField.Keys); + public static TheoryData RequestWithIgnoredContentsNames => new(RequestsWithIgnoredContents.Keys); + public static TheoryData InvalidRequestNames => new(InvalidRequests.Keys); // test-vectors.json csr..expected, the outcome CONTRACT.agent-identity.md §4 gives each request. // oracle: reference docs/agent-identity/test-vectors.json csr // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates @@ -370,276 +498,14 @@ public void AcceptsAnySubjectAndAttributes(string request) Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } - // None of these is a CertificationRequest. - // oracle: spec RFC 2986 §4 - [Theory] - [InlineData("")] - [InlineData("00")] - [InlineData("3000")] - [InlineData("300330010A")] - public void RejectsBytesThatAreNotARequest(string hex) - { - AssertInvalid(CertificateSigningRequest.Validate(Convert.FromHexString(hex))); - } - - // A CSR is DER, the encoding of one request. - // Bytes after the request would give one request many encodings. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions - [Fact] - public void RejectsTrailingBytes() - { - byte[] valid = AcceptedValidVector(); - - AssertInvalid(CertificateSigningRequest.Validate([.. valid, 0x00])); - } - - // A CSR is DER, except for the subject and the attributes, which the server ignores. - // oracle: spec X.690 §10.1 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions - [Theory] - [MemberData(nameof(RequestWithALongerLengthNames))] - public void RejectsLengthInMoreOctetsThanNeeded(string element) - { - AssertInvalid(CertificateSigningRequest.Validate(RequestsWithALongerLength[element]())); - } - - // DER uses the definite form of length. - // oracle: spec X.690 §10.1 - [Fact] - public void RejectsIndefiniteLength() - { - byte[] contents = ValidVectorContents(); - - AssertInvalid(CertificateSigningRequest.Validate([0x30, 0x80, .. contents, 0x00, 0x00])); - } - - // version is v1, encoded 0. - // oracle: spec RFC 2986 §4.1 - [Fact] - public void RejectsVersion1() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.Sequence( - TestCsr.Integer(1), - TestCsr.EmptySubject, - key.ExportSubjectPublicKeyInfo(), - TestCsr.EmptyAttributes); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); - } - - // The subject is a Name, which is a SEQUENCE. - // oracle: spec RFC 2986 §4.1 - // oracle: spec RFC 5280 §4.1.2.4 - [Fact] - public void RejectsSubjectThatIsNotASequence() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), subject: [0x31, 0x00]); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); - } - - // attributes is a required field, a constructed [0] IMPLICIT SET OF Attribute. - // The rows leave it out and write it primitive, 80 00. - // oracle: spec RFC 2986 §4.1 - [Theory] - [InlineData("")] - // The module of Appendix A uses IMPLICIT TAGS, and X.690 8.14.4 a) keeps an implicitly tagged SET OF constructed. - // oracle: spec RFC 2986 §A - // oracle: spec X.690 §8.14 - [InlineData("8000")] - public void RejectsRequestInfoWithoutConstructedAttributes(string attributes) - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.Sequence( - TestCsr.Integer(0), - TestCsr.EmptySubject, - key.ExportSubjectPublicKeyInfo(), - Convert.FromHexString(attributes)); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key))); - } - - // oracle: spec RFC 2986 §4 - [Theory] - [MemberData(nameof(RequestWithAnExtraElementNames))] - public void RejectsExtraElement(string sequence) - { - using ECDsa key = NewKey(); - - AssertInvalid(CertificateSigningRequest.Validate(RequestsWithAnExtraElement[sequence](key))); - } - - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - [Theory] - [MemberData(nameof(RequestForAKeyNotNamedP256Names))] - public void RejectsKeyNotNamedP256(string key) - { - AssertInvalid(CertificateSigningRequest.Validate(RequestsForAKeyNotNamedP256[key]())); - } - - // RFC 5480 §2.1.2: id-ecDH restricts the key to key agreement; CONTRACT.agent-identity.md §4 needs a signing key. - // oracle: spec RFC 5480 §2.1.2 - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - [Fact] - public void RejectsKeyRestrictedToKeyAgreement() - { - using ECDsa key = NewKey(); - byte[] algorithm = TestCsr.Sequence(TestCsr.Oid("1.3.132.1.12"), TestCsr.Oid(TestCsr.Secp256r1)); - byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); - } - - // oracle: spec RFC 5480 §2.1.1 - [Theory] - [MemberData(nameof(KeyWithoutANamedCurveNames))] - public void RejectsKeyWithoutANamedCurve(string parameters) - { - using ECDsa key = NewKey(); - byte[] spki = KeysWithoutANamedCurve[parameters](key); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(TestCsr.RequestInfo(spki), key))); - } - - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - [Theory] - [MemberData(nameof(PointNotUncompressedNames))] - public void RejectsPointNotUncompressed(string point) - { - using ECDsa key = NewKey(); - - AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, PointsNotUncompressed[point](key)))); - } - - // The key is a point on P-256. - // Flipping the low bit of y leaves the point off the curve, - // since the only other point with the same x has y' = p - y, which is y + 1 or y - 1 only for y = (p ± 1) / 2. - // No private key exists for such a point, so the key whose point was altered signs the request. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - [Fact] - public void RejectsPointOffTheCurve() - { - using ECDsa key = ECDsa.Create(); - key.ImportPkcs8PrivateKey(TestVectors.Instance.Key("device-a").PrivateKeyPkcs8, out _); - byte[] point = TestCsr.UncompressedPoint(key); - point[^1] ^= 0x01; - - AssertInvalid(CertificateSigningRequest.Validate(RequestWithPoint(key, point))); - } - - // SEC 1 §3.2.2.1: the coordinates of a public key are integers below p, - // and CONTRACT.agent-identity.md §4 gives a key that is not a P-256 point invalid_request, not an exception. - // Each point is on the curve modulo p, so only the range of its coordinates rejects it; - // a platform's key import throws an exception of a platform-specific type for such a point. - // The private key of either point is unknown, and the key is invalid whatever the signature, - // so r = s = 1 stands in for the signature. - // oracle: spec SEC 1 §3.2.2.1 - [Theory] - [MemberData(nameof(PointWithACoordinateOutOfTheFieldNames))] - public void RejectsPointWithACoordinateOutOfTheField(string coordinate) - { - (string x, string y) = PointsWithACoordinateOutOfTheField[coordinate]; - byte[] point = [0x04, .. Convert.FromHexString(x), .. Convert.FromHexString(y)]; - // oracle: spec SEC 2 §2.4.2 - Assert.True(IsOnP256ModuloP(point)); - byte[] der = TestCsr.Sequence( - TestCsr.RequestInfo(TestCsr.SubjectPublicKeyInfo(TestCsr.P256Algorithm(), point)), - TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), - TestCsr.BitString(TestCsr.Sequence(TestCsr.Integer(1), TestCsr.Integer(1)))); - - AssertInvalid(CertificateSigningRequest.Validate(der)); - } - - // The key needs an uncompressed P-256 point and the request a verified self-signature, else invalid_request; - // each row's marker names the encoding rule that the bit string breaks. + // Each request breaks a rule of the CSR profile, and a request that breaks one gets invalid_request; + // each row's markers name the rule it breaks. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Theory] - [MemberData(nameof(RequestWithUnusedBitsNames))] - public void RejectsBitStringWithUnusedBits(string bitString) - { - AssertInvalid(CertificateSigningRequest.Validate(RequestsWithUnusedBits[bitString]())); - } - - // The signature algorithm is ecdsa-with-SHA256, even when the signature verifies under it. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - [Fact] - public void RejectsAnotherSignatureAlgorithmOverASha256Signature() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); - byte[] algorithm = TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha384); - byte[] der = TestCsr.Request(info, key, algorithm, HashAlgorithmName.SHA256); - - AssertInvalid(CertificateSigningRequest.Validate(der)); - } - - // The AlgorithmIdentifier of ecdsa-with-SHA256 MUST omit the parameters. - // oracle: spec RFC 5758 §3.2 - [Fact] - public void RejectsSignatureAlgorithmWithNullParameters() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); - byte[] algorithm = TestCsr.Sequence(TestCsr.Oid(TestCsr.EcdsaWithSha256), TestCsr.Null); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, key, algorithm))); - } - - // Security invariant 1: the self-signature is the proof of possession of the request's key. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates - [Fact] - public void RejectsRequestSignedByAnotherKey() - { - using ECDsa key = NewKey(); - using ECDsa other = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); - - AssertInvalid(CertificateSigningRequest.Validate(TestCsr.Request(info, other))); - } - - // The signature is a DER ECDSA-Sig-Value, not the 64-octet r‖s form that request signatures use. - // oracle: spec RFC 5758 §3.2 - // oracle: spec RFC 3279 §2.2.3 - [Fact] - public void RejectsSignatureInFixedFieldForm() - { - using ECDsa key = NewKey(); - byte[] info = TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo()); - byte[] signature = key.SignData( - info, - HashAlgorithmName.SHA256, - DSASignatureFormat.IeeeP1363FixedFieldConcatenation); - byte[] der = TestCsr.Sequence( - info, - TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), - TestCsr.BitString(signature)); - - AssertInvalid(CertificateSigningRequest.Validate(der)); - } - - // SEC 1 4.1.4 step 1: a verifier outputs "invalid" unless r and s are both in [1, n - 1], so both are positive. - // An r whose top bit is set needs a leading zero octet; without it, DER reads a negative number, - // which the BCL verifier accepts as the unsigned value, so only the library's own check rejects it. - // An r that would start FF after the zero octet is skipped, since its negative form would not be minimal DER. - // oracle: spec SEC 1 §4.1.4 - // oracle: spec X.690 §8.3 - [Fact] - public void RejectsSignatureWithNegativeR() + [MemberData(nameof(InvalidRequestNames))] + public void RejectsInvalidRequest(string request) { - using ECDsa key = NewKey(); - (byte[] info, byte[] signature) = SignedInfoWhere(key, (r, _) => r.Length == 33 && r[1] != 0xFF); - (byte[] r, byte[] s) = ReadSignature(signature); - byte[] negative = TestCsr.Sequence(TestCsr.Tlv(0x02, r[1..]), TestCsr.Tlv(0x02, s)); - // oracle: reference .NET ECDsa.VerifyData - Assert.True(key.VerifyData(info, negative, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); - byte[] der = TestCsr.Sequence( - info, - TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), - TestCsr.BitString(negative)); - - AssertInvalid(CertificateSigningRequest.Validate(der)); + AssertInvalid(CertificateSigningRequest.Validate(InvalidRequests[request]())); } private static CertificateSigningRequest AssertValid(CsrValidationResult result) @@ -710,6 +576,49 @@ private static ECDsa NewKeyWhere(Func pointCondition) throw new InvalidOperationException("No key met the condition in 64 attempts."); } + private static Func WithNewKey(Func request) + { + return () => + { + using ECDsa key = NewKey(); + return request(key); + }; + } + + private static byte[] RequestWithAttributes(ECDsa key, byte[] attributes) + { + byte[] info = TestCsr.Sequence( + TestCsr.Integer(0), + TestCsr.EmptySubject, + key.ExportSubjectPublicKeyInfo(), + attributes); + + return TestCsr.Request(info, key); + } + + private static byte[] RequestWithKeyAlgorithm(ECDsa key, byte[] algorithm) + { + byte[] spki = TestCsr.SubjectPublicKeyInfo(algorithm, TestCsr.UncompressedPoint(key)); + + return TestCsr.Request(TestCsr.RequestInfo(spki), key); + } + + // A request for the uncompressed point of the given coordinates, after checking that the point is on P-256 + // once its coordinates are reduced modulo p. + // The private key of such a point is unknown, and the key is invalid whatever the signature, + // so r = s = 1 stands in for the signature. + private static byte[] RequestWithPointOnP256ModuloP(string x, string y) + { + byte[] point = [0x04, .. Convert.FromHexString(x), .. Convert.FromHexString(y)]; + // oracle: spec SEC 2 §2.4.2 + Assert.True(IsOnP256ModuloP(point)); + + return TestCsr.Sequence( + TestCsr.RequestInfo(TestCsr.SubjectPublicKeyInfo(TestCsr.P256Algorithm(), point)), + TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256), + TestCsr.BitString(TestCsr.Sequence(TestCsr.Integer(1), TestCsr.Integer(1)))); + } + private static byte[] RequestWithPoint(ECDsa signer, byte[] point) { byte[] spki = TestCsr.SubjectPublicKeyInfo(TestCsr.P256Algorithm(), point); From 22b8b5743ddf36fa4905b5ed7927fa7e790ba3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 03:45:24 +0900 Subject: [PATCH 74/93] test(dotnet-agent-identity): share the table of unacceptable base URLs The enrollment token and identity config tests listed the same URLs that are not an absolute `https` URL without user information, a query or a fragment. Both now draw them from `Support/UnacceptableUrls.cs`, whose rows cite the contract section of each field. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Bodies/IdentityConfigTests.cs | 30 +---------- .../Enrollment/EnrollmentTokenTests.cs | 24 +-------- .../Support/UnacceptableUrls.cs | 52 +++++++++++++++++++ 3 files changed, 55 insertions(+), 51 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index e14cc3f75f..8800517174 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -57,35 +57,9 @@ public void JsonWritesEveryRevisionExactly(ulong revision, string expected) Assert.Equal(revision, config.Revision); } - // CONTRACT.agent-identity.md §5.2: `agent_channel_url` is an absolute `https` URL without a query or fragment. - // RFC 9110 §4.2.4: a sender does not generate user information in an `https` URI. + // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - [InlineData("")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - [InlineData("/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - [InlineData("host/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - [InlineData("http://host/dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - [InlineData("wss://host/dvls")] - // oracle: spec RFC 9110 §4.2.2 - [InlineData("https:///dvls")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - [InlineData("https://host/dvls?tenant=1")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - // oracle: spec RFC 3986 §3.4 - [InlineData("https://host/dvls?")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - [InlineData("https://host/dvls#top")] - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll - // oracle: spec RFC 3986 §3.5 - [InlineData("https://host/dvls#")] - // oracle: spec RFC 9110 §4.2.4 - [InlineData("https://admin@host/dvls")] - // oracle: spec RFC 9110 §4.2.4 - [InlineData("https://@host/dvls")] + [MemberData(nameof(UnacceptableUrls.Rows), MemberType = typeof(UnacceptableUrls))] public void CreateRejectsAnotherAgentChannelUrl(string agentChannelUrl) { ArgumentException exception = diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index ae1019d8aa..d1f7c046ab 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -130,32 +130,10 @@ public void CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, Assert.Contains(creation, (Creation[])[Creation.Holding(normalized), Creation.Rejection]); } - // CONTRACT.agent-identity.md §2: `u` is an absolute `https` URL without a query or fragment. - // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. - private static readonly string[] UnacceptableUrls = - [ - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - "", - "/dvls", - "host/dvls", - "http://host/dvls", - "wss://host/dvls", - // oracle: spec RFC 9110 §4.2.2 - "https:///dvls", - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - "https://host/dvls?tenant=1", - "https://host/dvls?", - "https://host/dvls#top", - "https://host/dvls#", - // oracle: spec RFC 9110 §4.2.4 - "https://admin@host/dvls", - "https://@host/dvls", - ]; - // The unacceptable URLs, and a URL one byte longer than the longest that fits in a token // (CONTRACT.agent-identity.md §2). // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls, UrlOfLength(3026)]); + public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls.All, UrlOfLength(3026)]); // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs new file mode 100644 index 0000000000..1333aef8f1 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs @@ -0,0 +1,52 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// URLs that are not an absolute https URL without user information, a query or a fragment, +/// the form that a token's u and a config's agent_channel_url both take. +/// +public static class UnacceptableUrls +{ + // CONTRACT.agent-identity.md §2 makes `u` and §5.2 makes `agent_channel_url` an absolute `https` URL + // without a query or fragment. + // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. + public static string[] All { get; } = + [ + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "http://host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "wss://host/dvls", + // oracle: spec RFC 9110 §4.2.2 + "https:///dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://host/dvls?tenant=1", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: spec RFC 3986 §3.4 + "https://host/dvls?", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://host/dvls#top", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: spec RFC 3986 §3.5 + "https://host/dvls#", + // oracle: spec RFC 9110 §4.2.4 + "https://admin@host/dvls", + // oracle: spec RFC 9110 §4.2.4 + "https://@host/dvls", + ]; + + public static TheoryData Rows => new(All); +} From 942320484419bed392fbbfb12c4594502b5d7e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 03:52:00 +0900 Subject: [PATCH 75/93] test(dotnet-agent-identity): merge the handshake failure tests into theories `EarlierFailureWins` and `TwoSignatureFailuresAreSignatureInvalid` had the same body over two halves of the failure pairs, so one theory now runs every pair. The facts and theories that each checked one failure's code and status are now `FailureClosesTheStreamWithItsCode`, run over every named failure, with each failure's markers where it is defined. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Channel/ChannelHandshakeTests.cs | 165 ++++++------------ 1 file changed, 50 insertions(+), 115 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index a76e4d34b6..2d9312f522 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -29,6 +29,9 @@ public class ChannelHandshakeTests }; // Messages that break exactly one rule of §7.3 step 4 that closes the stream with signature_invalid. + // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination private static readonly Dictionary> SignatureFailures = new() { ["an Ack"] = opening => new AgentMessage @@ -113,17 +116,12 @@ private static readonly Dictionary opening.FirstMessageUnreadableAsync(), }; - public static TheoryData SignatureFailureNames => new(SignatureFailures.Keys); + public static TheoryData FailureNames => new(Failures.Keys); public static TheoryData InvalidMetadataEntryNames => new(InvalidMetadataEntries.Keys); public static TheoryData ScenarioNames => new(Scenarios.Keys); - // Each end with no Hello, alone and after each closure. - public static TheoryData NoHelloNames => - new(NoHelloEnds.Keys.SelectMany( - end => Closures.Keys.Select(closure => WithClosure(end, closure)).Prepend(end))); - // Each wrong answer of the registry, to each of the calls that read it. public static TheoryData WrongRegistryAnswersAndCalls { @@ -142,15 +140,21 @@ public static TheoryData WrongRegistryAnswersAndCalls } } - // Each pair of failures that the handshake checks one after the other, and that close the stream with different - // codes. - public static TheoryData PairsOfFailuresWithDifferentCodes => - FailurePairNames((earlier, later) => earlier.Code != later.Code); + // Each pair of failures that the handshake checks one after the other. + public static TheoryData FailurePairNames + { + get + { + TheoryData cases = new(); - // Each pair of failures that the handshake checks one after the other, and that close the stream with the same - // code, signature_invalid. - public static TheoryData PairsOfFailuresWithTheSameCode => - FailurePairNames((earlier, later) => earlier.Code == later.Code); + foreach ((string earlier, string later) in FailurePairs.Keys) + { + cases.Add(earlier, later); + } + + return cases; + } + } // Every way to end a handshake. public static TheoryData HandshakeEndNames => new(HandshakeEnds.Keys); @@ -163,14 +167,20 @@ public static TheoryData WrongRegistryAnswersAndCalls // after the Hello timeout. private static OrderedDictionary Closures => new() { + // Step 4: a certificate that is no longer registered, or whose identity was deleted, is identity_unknown. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination ["the certificate is no longer registered"] = new( new Scenario { CertificateRemoved = true }, AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated), + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination ["the identity is revoked"] = new( new Scenario { IdentityRevoked = true }, AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied), + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination ["the certificate has expired"] = new( new Scenario { HandshakeTime = DeviceANotAfter.AddTicks(1) }, AgentIdentityErrorCode.CertificateExpired, @@ -181,6 +191,9 @@ public static TheoryData WrongRegistryAnswersAndCalls // the closures, then a late Hello. private static OrderedDictionary EarlyFailures => new(Closures) { + // Step 4: no Hello within 10 s closes the stream with signature_invalid. + // A Hello that the host passes on after the timeout fails, so a late host timer cannot let it through. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence ["the Hello is late"] = new( new Scenario { HandshakeTime = VectorChannel.OpeningTime.AddSeconds(10).AddTicks(1) }, AgentIdentityErrorCode.SignatureInvalid, @@ -197,6 +210,11 @@ public static TheoryData WrongRegistryAnswersAndCalls new Scenario { FirstMessage = failure.Value }, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated))) + + // A Hello that proves the key but carries metadata violating §3 is INVALID_ARGUMENT with error-code + // invalid_request. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata .Append(KeyValuePair.Create( "a Hello with invalid metadata", new Failure( @@ -224,6 +242,13 @@ public static TheoryData WrongRegistryAnswersAndCalls // Every failure by name: each early failure, each message failure, // and each end with no Hello, alone with signature_invalid (§7.3 step 4) and after each closure, // which §7.4 applies first. + // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid, + // and so does a first message that cannot be decoded, which is not a Hello. + // CONTRACT.agent-identity.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the + // failures of §7.3 step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination private static Dictionary Failures => EarlyFailures .Concat(MessageFailures) @@ -281,32 +306,21 @@ public static TheoryData WrongRegistryAnswersAndCalls private static DateTimeOffset DeviceANotAfter => TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter; - // CONTRACT.agent-identity.md §7.3 step 4 and §7.4: UNAUTHENTICATED with error-code signature_invalid. + // Each failure, alone, closes the stream with its code and gRPC status; + // the markers where each failure is defined name the rule that decides its code. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination - [Theory] - [MemberData(nameof(SignatureFailureNames))] - public async Task FirstMessageFailingTheProofRulesIsSignatureInvalid(string failure) - { - ChannelHandshakeResult result = await VerifyAsync(SignatureFailures[failure]); - - AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); - } - - // CONTRACT.agent-identity.md §7.3 step 4: a Hello that proves the key but carries metadata violating §3 is - // INVALID_ARGUMENT with error-code invalid_request. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata - [Fact] - public async Task HelloWithInvalidMetadataIsInvalidRequest() + [Theory] + [MemberData(nameof(FailureNames))] + public async Task FailureClosesTheStreamWithItsCode(string failure) { - ChannelOpening opening = await VectorChannel.OpenAsync(); - AgentMessage hello = VectorChannel.ValidHelloMessage(opening); - LaterFailures["a Hello with invalid metadata"](hello); + (Scenario scenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[failure]; - ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello); + ChannelHandshakeResult result = await HandshakeAsync(scenario); - AssertFailed(result, AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument); + AssertFailed(result, expectedCode, expectedStatus); } // The failure detail goes to host logs, so it holds no key or value of the metadata, valid or not, @@ -342,11 +356,12 @@ public async Task FailureDetailHoldsNoMetadataKeyOrValue(string entry) // The late Hello is signature_invalid like the message failures, and comes before all of them, metadata included. // CONTRACT.agent-identity.md §7.3 step 4 then checks the payload, the correlation_id, the proof, then the metadata, // so an agent that has not proved the key never learns whether its metadata would pass. + // Two failures that both close the stream with signature_invalid close it with signature_invalid together. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination [Theory] - [MemberData(nameof(PairsOfFailuresWithDifferentCodes))] + [MemberData(nameof(FailurePairNames))] public async Task EarlierFailureWins(string earlier, string later) { (_, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[earlier]; @@ -356,19 +371,6 @@ public async Task EarlierFailureWins(string earlier, string later) AssertFailed(result, expectedCode, expectedStatus); } - // Two failures that both close the stream with signature_invalid close it with signature_invalid together. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence - [Theory] - [MemberData(nameof(PairsOfFailuresWithTheSameCode))] - public async Task TwoSignatureFailuresAreSignatureInvalid(string earlier, string later) - { - (_, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[earlier]; - - ChannelHandshakeResult result = await HandshakeAsync(FailurePairs[(earlier, later)]); - - AssertFailed(result, expectedCode, expectedStatus); - } - // The shared intent, security invariant 5: revocation blocks authentication, including of a stream whose identity // the host cannot know yet. // CONTRACT.agent-identity.md §7.4: PERMISSION_DENIED @@ -391,17 +393,6 @@ public async Task IdentityRevokedDuringTheHandshakeIsIdentityRevoked() .OrderBy(entry => entry.Key, StringComparer.Ordinal)); } - // Step 4: a certificate that is no longer registered, or whose identity was deleted, is identity_unknown. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination - [Fact] - public async Task CertificateRemovedDuringTheHandshakeIsIdentityUnknown() - { - ChannelHandshakeResult result = await HandshakeAsync(new Scenario { CertificateRemoved = true }); - - AssertFailed(result, AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated); - } - // The identity reports the status that the registry holds for the opening certificate when the handshake ends. // CONTRACT.agent-identity.md §6 table: a channel opening accepts a pending certificate as well as a current one. // CONTRACT.agent-identity.md §5.5: a confirm during the handshake makes a pending certificate current. @@ -734,24 +725,6 @@ public async Task HandshakeIsCertificateExpiredOnlyAfterTheNotAfter( AssertFailed(result, expectedCode, StatusCode.Unauthenticated); } - // CONTRACT.agent-identity.md §7.3 step 4: no Hello within 10 s closes the stream with signature_invalid, - // and so does a first message that cannot be decoded, which is not a Hello. - // CONTRACT.agent-identity.md §7.4: revocation and expiry close a stream still awaiting its Hello ahead of the - // failures of §7.3 step 4, the timeout included, and so does a deletion, which leaves the certificate unregistered. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination - [Theory] - [MemberData(nameof(NoHelloNames))] - public async Task NoHelloIsSignatureInvalidUnlessAClosureApplies(string noHello) - { - (Scenario scenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[noHello]; - - ChannelHandshakeResult result = await HandshakeAsync(scenario); - - AssertFailed(result, expectedCode, expectedStatus); - } - // Step 4: the server waits 10 s for the Hello. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence [Fact] @@ -775,21 +748,6 @@ public async Task HelloExactlyAtTheTimeoutSucceeds() Assert.True(result.Succeeded, result.FailureDetail); } - // Step 4: no Hello within 10 s closes the stream with signature_invalid. - // A Hello that the host passes on after the timeout fails, so a late host timer cannot let it through. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence - [Fact] - public async Task HelloOneTickAfterTheTimeoutIsSignatureInvalid() - { - DateTimeOffset opened = VectorChannel.OpeningTime; - ChannelOpening opening = await VectorChannel.OpenAsync( - new SteppingTimeProvider(opened, opened, opened.AddSeconds(10).AddTicks(1))); - - ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening)); - - AssertFailed(result, AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated); - } - // Steps 1 and 2: the agent sends nothing until it receives the Challenge, // which the server sends once it has verified the opening request. // The agent can answer only once the opening is verified and its challenge sent, @@ -927,29 +885,6 @@ private static string WithClosure(string end, string closure) return $"{end}, and {closure}"; } - // The names of the pairs of failures whose two failures the predicate accepts. - private static TheoryData FailurePairNames(Func include) - { - Dictionary failures = Failures; - TheoryData cases = new(); - - foreach ((string earlier, string later) in FailurePairs.Keys) - { - if (include(failures[earlier], failures[later])) - { - cases.Add(earlier, later); - } - } - - return cases; - } - - // Opens the vector stream and verifies the first message built for it. - private static Task VerifyAsync(Func firstMessage) - { - return HandshakeAsync(new Scenario { FirstMessage = firstMessage }); - } - // Opens the vector stream, changes the registry and the clock as the scenario says, // then ends the handshake with its first message. private static async Task HandshakeAsync( From 28fe38fd7e6849939b582805e923140ea5be455e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 03:55:07 +0900 Subject: [PATCH 76/93] test(dotnet-agent-identity): share the channel opening setup Several tests and helpers each authenticated a channel opening from a signed request or from the vector connect case, and two signed the matching Hello. `TestAuthority.OpenChannelAsync` and `TestAuthority.ValidHello` now open a stream of `device-a` and answer it, `VectorAuthority.OpenChannelAsync` authenticates a connect case, and `VectorChannel.OpenAsync` opens the vector stream over any registry. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Channel/ChannelHandshakeTests.cs | 38 ++----------------- .../Channel/ChannelOpeningTests.cs | 27 ++++--------- .../Support/TestAuthority.cs | 38 +++++++++++++++++-- .../Support/TestIdentity.cs | 16 ++------ .../Support/VectorAuthority.cs | 16 +++++++- .../Support/VectorChannel.cs | 24 ++++++++---- 6 files changed, 78 insertions(+), 81 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs index 2d9312f522..51ab0cb70f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs @@ -471,7 +471,7 @@ public async Task HandshakeReadsTheRegistryForTheOpeningCertificate(string scena public async Task HandshakeReadsTheRegistryAfterTheHostAddsTheStream(string end) { LiveStreamRecordingRegistry registry = new(); - ChannelOpening opening = await OpenAsync(registry); + ChannelOpening opening = await VectorChannel.OpenAsync(registry); registry.StreamAdded = true; await HandshakeEnds[end](opening); @@ -924,42 +924,10 @@ private static async Task HandshakeAsync( { DateTimeOffset opened = DeviceANotAfter.AddSeconds(-5); TestAuthority authority = new(opened, clock: new SteppingTimeProvider(opened, opened, handshakeTime)); - SignedHttpRequest request = authority.Valid(AgentOperation.Connect).ToHttpRequest(); - ChannelOpeningResult result = await authority.Authenticator.AuthenticateChannelOpeningAsync( - request.Method, - request.SignatureInput, - request.Signature); + ChannelOpening opening = await authority.OpenChannelAsync(); - Assert.True(result.Succeeded, result.FailureDetail); - ChannelOpening opening = result.Opening; - byte[] proof = TestChannelProof.Sign( - DeviceA, - opening.Challenge.Challenge.Challenge_.Span, - TestRequest.DefaultNonce); - - return (opening, VectorChannel.HelloMessage(opening, proof)); - } - - // Opens the vector stream as VectorChannel.OpenAsync does, but over the given registry. - private static async Task OpenAsync(ICertificateRegistry registry) - { - ChannelProofVectors vectors = TestVectors.Instance.ChannelProof; - HttpSignatureCaseVector connect = TestVectors.Instance.HttpSignature.Case(vectors.ConnectCase); - RequestAuthenticator authenticator = new( - registry, - new RecordingNonceStore(), - new FixedTimeProvider(VectorChannel.OpeningTime), - new ScriptedRandomNumberGenerator(vectors.Challenge)); - - ChannelOpeningResult result = await authenticator.AuthenticateChannelOpeningAsync( - connect.Method, - VectorAuthority.Lines(connect.Headers.SignatureInput), - VectorAuthority.Lines(connect.Headers.Signature)); - - // oracle: reference docs/agent-identity/test-vectors.json channel_proof.connect_case - Assert.True(result.Succeeded, result.FailureDetail); - return result.Opening; + return (opening, TestAuthority.ValidHello(opening)); } private static string NewId() diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index 06f11b2674..021782e5cf 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -25,7 +25,7 @@ public async Task ChallengeHolds32Bytes() { TestAuthority authority = new(TestAuthority.DefaultNow); - ChannelOpening opening = await OpenAsync(authority, TestRequest.DefaultNonce); + ChannelOpening opening = await authority.OpenChannelAsync(); ServerMessage challenge = opening.Challenge; Assert.Equal(ServerMessage.PayloadOneofCase.Challenge, challenge.PayloadCase); @@ -41,8 +41,8 @@ public async Task EachOpeningHasItsOwnChallenge() { TestAuthority authority = new(TestAuthority.DefaultNow); - ChannelOpening first = await OpenAsync(authority, TestRequest.DefaultNonce); - ChannelOpening second = await OpenAsync(authority, "EBESExQVFhcYGRobHB0eHw"); + ChannelOpening first = await authority.OpenChannelAsync(); + ChannelOpening second = await authority.OpenChannelAsync("EBESExQVFhcYGRobHB0eHw"); // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions Assert.NotEqual(first.Challenge.Id, second.Challenge.Id); @@ -69,10 +69,8 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() TestVectors.Instance.HttpSignature.RegisteredCertificate(ChannelProof.Device); authority.Clock.Now = connect.Now; - ChannelOpeningResult result = await authority.Authenticator.AuthenticateChannelOpeningAsync( - connect.Method, - VectorAuthority.Lines(connect.Headers.SignatureInput), - VectorAuthority.Lines(connect.Headers.Signature)); + ChannelOpeningResult result = await VectorAuthority.OpenChannelAsync(authority.Authenticator, connect); + Assert.True(result.Succeeded); ChannelOpening opening = result.Opening; @@ -93,7 +91,7 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() public async Task KeptChallengeDoesNotFollowEditsOfTheMessage() { TestAuthority authority = new(TestAuthority.DefaultNow); - ChannelOpening opening = await OpenAsync(authority, TestRequest.DefaultNonce); + ChannelOpening opening = await authority.OpenChannelAsync(); string sentId = opening.Challenge.Id; byte[] sentBytes = opening.Challenge.Challenge.Challenge_.ToByteArray(); @@ -114,24 +112,13 @@ public async Task OpeningGivesTheNotAfterOfItsCertificate() { TestAuthority authority = new(TestAuthority.DefaultNow); - ChannelOpening opening = await OpenAsync(authority, TestRequest.DefaultNonce); + ChannelOpening opening = await authority.OpenChannelAsync(); Assert.Equal( TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter, opening.CertificateNotAfter); } - private static async Task OpenAsync(TestAuthority authority, string nonce) - { - SignedHttpRequest request = authority.Valid(AgentOperation.Connect, nonce: nonce).ToHttpRequest(); - ChannelOpeningResult result = await authority.Authenticator.AuthenticateChannelOpeningAsync( - request.Method, - request.SignatureInput, - request.Signature); - - Assert.True(result.Succeeded, result.FailureDetail); - return result.Opening; - } // SEC 1 §2.3.3: an uncompressed point is 0x04, then X and Y. private static byte[] Sec1PublicKey(byte[] subjectPublicKeyInfo) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs index 8a115ad362..e1ee30f963 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs @@ -1,3 +1,5 @@ +using Devolutions.Agent.Channel.V1; + namespace Devolutions.AgentIdentity.Tests; /// @@ -54,10 +56,7 @@ public async Task OutcomeAsync(AgentOperation operation, SignedHttpReque { if (operation == AgentOperation.Connect) { - ChannelOpeningResult opening = await Authenticator.AuthenticateChannelOpeningAsync( - request.Method, - request.SignatureInput, - request.Signature); + ChannelOpeningResult opening = await AuthenticateChannelOpeningAsync(request); return opening.Succeeded ? "ok" : opening.Error.Code.ToWireValue(); } @@ -66,4 +65,35 @@ public async Task OutcomeAsync(AgentOperation operation, SignedHttpReque return result.Succeeded ? "ok" : result.Error.Code.ToWireValue(); } + + /// + /// Opens a channel through the public API with a valid connect request of device-a. + /// + public async Task OpenChannelAsync(string nonce = TestRequest.DefaultNonce) + { + ChannelOpeningResult result = + await AuthenticateChannelOpeningAsync(Valid(AgentOperation.Connect, nonce: nonce).ToHttpRequest()); + + Assert.True(result.Succeeded, result.FailureDetail); + return result.Opening; + } + + /// + /// A Hello of device-a that answers the Challenge of an opening + /// that made with the default nonce. + /// + public static AgentMessage ValidHello(ChannelOpening opening) + { + byte[] proof = TestChannelProof.Sign( + TestVectors.Instance.Key("device-a"), + opening.Challenge.Challenge.Challenge_.Span, + TestRequest.DefaultNonce); + + return VectorChannel.HelloMessage(opening, proof); + } + + private ValueTask AuthenticateChannelOpeningAsync(SignedHttpRequest request) + { + return Authenticator.AuthenticateChannelOpeningAsync(request.Method, request.SignatureInput, request.Signature); + } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs index 8bfa400382..0140212c14 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs @@ -12,27 +12,17 @@ internal static class TestIdentity public static async Task AuthenticateAsync(AgentOperation operation) { TestAuthority authority = new(TestAuthority.DefaultNow); - SignedHttpRequest request = authority.Valid(operation).ToHttpRequest(); if (operation == AgentOperation.Connect) { - ChannelOpeningResult opening = await authority.Authenticator.AuthenticateChannelOpeningAsync( - request.Method, - request.SignatureInput, - request.Signature); - Assert.True(opening.Succeeded, opening.FailureDetail); - - byte[] proof = TestChannelProof.Sign( - TestVectors.Instance.Key("device-a"), - opening.Opening.Challenge.Challenge.Challenge_.Span, - TestRequest.DefaultNonce); - ChannelHandshakeResult handshake = - await opening.Opening.VerifyHelloAsync(VectorChannel.HelloMessage(opening.Opening, proof)); + ChannelOpening opening = await authority.OpenChannelAsync(); + ChannelHandshakeResult handshake = await opening.VerifyHelloAsync(TestAuthority.ValidHello(opening)); Assert.True(handshake.Succeeded, handshake.FailureDetail); return handshake.Identity; } + SignedHttpRequest request = authority.Valid(operation).ToHttpRequest(); AuthenticationResult result = await authority.Authenticator.AuthenticateAsync(operation, request); Assert.True(result.Succeeded, result.FailureDetail); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs index c2d28f6ae1..13bbe564f6 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs @@ -43,8 +43,8 @@ public async Task RunAsync(HttpSignatureCaseVector vector) if (vector.Endpoint == "connect") { - ChannelOpeningResult opening = - await Authenticator.AuthenticateChannelOpeningAsync(vector.Method, signatureInput, signature); + ChannelOpeningResult opening = await OpenChannelAsync(Authenticator, vector); + return opening.Succeeded ? VectorOutcome.Ok @@ -64,6 +64,18 @@ public async Task RunAsync(HttpSignatureCaseVector vector) : new VectorOutcome(result.Error.Code.ToWireValue(), result.FailureDetail); } + /// + /// Authenticates the opening request of a connect case through the public API. + /// + public static ValueTask OpenChannelAsync( + RequestAuthenticator authenticator, + HttpSignatureCaseVector vector) + { + return authenticator.AuthenticateChannelOpeningAsync( + vector.Method, + Lines(vector.Headers.SignatureInput), + Lines(vector.Headers.Signature)); + } /// /// The field lines of a vector header, which holds at most one. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs index fffcb856b8..ae7578c52e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs @@ -47,7 +47,7 @@ public static Task OpenAsync(TimeProvider? clock = null, Certifi /// Opens the stream through the public API, with the registry and the nonce store of , /// which the test can then change before the handshake. /// - public static async Task OpenAsync( + public static Task OpenAsync( VectorAuthority authority, TimeProvider? clock = null, CertificateStatus? status = null) @@ -57,17 +57,27 @@ public static async Task OpenAsync( RegisterDevice(authority.Registry, registeredStatus, identityRevoked: false); } + return OpenAsync(authority.Registry, authority.Nonces, clock); + } + + /// + /// Opens the stream through the public API, over , + /// which must answer for the device's certificate as the vector file registers it. + /// + public static async Task OpenAsync( + ICertificateRegistry registry, + INonceStore? nonces = null, + TimeProvider? clock = null) + { RequestAuthenticator authenticator = new( - authority.Registry, - authority.Nonces, + registry, + nonces ?? new RecordingNonceStore(), clock ?? new FixedTimeProvider(OpeningTime), new ScriptedRandomNumberGenerator(Vectors.Challenge)); - ChannelOpeningResult result = await authenticator.AuthenticateChannelOpeningAsync( - ConnectCase.Method, - VectorAuthority.Lines(ConnectCase.Headers.SignatureInput), - VectorAuthority.Lines(ConnectCase.Headers.Signature)); + ChannelOpeningResult result = await VectorAuthority.OpenChannelAsync(authenticator, ConnectCase); + // oracle: reference docs/agent-identity/test-vectors.json channel_proof.connect_case Assert.True(result.Succeeded, result.FailureDetail); Assert.Equal(Vectors.Challenge, result.Opening.Challenge.Challenge.Challenge_.ToByteArray()); return result.Opening; From fa633903cdfcf2a049af3865242040f416cd7453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 23 Oct 2026 03:55:31 +0900 Subject: [PATCH 77/93] test(dotnet-agent-identity): remove unused vector theory data No test draws its rows from `VectorData.KeyNames` or `VectorData.RegisteredCertificateNames`. Issue: DGW-479 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.AgentIdentity.Tests/Support/VectorData.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs index d46979e69a..978ad30736 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs @@ -5,15 +5,9 @@ namespace Devolutions.AgentIdentity.Tests; /// public static class VectorData { - public static TheoryData KeyNames => - new(TestVectors.Instance.Keys.Select(key => key.Name)); - public static TheoryData CsrNames => new(TestVectors.Instance.Csr.Keys); - public static TheoryData RegisteredCertificateNames => - new(TestVectors.Instance.HttpSignature.Registered.Select(registered => registered.Name)); - public static TheoryData HttpSignatureCaseNames => new(TestVectors.Instance.HttpSignature.Cases.Select(c => c.Name)); From 221041f9efb9196f77e07c6d15d33e78c89ea48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 24 Oct 2026 02:40:32 +0900 Subject: [PATCH 78/93] test(dotnet-agent-identity): tighten the shared test setup ReadsTheClockOnce gives the issuer three distinct readings, so an issuer that took both validity bounds from readings after the first fails it. TestAuthority.ValidHello signs over the nonce of the opening it answers rather than the default one, VectorAuthority builds the signature lines only for the requests that use them, and stray blank lines are fixed. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Certificates/CertificateSigningRequestTests.cs | 1 + .../Certificates/IdentityCertificateIssuerTests.cs | 2 +- .../Channel/ChannelOpeningTests.cs | 2 -- .../Support/TestAuthority.cs | 5 ++--- .../Support/VectorAuthority.cs | 8 +++----- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index ea0f021d3a..bb84ebc97a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -440,6 +440,7 @@ static byte[] Attribute(string oid) => public static TheoryData RequestWithIgnoredContentsNames => new(RequestsWithIgnoredContents.Keys); public static TheoryData InvalidRequestNames => new(InvalidRequests.Keys); + // test-vectors.json csr..expected, the outcome CONTRACT.agent-identity.md §4 gives each request. // oracle: reference docs/agent-identity/test-vectors.json csr // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 0827be32fd..d7e4b94cce 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -270,7 +270,7 @@ public void DefaultLifetimeIs90Days() [Fact] public void ReadsTheClockOnce() { - using X509Certificate2 leaf = Issue(clock: new SteppingTimeProvider(Now, Now.AddHours(1))); + using X509Certificate2 leaf = Issue(clock: new SteppingTimeProvider(Now, Now.AddHours(1), Now.AddHours(2))); Assert.Equal(Lifetime, leaf.NotAfter.ToUniversalTime() - leaf.NotBefore.ToUniversalTime()); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs index 021782e5cf..46664dd714 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs @@ -71,7 +71,6 @@ public async Task OpeningKeepsWhatTheChannelProofCovers() ChannelOpeningResult result = await VectorAuthority.OpenChannelAsync(authority.Authenticator, connect); - Assert.True(result.Succeeded); ChannelOpening opening = result.Opening; Assert.Equal(ChannelProof.ConnectNonce, opening.ConnectNonce); @@ -119,7 +118,6 @@ public async Task OpeningGivesTheNotAfterOfItsCertificate() opening.CertificateNotAfter); } - // SEC 1 §2.3.3: an uncompressed point is 0x04, then X and Y. private static byte[] Sec1PublicKey(byte[] subjectPublicKeyInfo) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs index e1ee30f963..dd0b38083f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs @@ -79,15 +79,14 @@ public async Task OpenChannelAsync(string nonce = TestRequest.De } /// - /// A Hello of device-a that answers the Challenge of an opening - /// that made with the default nonce. + /// A Hello of device-a that answers the Challenge of an opening of device-a. /// public static AgentMessage ValidHello(ChannelOpening opening) { byte[] proof = TestChannelProof.Sign( TestVectors.Instance.Key("device-a"), opening.Challenge.Challenge.Challenge_.Span, - TestRequest.DefaultNonce); + opening.ConnectNonce); return VectorChannel.HelloMessage(opening, proof); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs index 13bbe564f6..61aed832a9 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs @@ -38,14 +38,11 @@ public VectorAuthority() public async Task RunAsync(HttpSignatureCaseVector vector) { Clock.Now = vector.Now; - string[] signatureInput = Lines(vector.Headers.SignatureInput); - string[] signature = Lines(vector.Headers.Signature); if (vector.Endpoint == "connect") { ChannelOpeningResult opening = await OpenChannelAsync(Authenticator, vector); - return opening.Succeeded ? VectorOutcome.Ok : new VectorOutcome(opening.Error.Code.ToWireValue(), opening.FailureDetail); @@ -53,8 +50,8 @@ public async Task RunAsync(HttpSignatureCaseVector vector) SignedHttpRequest request = new( vector.Method, - signatureInput, - signature, + Lines(vector.Headers.SignatureInput), + Lines(vector.Headers.Signature), Lines(vector.Headers.ContentDigest), vector.BodyBytes); AuthenticationResult result = await Authenticator.AuthenticateAsync(Operation(vector.Endpoint), request); @@ -76,6 +73,7 @@ public static ValueTask OpenChannelAsync( Lines(vector.Headers.SignatureInput), Lines(vector.Headers.Signature)); } + /// /// The field lines of a vector header, which holds at most one. /// From 5d70eb15574896697003e09536121f8630950139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 24 Oct 2026 02:05:39 +0900 Subject: [PATCH 79/93] fix(dotnet-agent-identity)!: hash the bytes an enrollment secret encodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authority stores SHA-256 of the 32 bytes a token's secret encodes and accepts the secret only in its canonical base64url form (CONTRACT.agent-identity.md §2), so that every authority stores the same hash for the same token. EnrollmentToken.SecretHash and the hash ReadBearerToken returns now cover the decoded bytes rather than the secret's text. ReadBearerToken answers token_invalid for a secret whose pad bits are not zero, since such a secret decodes to the bytes of a canonical one. A hash stored from an earlier build of the library no longer matches its token. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../EnrollmentAuthorizationTests.cs | 49 ++++++++----------- .../Enrollment/EnrollmentTokenTests.cs | 14 ++---- .../Enrollment/EnrollmentAuthorization.cs | 13 ++++- .../EnrollmentAuthorizationResult.cs | 2 +- .../Enrollment/EnrollmentToken.cs | 19 +++---- 5 files changed, 43 insertions(+), 54 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs index 0de9a30285..2a2ca95358 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using System.Text; namespace Devolutions.AgentIdentity.Tests; @@ -10,6 +9,10 @@ public class EnrollmentAuthorizationTests private const string Secret = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; private const string Token = "dvaet1." + Bag + "." + Secret; + // The authority stores the SHA-256 of the 32 bytes the secret encodes. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + private static readonly byte[] SecretHash = SHA256.HashData([.. Enumerable.Range(0, 32).Select(i => (byte)i)]); + public static TheoryData FieldLinesWithoutABearerTokenNames => new(FieldLinesWithoutABearerToken.Keys); // Field lines that hold no bearer token, by what is wrong with them. @@ -45,6 +48,16 @@ private static Dictionary FieldLinesWithoutABearerToken ["no space"] = ["Bearer" + Token], ["a tab"] = ["Bearer\t" + Token], ["a space and a tab"] = ["Bearer \t" + Token], + + // (1): the authority accepts a secret only in its canonical base64url form, + // whose two pad bits after the 256 bits of the secret are zero. + // The secret ends in '8' (sextet 60), and '9', '-' and '_' (sextets 61 to 63) set the pad bits, + // so a decoder that ignores them would read the stored token's 32 bytes from each. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: spec RFC 4648 §3.5 + ["a secret with the low pad bit set"] = ["Bearer dvaet1." + Bag + "." + Secret[..42] + "9"], + ["a secret with the high pad bit set"] = ["Bearer dvaet1." + Bag + "." + Secret[..42] + "-"], + ["a secret with both pad bits set"] = ["Bearer dvaet1." + Bag + "." + Secret[..42] + "_"], }; // (1): a field that holds no token gets `token_invalid`. @@ -86,8 +99,8 @@ private static Dictionary FieldLinesWithoutABearerToken ["a second credential after the token"] = Token + ", Bearer " + Token, }; - // CONTRACT.agent-identity.md §2.1: the authority finds the token whose SHA-256(secret) matches, - // and §2 makes it match the secret text exactly. + // CONTRACT.agent-identity.md §2.1: the authority finds the token whose stored hash matches + // the SHA-256 of the 32 bytes its secret encodes. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [Theory] @@ -101,7 +114,7 @@ private static Dictionary FieldLinesWithoutABearerToken // oracle: spec RFC 9110 §11.4 [InlineData("two spaces", "Bearer " + Token)] [InlineData("three spaces", "Bearer " + Token)] - // CONTRACT.agent-identity.md §2.1 (1): the authority finds a token by `SHA-256(secret)` alone, + // CONTRACT.agent-identity.md §2.1 (1): the authority finds a token by the hash of its secret alone, // and §2 makes the bag routing data that the authority never trusts, so it is not decoded. // The rows are a bag of an impossible base64url length, one that decodes to bytes that are not JSON, // and the bag of {"u":"https://other/psu"}. @@ -121,7 +134,7 @@ public void ReadsTheSecretHashOfABearerToken(string name, string fieldValue) EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken([fieldValue]); Assert.True(result.Succeeded, name); - Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + Assert.Equal(SecretHash, result.SecretHash.ToArray()); } // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules @@ -147,7 +160,7 @@ public void AcceptsATokenOf4096Bytes() Assert.Equal(4096, token.Length); Assert.True(result.Succeeded); - Assert.Equal(HashOf(Secret), result.SecretHash.ToArray()); + Assert.Equal(SecretHash, result.SecretHash.ToArray()); } // CONTRACT.agent-identity.md §2: a token is at most 4096 bytes; @@ -165,24 +178,6 @@ public void RejectsATokenLongerThan4096Bytes() AssertTokenInvalid(result); } - // The authority matches the `secret` text exactly. - // Ending in '9' (sextet 61) instead of '8' (sextet 60) sets a pad bit, - // so a lenient decoder reads the same 32 bytes, but the text, and so its hash, differs. - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules - [Fact] - public void HashesANonCanonicalSecretAsItsText() - { - string nonCanonical = Secret[..42] + "9"; - - EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken( - ["Bearer dvaet1." + Bag + "." + nonCanonical]); - - Assert.True(result.Succeeded); - Assert.Equal(HashOf(nonCanonical), result.SecretHash.ToArray()); - Assert.NotEqual(HashOf(Secret), result.SecretHash.ToArray()); - } - // INTENT.agent-identity.md, security invariant 9: tokens never appear in logs, not even partially, // and a host logs the failure detail, the error and the result of a refused enrollment. // Each row takes another failure path with the token's bag and secret in the field. @@ -196,6 +191,7 @@ public void HashesANonCanonicalSecretAsItsText() new[] { "Bearer" }, new[] { "Bearer dvaet1." + Bag + new string('A', 4012) + "." + Secret }, new[] { "Bearer " + Token + "\n" }, + new[] { "Bearer dvaet1." + Bag + "." + Secret[..42] + "9" }, }; // Security invariant 9: tokens never appear in logs, not even partially. @@ -233,9 +229,4 @@ private static void AssertTokenInvalid(EnrollmentAuthorizationResult result) // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors Assert.Equal(401, result.Error.HttpStatusCode); } - - private static byte[] HashOf(string secret) - { - return SHA256.HashData(Encoding.ASCII.GetBytes(secret)); - } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index d1f7c046ab..d32ed849cb 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -1,6 +1,5 @@ using System.Buffers.Text; using System.Security.Cryptography; -using System.Text; using System.Text.Json; namespace Devolutions.AgentIdentity.Tests; @@ -23,17 +22,15 @@ public void CreateEncodesTheGeneratorBytesAsTheSecret() Assert.EndsWith("." + SecretText, token.Value, StringComparison.Ordinal); } - // The authority matches the `secret` text exactly, - // so it hashes the 43 characters rather than the bytes they encode. + // The authority stores the SHA-256 of the 32 bytes the secret encodes, not of its text. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Fact] - public void SecretHashIsTheSha256OfTheSecretText() + public void SecretHashIsTheSha256OfTheSecretBytes() { EnrollmentToken token = EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(SecretBytes)); - Assert.Equal(SHA256.HashData(Encoding.ASCII.GetBytes(SecretText)), token.SecretHash.ToArray()); + Assert.Equal(SHA256.HashData(SecretBytes), token.SecretHash.ToArray()); } // A complete `dvaet1` token matches `^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$`, @@ -63,8 +60,7 @@ public void CreateWritesEverySecretInTheContractPattern() // The authority stores SecretHash when it creates a token, // and finds the token of an enroll request by the hash that ReadBearerToken returns. - // CONTRACT.agent-identity.md §2: the authority matches the `secret` text exactly, - // so both hashes are the SHA-256 of the 43 ASCII characters of the token's secret. + // Both are the SHA-256 of the 32 bytes the token's secret encodes. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Fact] @@ -80,7 +76,7 @@ public void ReadBearerTokenReturnsTheSecretHashOfEveryCreatedToken() random.NextBytes(bytes); EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(bytes)); - byte[] expected = SHA256.HashData(Encoding.ASCII.GetBytes(token.Value.Split('.')[2])); + byte[] expected = SHA256.HashData(bytes); EnrollmentAuthorizationResult result = EnrollmentAuthorization.ReadBearerToken(["Bearer " + token.Value]); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs index cb1c36ccba..a8035e486a 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs @@ -19,7 +19,8 @@ public static class EnrollmentAuthorization /// /// /// The request gets token_invalid when the Authorization field is missing or repeated, - /// does not use the Bearer scheme, or holds no dvaet1 token of at most 4096 bytes. + /// does not use the Bearer scheme, or holds no dvaet1 token of at most 4096 bytes + /// whose secret is in canonical base64url form, with its pad bits zero. /// The scheme is case-insensitive, one or more spaces separate it from the token, /// and white space around the field value is ignored. /// @@ -78,7 +79,15 @@ public static EnrollmentAuthorizationResult ReadBearerToken(IReadOnlyList - /// The SHA-256 of the ASCII text of the token's secret, + /// The SHA-256 of the 32 bytes that the token's secret encodes, /// which is the of the stored token; /// empty when is false. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs index 953dbc0170..b9e7dc7a61 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs @@ -1,6 +1,5 @@ using System.Buffers; using System.Security.Cryptography; -using System.Text; using System.Text.Json; namespace Devolutions.AgentIdentity; @@ -47,7 +46,7 @@ private EnrollmentToken(string value, byte[] secretHash) public string Value { get; } /// - /// The SHA-256 of the ASCII text of the secret segment, under which the authority stores the token. + /// The SHA-256 of the 32 bytes that the secret segment encodes, under which the authority stores the token. /// /// /// returns the same hash for the token text, @@ -101,7 +100,7 @@ public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerato randomNumberGenerator.GetBytes(secretBytes); string secret = StrictBase64.EncodeBase64Url(secretBytes); - return new EnrollmentToken(Prefix + "." + bag + "." + secret, HashSecret(secret)); + return new EnrollmentToken(Prefix + "." + bag + "." + secret, HashSecret(secretBytes)); } /// @@ -113,17 +112,11 @@ public override string ToString() } /// - /// Computes the hash under which the authority stores a token: the SHA-256 of the ASCII text of its secret. + /// Computes the hash under which the authority stores a token: the SHA-256 of the 32 bytes its secret encodes. /// - /// - /// The authority matches the secret text exactly, so the hash covers the text rather than the bytes it encodes. - /// - /// The secret segment of the token, in the base64url alphabet. - internal static byte[] HashSecret(ReadOnlySpan secret) + /// The 32 bytes that the secret segment of the token encodes. + internal static byte[] HashSecret(ReadOnlySpan secretBytes) { - byte[] ascii = new byte[secret.Length]; - Encoding.ASCII.GetBytes(secret, ascii); - - return SHA256.HashData(ascii); + return SHA256.HashData(secretBytes); } } From 4c57ab4036e4872ea12c6dafe4c7de2a6806f788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 24 Oct 2026 02:06:54 +0900 Subject: [PATCH 80/93] test(dotnet-agent-identity): pin the refusal of a token URL password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token's product base URL carries no user name or password (CONTRACT.agent-identity.md §2), which could make an address read as one host while it reaches another. The library already refused any user information; rows with a password alone and with a user name and password now pin it, and the user name rows cite the contract. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Support/UnacceptableUrls.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs index 1333aef8f1..1ac87150a8 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs @@ -6,8 +6,8 @@ namespace Devolutions.AgentIdentity.Tests; /// public static class UnacceptableUrls { - // CONTRACT.agent-identity.md §2 makes `u` and §5.2 makes `agent_channel_url` an absolute `https` URL - // without a query or fragment. + // CONTRACT.agent-identity.md §2 makes `u` an absolute `https` URL without a user name or password, + // a query or a fragment, and §5.2 makes `agent_channel_url` one without a query or fragment. // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. public static string[] All { get; } = [ @@ -42,8 +42,15 @@ public static class UnacceptableUrls // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll // oracle: spec RFC 3986 §3.5 "https://host/dvls#", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token // oracle: spec RFC 9110 §4.2.4 "https://admin@host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: spec RFC 9110 §4.2.4 + "https://admin:secret@host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: spec RFC 9110 §4.2.4 + "https://:secret@host/dvls", // oracle: spec RFC 9110 §4.2.4 "https://@host/dvls", ]; From 21df0f0d7b5328240969c8b2fc5d586054a1077f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 24 Oct 2026 09:52:32 +0900 Subject: [PATCH 81/93] fix(dotnet-agent-identity)!: call an enrollment token revoked, not deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authority revokes a token that is no longer wanted instead of deleting it (CONTRACT.agent-identity.md §2.1). A revoked token keeps its secret hash, so a lost enrollment response stays recoverable, and gets token_invalid for anything but that replay, as a deleted token did. EnrollmentTokenState's deleted parameter and Deleted property become revoked and Revoked, and the docs and failure details say revoked. BREAKING CHANGE: EnrollmentTokenState(bool deleted, ...) is now EnrollmentTokenState(bool revoked, ...), and EnrollmentTokenState.Deleted is now EnrollmentTokenState.Revoked. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Enrollment/EnrollmentRulesTests.cs | 32 +++++++++---------- .../Enrollment/EnrollmentAuthorization.cs | 2 +- .../Enrollment/EnrollmentRules.cs | 12 +++---- .../Enrollment/EnrollmentTokenState.cs | 15 ++++----- .../Enrollment/IssuedKeyMatch.cs | 2 +- .../Errors/AgentIdentityErrorCode.cs | 2 +- dotnet/AgentIdentity/README.md | 4 +-- 7 files changed, 34 insertions(+), 35 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index 8422ce37e8..d4a9f8bda8 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -31,7 +31,7 @@ public class EnrollmentRulesTests Now), }; - // A token creates an identity when it exists, has not been deleted, + // A token creates an identity when it exists, has not been revoked, // has not expired, and fewer than `max_uses` identities have been created with it; (6) a new identity. // The rows are a usable token, one that expires one tick after now, and one that can create one more identity. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules @@ -90,7 +90,7 @@ public void CarriesNoNewIdentityIdWhenCreatingNone(EnrollmentOutcome outcome) } // When several outcomes apply, the earliest wins: - // (4) `token_invalid` for a deleted token, then `token_expired` for an expired one; (5) `token_exhausted`. + // (4) `token_invalid` for a revoked token, then `token_expired` for an expired one; (5) `token_exhausted`. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] // `token_expired` when `now ≥ expires_at`. @@ -101,7 +101,7 @@ public void CarriesNoNewIdentityIdWhenCreatingNone(EnrollmentOutcome outcome) [InlineData(false, TimeSpan.TicksPerDay, 10, 10, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 10, 11, AgentIdentityErrorCode.TokenExhausted)] [InlineData(false, TimeSpan.TicksPerDay, 0, 0, AgentIdentityErrorCode.TokenExhausted)] - // A deleted token authenticates only for an idempotent replay; + // A revoked token authenticates only for an idempotent replay; // every other use returns `token_invalid`, whether or not the token has also expired or been exhausted. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [InlineData(true, TimeSpan.TicksPerDay, 10, 3, AgentIdentityErrorCode.TokenInvalid)] @@ -112,13 +112,13 @@ public void CarriesNoNewIdentityIdWhenCreatingNone(EnrollmentOutcome outcome) // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [InlineData(false, 0L, 10, 10, AgentIdentityErrorCode.TokenExpired)] public void RejectsANewKeyWithTheEarliestTokenOutcome( - bool deleted, + bool revoked, long ticksToExpiry, int maxUses, int identitiesCreated, AgentIdentityErrorCode expected) { - EnrollmentTokenState token = new(deleted, Now.AddTicks(ticksToExpiry), maxUses, identitiesCreated); + EnrollmentTokenState token = new(revoked, Now.AddTicks(ticksToExpiry), maxUses, identitiesCreated); EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); @@ -127,8 +127,8 @@ public void RejectsANewKeyWithTheEarliestTokenOutcome( // The key of the current certificate of a non-revoked identity enrolled with the // same token → 200 with that identity, without counting a use. - // The key lookup (3) comes before (4) and (5), and a deleted token authenticates for this replay, - // so the replay wins whether the token is deleted, expired or exhausted. + // The key lookup (3) comes before (4) and (5), and a revoked token authenticates for this replay, + // so the replay wins whether the token is revoked, expired or exhausted. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [InlineData(false, false, false)] @@ -136,11 +136,11 @@ public void RejectsANewKeyWithTheEarliestTokenOutcome( [InlineData(false, true, false)] [InlineData(false, false, true)] [InlineData(true, true, true)] - public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool deleted, bool expired, bool exhausted) + public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool revoked, bool expired, bool exhausted) { IssuedKeyMatch keyMatch = IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current); - EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted, expired, exhausted), keyMatch, Now); + EnrollmentDecision decision = EnrollmentRules.Decide(Token(revoked, expired, exhausted), keyMatch, Now); Assert.Equal(EnrollmentOutcome.ReturnExistingIdentity, decision.Outcome); Assert.Equal(IdentityId, decision.ExistingIdentityId); @@ -206,14 +206,14 @@ public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, boo AssertRejected(AgentIdentityErrorCode.IdentityRevoked, decision); } - // (3): a deleted token gets `token_invalid` for anything but an idempotent replay, + // (3): a revoked token gets `token_invalid` for anything but an idempotent replay, // whatever the CSR public key matches. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules [Theory] [MemberData(nameof(KeysButTheReplayNames))] - public void RejectsADeletedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(string key) + public void RejectsARevokedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(string key) { - EnrollmentDecision decision = EnrollmentRules.Decide(Token(deleted: true), KeysButTheReplay[key], Now); + EnrollmentDecision decision = EnrollmentRules.Decide(Token(revoked: true), KeysButTheReplay[key], Now); AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); } @@ -267,16 +267,16 @@ private static void AssertRejected(AgentIdentityErrorCode expected, EnrollmentDe Assert.Equal(EnrollmentOutcome.Reject, decision.Outcome); Assert.Equal(expected, decision.Error?.Code); Assert.Null(decision.ExistingIdentityId); - // oracle: mutant an empty failure detail for an enrollment refused for a deleted, expired or exhausted token, + // oracle: mutant an empty failure detail for an enrollment refused for a revoked, expired or exhausted token, // or for a CSR key of a deleted, revoked or other-token identity or of a pending or retired certificate Assert.False(string.IsNullOrEmpty(decision.FailureDetail)); } - // A token that is neither deleted, expired nor exhausted, unless a flag says otherwise. - private static EnrollmentTokenState Token(bool deleted = false, bool expired = false, bool exhausted = false) + // A token that is neither revoked, expired nor exhausted, unless a flag says otherwise. + private static EnrollmentTokenState Token(bool revoked = false, bool expired = false, bool exhausted = false) { return new EnrollmentTokenState( - deleted, + revoked, expiresAt: expired ? Now : Now.AddDays(1), maxUses: 10, identitiesCreated: exhausted ? 10 : 3); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs index a8035e486a..fe50a1517b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs @@ -26,7 +26,7 @@ public static class EnrollmentAuthorization /// /// /// The authority finds a token by its secret alone, so the bag is not decoded. - /// The lookup includes deleted tokens, which keep their hash, + /// The lookup includes revoked tokens, which keep their hash, /// and refuses them except for a replay. /// When no stored token has , /// the request gets token_invalid too. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs index 98cb928d4d..9c1fc7832e 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs @@ -15,8 +15,8 @@ public static class EnrollmentRules /// and the request body, CSR and metadata are valid. /// When several outcomes apply, the earliest of these wins: /// the result of the CSR key lookup, - /// except that a deleted token gets token_invalid for anything but a replay; - /// token_invalid for a deleted token, + /// except that a revoked token gets token_invalid for anything but a replay; + /// token_invalid for a revoked token, /// then token_expired once reaches its expiry; /// token_exhausted once it has created identities; /// a new identity, whose ID, , is minted at random for this decision. @@ -52,19 +52,19 @@ public static EnrollmentDecision Decide(EnrollmentTokenState token, IssuedKeyMat return EnrollmentDecision.ReturnExistingIdentity(keyMatch.IdentityId); } - if (token.Deleted) + if (token.Revoked) { return EnrollmentDecision.Reject( AgentIdentityErrorCode.TokenInvalid, - "The token is deleted, and the CSR key is not the current key of an identity that it created."); + "The token is revoked, and the CSR key is not the current key of an identity that it created."); } return RejectIssuedKey(keyMatch); } - if (token.Deleted) + if (token.Revoked) { - return EnrollmentDecision.Reject(AgentIdentityErrorCode.TokenInvalid, "The token is deleted."); + return EnrollmentDecision.Reject(AgentIdentityErrorCode.TokenInvalid, "The token is revoked."); } if (!(now < token.ExpiresAt)) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs index fd7c5d8873..83c0dbb167 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs @@ -4,39 +4,38 @@ namespace Devolutions.AgentIdentity; /// The stored state of the enrollment token that an enroll request presents. /// /// -/// None of it changes after the token is created, except the count of identities it created and its deletion. +/// None of it changes after the token is created, except the count of identities it created and its revocation. /// public sealed class EnrollmentTokenState { /// /// Creates the state of a token. /// - /// Whether the token was deleted. + /// Whether the token was revoked. /// The instant from which the token is expired. /// The number of identities that the token may create. /// The number of identities that the token has created. /// /// or is negative. /// - public EnrollmentTokenState(bool deleted, DateTimeOffset expiresAt, int maxUses, int identitiesCreated) + public EnrollmentTokenState(bool revoked, DateTimeOffset expiresAt, int maxUses, int identitiesCreated) { ArgumentOutOfRangeException.ThrowIfNegative(maxUses); ArgumentOutOfRangeException.ThrowIfNegative(identitiesCreated); - Deleted = deleted; + Revoked = revoked; ExpiresAt = expiresAt; MaxUses = maxUses; IdentitiesCreated = identitiesCreated; } /// - /// Whether the token was deleted. + /// Whether the token was revoked. /// /// - /// A deleted token keeps its secret hash and deletion time as a tombstone, - /// so that a device can still recover the enrollment response of an identity that the token created. + /// A revoked token keeps its secret hash, so that a device can still recover the enrollment response of an identity that the token created. /// - public bool Deleted { get; } + public bool Revoked { get; } /// /// The instant from which the token is expired. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs index a32c410fc4..fc8900d0aa 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs @@ -78,7 +78,7 @@ public static IssuedKeyMatch ForIdentity( /// After an identity is deleted, the authority keeps only the SPKI SHA-256 and the issuance time of its keys, /// so refuses the key with identity_revoked whatever token enrolled /// the identity. - /// A deleted presented token gets token_invalid instead, since it authenticates nothing but a replay. + /// A revoked presented token gets token_invalid instead, since it authenticates nothing but a replay. /// refuses the key with invalid_request, as it does any key of another /// identity. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs index 17c4b423ea..97d1db68fd 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs @@ -15,7 +15,7 @@ public enum AgentIdentityErrorCode // values start at 1 so that an uninitialized code is not a valid one. /// - /// The enrollment token is unknown or deleted, or its secret does not match. + /// The enrollment token is unknown or revoked, or its secret does not match. /// TokenInvalid = 1, diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 86537b8554..2deb4ace7b 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -18,7 +18,7 @@ The contract is `docs/agent-identity/CONTRACT.agent-identity.md` in the [devolut The host keeps everything else: -- Storage: identities, certificates and their statuses, the SPKI SHA-256 of every issued key, token hashes and tombstones, accepted nonces, metadata, configs and request-renewal flags. +- Storage: identities, certificates and their statuses, the SPKI SHA-256 of every issued key, token hashes, revoked tokens included, accepted nonces, metadata, configs and request-renewal flags. - Hosting: routes, TLS, the gRPC service and its timers, and when to send each push message. - Management: tokens, identities and root rotation, and the permissions that guard them. - The root CA and its private key at rest; the library reaches the key only through `IRootSigner`. @@ -69,7 +69,7 @@ Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normall 1. Answer a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge`. 2. Pass the `Authorization` field lines, as received, to `EnrollmentAuthorization.ReadBearerToken`. - Find the stored token by the result's `SecretHash`, deleted tokens included, and answer `AgentIdentityError.From(AgentIdentityErrorCode.TokenInvalid)` when there is none. + Find the stored token by the result's `SecretHash`, revoked tokens included, and answer `AgentIdentityError.From(AgentIdentityErrorCode.TokenInvalid)` when there is none. 3. Parse the body with `EnrollRequestBody.Parse`. 4. Look up `Csr.SubjectPublicKeyInfoSha256` among the keys of every certificate the authority has issued, deleted identities included. Describe the match with `IssuedKeyMatch.ForIdentity` or `IssuedKeyMatch.ForDeletedIdentity`, or use null when there is none. From 8fb95b74c7c26b06f07c3be6cf9216264ab0801d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 24 Oct 2026 09:54:50 +0900 Subject: [PATCH 82/93] fix(dotnet-agent-identity): refuse any @ in a token or channel URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token's u and a config's agent_channel_url are read as text: each begins with https:// and contains no @, and any @ counts as a user name (CONTRACT.agent-identity.md §2 and §5.2), because URL parsers disagree on forms such as https://@host. The library refused only an @ that .NET's parser placed in the user information, so https://host/dvls/a@b made a token whose u holds an @. It now refuses any @ in the URL text. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Bodies/IdentityConfigTests.cs | 4 +-- .../Support/UnacceptableUrls.cs | 26 +++++++++++++------ .../Bodies/IdentityConfig.cs | 4 +-- .../Enrollment/EnrollmentToken.cs | 4 +-- .../Protocol/BaseUrl.cs | 10 ++++--- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs index 8800517174..20ef15c8cd 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs @@ -69,8 +69,8 @@ public void CreateRejectsAnotherAgentChannelUrl(string agentChannelUrl) Assert.Equal("agentChannelUrl", exception.ParamName); } - // `agent_channel_url` is an absolute `https` URL without a query or fragment, which the config carries as the host - // gave it. + // `agent_channel_url` is an absolute `https` URL without an `@`, a query or a fragment, + // which the config carries as the host gave it. // Whether a URL ending in a slash is rejected or written without the slash is the library's choice. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs index 1ac87150a8..b0104e3dea 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs @@ -1,14 +1,14 @@ namespace Devolutions.AgentIdentity.Tests; /// -/// URLs that are not an absolute https URL without user information, a query or a fragment, +/// URLs that are not an absolute https URL without an @, a query or a fragment, /// the form that a token's u and a config's agent_channel_url both take. /// public static class UnacceptableUrls { - // CONTRACT.agent-identity.md §2 makes `u` an absolute `https` URL without a user name or password, - // a query or a fragment, and §5.2 makes `agent_channel_url` one without a query or fragment. - // RFC 9110 §4.2.4 deprecates user information in an `https` URI and tells a recipient to treat it as an error. + // CONTRACT.agent-identity.md §2 makes `u`, and §5.2 makes `agent_channel_url`, an absolute `https` URL + // without a user name or password, a query or a fragment, read as text: + // it begins with `https://` and contains no '@', since any '@' counts as a user name. public static string[] All { get; } = [ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token @@ -43,16 +43,26 @@ public static class UnacceptableUrls // oracle: spec RFC 3986 §3.5 "https://host/dvls#", // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - // oracle: spec RFC 9110 §4.2.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll "https://admin@host/dvls", // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - // oracle: spec RFC 9110 §4.2.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll "https://admin:secret@host/dvls", // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - // oracle: spec RFC 9110 §4.2.4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll "https://:secret@host/dvls", - // oracle: spec RFC 9110 §4.2.4 + // Any '@' counts as a user name, even with nothing before it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll "https://@host/dvls", + // Read as text, a URL contains no '@' anywhere, even after the authority. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://host/dvls/a@b", + // Read as text, a URL begins with `https://`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https:@host/dvls", ]; public static TheoryData Rows => new(All); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs index 2e5106748f..d6b030b862 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs @@ -76,13 +76,13 @@ private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) /// The revision of the config. /// /// The absolute https base URL of the agent channel, such as https://host/dvls, - /// without user information, query or fragment; + /// without user information or any other @, query or fragment; /// null when the authority does not offer the channel to the identity, so that the agent opens none. /// /// The product-specific fields, if any. /// /// is not an absolute https URL, - /// or has user information, a query or a fragment; + /// or has user information or any other @, a query or a fragment; /// or a product-specific field has no name, a name that is not snake_case or that the contract gives a member, /// no value, the value null, or a value that UTF-8 JSON cannot carry, such as a lone surrogate; /// or a member inside a value has a name that is not snake_case or that its object repeats, or the value diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs index b9e7dc7a61..ee397917e5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs @@ -65,13 +65,13 @@ private EnrollmentToken(string value, byte[] secretHash) /// /// /// The absolute https URL of the product, such as https://host/dvls, - /// without user information, query or fragment. + /// without user information or any other @, query or fragment. /// /// A cryptographically secure generator, which supplies the secret. /// An argument is null. /// /// is not an absolute https URL, - /// has user information, a query or a fragment, or makes the token longer than 4096 bytes. + /// has user information or any other @, a query or a fragment, or makes the token longer than 4096 bytes. /// public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerator randomNumberGenerator) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs index a76be6b031..17409eb131 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs @@ -13,7 +13,8 @@ internal static class BaseUrl /// What the URL is, as error messages start, such as The product base URL. /// The parameter that holds the URL. /// - /// The URL is not an absolute https URL, or has user information, a query or a fragment. + /// The URL is not an absolute https URL, + /// or has user information or any other @, a query or a fragment. /// public static string Normalize(string url, string name, string paramName) { @@ -27,10 +28,11 @@ public static string Normalize(string url, string name, string paramName) throw new ArgumentException($"{name} does not use the https scheme.", paramName); } - // UserInfo is empty for https://@host, so only the component with its delimiter shows the '@'. - if (parsed.GetComponents(UriComponents.UserInfo | UriComponents.KeepDelimiter, UriFormat.UriEscaped).Length != 0) + // CONTRACT.agent-identity.md §2 reads the URL as text, where any '@' counts as a user name, + // because URL parsers disagree on forms such as https://@host. + if (url.Contains('@')) { - throw new ArgumentException($"{name} has user information.", paramName); + throw new ArgumentException($"{name} contains an '@', which counts as user information.", paramName); } // Query and Fragment include their delimiter, so an empty query or fragment still makes them non-empty. From a400c56a6b8e40d71103500fbd5703c8216532c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 24 Oct 2026 21:12:16 +0900 Subject: [PATCH 83/93] docs(dotnet-agent-identity): mark the untested UTC reading of validity Certificate validity reaches the library as local DateTime values from X509Certificate2, and it converts each straight to UTC. The conversion is exact in the hour that a daylight-saving fall-back repeats only because that DateTime keeps its ambiguous-DST bit, which no test pins without swapping the process's time zone. An UNTESTED comment at each conversion says so, and what would break it. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Authentication/RequestAuthenticator.cs | 6 ++++++ .../Devolutions.AgentIdentity/Bodies/ResponseBodies.cs | 6 ++++++ .../Certificates/IdentityCertificateIssuer.cs | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs index 4265e40f62..4a9b7581d1 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs @@ -247,6 +247,12 @@ private async ValueTask VerifyAsync( return Verification.Reject(AgentIdentityErrorCode.IdentityRevoked, "Step 4: the identity is revoked."); } + // UNTESTED: the conversion is exact in the hour that a daylight-saving fall-back repeats, + // because the local DateTime that X509Certificate2 returns keeps the ambiguous-DST bit, + // which ToUniversalTime uses. + // A wrong instant there shifts expiry and validity checks by an hour on hosts outside UTC. + // Keep the conversion straight from the property: arithmetic, formatting or a DateTimeOffset built from the + // local value before ToUniversalTime loses the bit. DateTimeOffset notBefore = new(certificate.NotBefore.ToUniversalTime()); DateTimeOffset notAfter = new(certificate.NotAfter.ToUniversalTime()); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs index d240ba6d56..82a07d7807 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs @@ -211,6 +211,12 @@ private static void WriteConfig(Utf8JsonWriter writer, IdentityConfig config) } // X509Certificate2 gives its validity in local time. + // UNTESTED: the conversion is exact in the hour that a daylight-saving fall-back repeats, + // because the local DateTime that X509Certificate2 returns keeps the ambiguous-DST bit, + // which ToUniversalTime uses. + // A wrong instant there publishes a root's validity an hour off on hosts outside UTC. + // Keep the conversion straight from the property: arithmetic, formatting or a DateTimeOffset built from the + // local value before ToUniversalTime loses the bit. private static string Timestamp(DateTime localTime) { return AgentIdentityProtocol.FormatTimestamp(new DateTimeOffset(localTime.ToUniversalTime())); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs index 1a7030ad66..0548431056 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs @@ -106,6 +106,12 @@ private static X509Certificate2 Issue( } DateTimeOffset notBefore = TruncateToSecond(timeProvider.GetUtcNow()); + // UNTESTED: the conversion is exact in the hour that a daylight-saving fall-back repeats, + // because the local DateTime that X509Certificate2 returns keeps the ambiguous-DST bit, + // which ToUniversalTime uses. + // A wrong instant there shifts expiry and validity checks by an hour on hosts outside UTC. + // Keep the conversion straight from the property: arithmetic, formatting or a DateTimeOffset built from the + // local value before ToUniversalTime loses the bit. DateTimeOffset issuerNotAfter = new(issuerCertificate.NotAfter.ToUniversalTime(), TimeSpan.Zero); if (issuerNotAfter <= notBefore) From 3148d35bba9c186dcd1b3494c17f6b69897ae943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 02:35:54 +0900 Subject: [PATCH 84/93] fix(dotnet-agent-identity): refuse a token URL that is not ASCII MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token's u holds only ASCII characters, so that certificates can carry it as an IA5String (CONTRACT.agent-identity.md §2); an internationalized host is written in its xn-- form. EnrollmentToken.Create now refuses a product base URL that is not ASCII, which also refuses a host holding a character such as the fullwidth commercial at. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Enrollment/EnrollmentTokenTests.cs | 13 ++++++++++--- .../Enrollment/EnrollmentToken.cs | 13 +++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index d32ed849cb..520a02250d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -126,10 +126,17 @@ public void CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, Assert.Contains(creation, (Creation[])[Creation.Holding(normalized), Creation.Rejection]); } - // The unacceptable URLs, and a URL one byte longer than the longest that fits in a token - // (CONTRACT.agent-identity.md §2). + // The unacceptable URLs, a URL one byte longer than the longest that fits in a token, + // and URLs that are not ASCII, since `u` holds only ASCII characters (CONTRACT.agent-identity.md §2): + // an internationalized host not in its `xn--` form, and a fullwidth commercial at (U+FF20) in the host. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token - public static TheoryData RejectedUrlRows => new([.. UnacceptableUrls.All, UrlOfLength(3026)]); + public static TheoryData RejectedUrlRows => new( + [ + .. UnacceptableUrls.All, + UrlOfLength(3026), + "https://bücher.example/dvls", + "https://admin@host/dvls", + ]); // oracle: spec Framework Design Guidelines §Validating Arguments [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs index ee397917e5..f659b9b921 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace Devolutions.AgentIdentity; @@ -64,13 +65,13 @@ private EnrollmentToken(string value, byte[] secretHash) /// The secret is 32 bytes from . /// /// - /// The absolute https URL of the product, such as https://host/dvls, + /// The absolute https URL of the product, in ASCII, such as https://host/dvls, /// without user information or any other @, query or fragment. /// /// A cryptographically secure generator, which supplies the secret. /// An argument is null. /// - /// is not an absolute https URL, + /// is not ASCII, is not an absolute https URL, /// has user information or any other @, a query or a fragment, or makes the token longer than 4096 bytes. /// public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerator randomNumberGenerator) @@ -78,6 +79,14 @@ public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerato ArgumentNullException.ThrowIfNull(productBaseUrl); ArgumentNullException.ThrowIfNull(randomNumberGenerator); + // CONTRACT.agent-identity.md §2: `u` holds only ASCII characters, since certificates carry it as an IA5String. + if (!Ascii.IsValid(productBaseUrl)) + { + throw new ArgumentException( + "The product base URL is not ASCII; write an internationalized host in its xn-- form.", + nameof(productBaseUrl)); + } + ArrayBufferWriter json = new(); using (Utf8JsonWriter writer = new(json)) From f63a6ffcb2efc6bc2c80812c090178a7f93f13c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 02:38:47 +0900 Subject: [PATCH 85/93] fix(dotnet-agent-identity)!: issue only under a root of the chain profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every certificate in a chain is signed with ecdsa-with-SHA256 by a P-256 key or with ecdsa-with-SHA384 by a P-384 key, never with RSA, and every CA certificate, the root included, carries basicConstraints with cA set and key usage with keyCertSign (CONTRACT.agent-identity.md §4). The issuer signed with SHA-256 and whatever generator the host's IRootSigner gave, so an RSA root, another curve, or a root that is not a CA produced chains the contract forbids. IdentityCertificateIssuer.Issue now picks the hash from the issuer key's curve and throws ArgumentException, with ParamName issuer, unless the issuer certificate has a P-256 or P-384 key, basicConstraints with cA set and key usage with keyCertSign, and its generator's algorithm identifier for that hash is exactly the matching ECDSA one without parameters. IRootSigner's docs and the README name the profile. BREAKING CHANGE: IdentityCertificateIssuer.Issue refuses an issuer outside that profile; a host whose root is RSA, another curve, or not a CA certificate must replace it. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../IdentityCertificateIssuerTests.cs | 145 ++++++++++++++++++ .../Certificates/IRootSigner.cs | 7 +- .../Certificates/IdentityCertificateIssuer.cs | 85 +++++++++- dotnet/AgentIdentity/README.md | 2 + 4 files changed, 234 insertions(+), 5 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index d7e4b94cce..623ae9fc16 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -356,6 +356,97 @@ public void ThrowsWhenTheIssuerHasNoSubjectKeyIdentifier() Assert.Throws(() => Issue(root: root)); } + // Every certificate in a chain is signed with ecdsa-with-SHA256 by a P-256 key or with ecdsa-with-SHA384 by a P-384 + // key, never with RSA, and every CA certificate, the root included, carries basicConstraints with cA set and key + // usage with keyCertSign, so the library issues nothing under a root that breaks one of these. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: spec Framework Design Guidelines §Validating Arguments + [Theory] + [MemberData(nameof(IssuersOutsideTheChainProfileNames))] + public void ThrowsForAnIssuerOutsideTheChainProfile(string issuer) + { + using OwnedRootSigner root = IssuersOutsideTheChainProfile[issuer](); + + ArgumentException exception = Assert.Throws(() => IdentityCertificateIssuer.Issue( + _creating, + _csr, + root, + Lifetime, + new FixedTimeProvider(Now), + _random)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("issuer", exception.ParamName); + } + + public static TheoryData IssuersOutsideTheChainProfileNames => new(IssuersOutsideTheChainProfile.Keys); + + // A root of each allowed curve signs the certificate with the signature algorithm of its curve, + // and the certificate chains to it: ecdsa-with-SHA256 for P-256 and ecdsa-with-SHA384 for P-384 (RFC 5758 §3.2). + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: spec RFC 5758 §3.2 + [Theory] + [InlineData("P-256", "1.2.840.10045.4.3.2")] + [InlineData("P-384", "1.2.840.10045.4.3.3")] + public void SignsWithTheAlgorithmOfTheIssuerCurve(string curve, string signatureAlgorithm) + { + using OwnedRootSigner root = EcdsaProfileRoot( + curve == "P-256" ? ECCurve.NamedCurves.nistP256 : ECCurve.NamedCurves.nistP384); + + using X509Certificate2 leaf = IdentityCertificateIssuer.Issue( + _creating, + _csr, + root, + Lifetime, + new FixedTimeProvider(Now), + _random); + + Assert.Equal(signatureAlgorithm, leaf.SignatureAlgorithm.Value); + using X509Chain chain = new(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(root.Certificate); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationTime = Now.AddHours(1).UtcDateTime; + Assert.True(chain.Build(leaf), string.Join("; ", chain.ChainStatus.Select(status => status.StatusInformation))); + } + + // Roots that break the chain profile, by what they break; every other part of each is a conforming CA root. + private static Dictionary> IssuersOutsideTheChainProfile => new() + { + ["an RSA key"] = () => + { + RSA key = RSA.Create(2048); + return new OwnedRootSigner( + ProfileRoot(new CertificateRequest(RootName, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)), + X509SignatureGenerator.CreateForRSA(key, RSASignaturePadding.Pkcs1), + key); + }, + ["a P-521 key"] = () => EcdsaProfileRoot(ECCurve.NamedCurves.nistP521), + ["a P-256 certificate whose signer is RSA"] = () => + { + ECDsa certificateKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + RSA key = RSA.Create(2048); + X509Certificate2 certificate = + ProfileRoot(new CertificateRequest(RootName, certificateKey, HashAlgorithmName.SHA256)); + certificateKey.Dispose(); + return new OwnedRootSigner( + certificate, + X509SignatureGenerator.CreateForRSA(key, RSASignaturePadding.Pkcs1), + key); + }, + ["no basicConstraints"] = () => EcdsaProfileRoot(ECCurve.NamedCurves.nistP256, [CaKeyUsage()]), + ["basicConstraints without cA"] = () => EcdsaProfileRoot( + ECCurve.NamedCurves.nistP256, + [new X509BasicConstraintsExtension(false, false, 0, true), CaKeyUsage()]), + ["no key usage"] = () => EcdsaProfileRoot(ECCurve.NamedCurves.nistP256, [CaBasicConstraints()]), + ["key usage without keyCertSign"] = () => EcdsaProfileRoot( + ECCurve.NamedCurves.nistP256, + [ + CaBasicConstraints(), + new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.CrlSign, true), + ]), + }; + // Code that acts for an identity takes it as a type only the library's checks can create; // for a new identity, that is the enrollment decision that creates it. // A refusal creates no identity, and a replay returns the identity's current chain rather than a new certificate. @@ -533,6 +624,60 @@ private static byte[] SerialNumber(X509Certificate2 certificate) return tbsCertificate.ReadIntegerBytes().ToArray(); } + private const string RootName = "CN=Test Agent Identity Root"; + + // A self-signed root with a subject key identifier and the given extensions, + // by default those of a CA root: basicConstraints with cA set, and key usage with keyCertSign. + private static X509Certificate2 ProfileRoot(CertificateRequest request, X509Extension[]? extensions = null) + { + foreach (X509Extension extension in extensions ?? [CaBasicConstraints(), CaKeyUsage()]) + { + request.CertificateExtensions.Add(extension); + } + + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + return request.CreateSelfSigned(Now.AddDays(-1), Now.AddYears(10)); + } + + private static OwnedRootSigner EcdsaProfileRoot(ECCurve curve, X509Extension[]? extensions = null) + { + ECDsa key = ECDsa.Create(curve); + + return new OwnedRootSigner( + ProfileRoot(new CertificateRequest(RootName, key, HashAlgorithmName.SHA256), extensions), + X509SignatureGenerator.CreateForECDsa(key), + key); + } + + private static X509BasicConstraintsExtension CaBasicConstraints() + { + return new X509BasicConstraintsExtension(true, false, 0, true); + } + + private static X509KeyUsageExtension CaKeyUsage() + { + return new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true); + } + + // A root signer that owns its certificate and the key behind its generator. + private sealed class OwnedRootSigner( + X509Certificate2 certificate, + X509SignatureGenerator generator, + IDisposable key) + : IRootSigner, IDisposable + { + public X509Certificate2 Certificate => certificate; + + public X509SignatureGenerator SignatureGenerator => generator; + + public void Dispose() + { + certificate.Dispose(); + key.Dispose(); + } + } + private sealed class TestRootSigner(X509Certificate2 certificate, ECDsa key) : IRootSigner { public X509Certificate2 Certificate => certificate; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs index a4966c9067..50a1896784 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs @@ -8,7 +8,9 @@ namespace Devolutions.AgentIdentity; public interface IRootSigner { /// - /// The root certificate, which must have a subject key identifier. + /// The root certificate, which must have a subject key identifier, + /// and fit the chain profile of CONTRACT.agent-identity.md §4: a P-256 or P-384 key, basicConstraints with cA set, + /// and key usage with keyCertSign. /// /// /// Its subject becomes the issuer of each identity certificate, @@ -17,7 +19,8 @@ public interface IRootSigner X509Certificate2 Certificate { get; } /// - /// Signs identity certificates with the private key of . + /// Signs identity certificates with the private key of , + /// with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a P-384 key. /// X509SignatureGenerator SignatureGenerator { get; } } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs index 0548431056..6417e5abf4 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs @@ -14,6 +14,24 @@ public static class IdentityCertificateIssuer private const string SubjectKeyIdentifierOid = "2.5.29.14"; + private const string KeyUsageOid = "2.5.29.15"; + + private const string BasicConstraintsOid = "2.5.29.19"; + + // RFC 5480 §2.1.1: id-ecPublicKey. + private const string EcPublicKeyOid = "1.2.840.10045.2.1"; + + // RFC 5480 §2.1.1.1: the DER encodings of the namedCurve secp256r1 (P-256) and secp384r1 (P-384). + private static readonly byte[] P256CurveParameters = [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]; + private static readonly byte[] P384CurveParameters = [0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22]; + + // RFC 5758 §3.2: the DER AlgorithmIdentifiers of ecdsa-with-SHA256 and ecdsa-with-SHA384, + // whose parameters are absent. + private static readonly byte[] EcdsaWithSha256Identifier = + [0x30, 0x0A, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02]; + private static readonly byte[] EcdsaWithSha384Identifier = + [0x30, 0x0A, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x03]; + /// /// The lifetime of an identity certificate when the host sets no other: 90 days. /// @@ -56,7 +74,11 @@ public static class IdentityCertificateIssuer /// An argument is null. /// /// does not create an identity; - /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second. + /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second; + /// or the issuer is outside the chain profile of CONTRACT.agent-identity.md §4: + /// its certificate has neither a P-256 nor a P-384 key, no basicConstraints with cA set or no key usage with + /// keyCertSign, or its generator does not sign with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a + /// P-384 key. /// /// /// is shorter than one second. @@ -105,6 +127,36 @@ private static X509Certificate2 Issue( throw new ArgumentException("The issuer certificate has no subject key identifier.", nameof(issuer)); } + // CONTRACT.agent-identity.md §4: every certificate in a chain is signed with ecdsa-with-SHA256 by a P-256 key + // or with ecdsa-with-SHA384 by a P-384 key, and every CA certificate carries basicConstraints with cA set + // and key usage with keyCertSign. + (HashAlgorithmName hash, byte[] expectedAlgorithm) = SignatureProfile(issuerCertificate.PublicKey) + ?? throw new ArgumentException( + "The issuer certificate's key is neither a P-256 nor a P-384 key.", + nameof(issuer)); + + byte[] signatureAlgorithm = issuer.SignatureGenerator.GetSignatureAlgorithmIdentifier(hash); + if (!signatureAlgorithm.AsSpan().SequenceEqual(expectedAlgorithm)) + { + throw new ArgumentException( + "The issuer does not sign with the ECDSA algorithm of its key's curve.", + nameof(issuer)); + } + + if (issuerCertificate.Extensions[BasicConstraintsOid] is not X509BasicConstraintsExtension + { + CertificateAuthority: true, + }) + { + throw new ArgumentException("The issuer certificate is not a CA certificate.", nameof(issuer)); + } + + if (issuerCertificate.Extensions[KeyUsageOid] is not X509KeyUsageExtension keyUsage + || !keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.KeyCertSign)) + { + throw new ArgumentException("The issuer certificate's key usage has no keyCertSign.", nameof(issuer)); + } + DateTimeOffset notBefore = TruncateToSecond(timeProvider.GetUtcNow()); // UNTESTED: the conversion is exact in the hour that a daylight-saving fall-back repeats, // because the local DateTime that X509Certificate2 returns keeps the ambiguous-DST bit, @@ -135,7 +187,7 @@ private static X509Certificate2 Issue( SubjectAlternativeNameBuilder alternativeName = new(); alternativeName.AddUri(new Uri($"urn:uuid:{id}")); - CertificateRequest request = new(subject.Build(), publicKey, HashAlgorithmName.SHA256); + CertificateRequest request = new(subject.Build(), publicKey, hash); OidCollection usages = [new Oid(ClientAuthentication)]; // RFC 5280 §4.2.1.2, method (1): the SHA-1 hash of the subjectPublicKey bits. @@ -155,6 +207,29 @@ private static X509Certificate2 Issue( NewSerialNumber(random)); } + // The hash and the signature AlgorithmIdentifier that CONTRACT.agent-identity.md §4 pairs with the key's curve, + // or null for a key of another type or curve. + private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignatureProfile(PublicKey key) + { + byte[]? curve = key.EncodedParameters?.RawData; + + if (key.Oid.Value != EcPublicKeyOid || curve is null) + { + return null; + } + + if (curve.AsSpan().SequenceEqual(P256CurveParameters)) + { + return (HashAlgorithmName.SHA256, EcdsaWithSha256Identifier); + } + + if (curve.AsSpan().SequenceEqual(P384CurveParameters)) + { + return (HashAlgorithmName.SHA384, EcdsaWithSha384Identifier); + } + + return null; + } /// /// Issues the pending certificate of a renewal, for the identity that signed the renew request. /// @@ -176,7 +251,11 @@ private static X509Certificate2 Issue( /// An argument is null. /// /// was authenticated for another operation than renew; - /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second. + /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second; + /// or the issuer is outside the chain profile of CONTRACT.agent-identity.md §4: + /// its certificate has neither a P-256 nor a P-384 key, no basicConstraints with cA set or no key usage with + /// keyCertSign, or its generator does not sign with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a + /// P-384 key. /// /// /// is shorter than one second. diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 2deb4ace7b..964282c1ff 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -33,6 +33,7 @@ The host provides these dependencies, with the guarantees the library relies on: - `INonceStore.TryRecordAsync` records a `keyid` and `nonce` pair, or returns false when the pair is already recorded, in one atomic operation, and keeps the pair until `retainUntil`. - `IRootSigner` holds the root that issues new certificates and a `SignatureGenerator` over its private key. Its `Certificate` must have a Subject Key Identifier extension, which becomes the Authority Key Identifier of each issued certificate; without one, `IdentityCertificateIssuer.Issue` throws `ArgumentException`. + The root must also fit the chain profile (CONTRACT.agent-identity.md [§4]): a P-256 or P-384 key, basicConstraints with `cA` set, key usage with `keyCertSign`, and a `SignatureGenerator` that signs with `ecdsa-with-SHA256` for a P-256 key or `ecdsa-with-SHA384` for a P-384 key; otherwise `Issue` throws `ArgumentException`. Once a root rotation starts, it holds the new root (CONTRACT.agent-identity.md [§8.3]). - `TimeProvider` is the authority's clock, usually `TimeProvider.System`, which decides signature freshness, certificate validity and handshake deadlines. Take the `now` of `EnrollmentRules.Decide` and the `serverTime` of every error from the same clock, since the agent corrects its own clock from `server_time`. @@ -189,6 +190,7 @@ Implement the `AgentChannel` service with that package; a feed that serves this [contract]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md [repo]: https://github.com/Devolutions/devolutions-gateway [§2.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#token-rules +[§4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#certificates [§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#trust-anchor [§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#enroll [§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#renew From b0434a2642cd2d043f0be93a8efa09d98b2d9f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 02:47:12 +0900 Subject: [PATCH 86/93] feat(dotnet-agent-identity): add the token_url_mismatch error code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enrollment whose CSR names no enrollment URL, or not the presented token's u, gets token_url_mismatch with HTTP 403, a permanent outcome for the agent (CONTRACT.agent-identity.md §5.4). It takes the next unused value, and maps to UNAUTHENTICATED like the other token codes. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Errors/AgentIdentityErrorCodeTests.cs | 5 +++++ .../Devolutions.AgentIdentity/Errors/AgentIdentityError.cs | 1 + .../Errors/AgentIdentityErrorCode.cs | 5 +++++ .../Errors/AgentIdentityErrorCodeExtensions.cs | 3 +++ 4 files changed, 14 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index cf83b09dde..c972a24a04 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -17,6 +17,7 @@ public class AgentIdentityErrorCodeTests { AgentIdentityErrorCode.ClockSkew, "clock_skew" }, { AgentIdentityErrorCode.InvalidRequest, "invalid_request" }, { AgentIdentityErrorCode.Unavailable, "unavailable" }, + { AgentIdentityErrorCode.TokenUrlMismatch, "token_url_mismatch" }, }; // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors @@ -48,6 +49,8 @@ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expec { AgentIdentityErrorCode.ClockSkew, 401 }, // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors { AgentIdentityErrorCode.InvalidRequest, 400 }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + { AgentIdentityErrorCode.TokenUrlMismatch, 403 }, }; // CONTRACT.agent-identity.md §5.4, column "HTTP", for the codes to which it gives a class of statuses, @@ -102,6 +105,8 @@ public void HttpStatusCodeIsInTheContractClass(AgentIdentityErrorCode code, int { AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument }, // oracle: spec gRPC status codes §Status codes and their use in gRPC { AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable }, + // oracle: mutant the gRPC mapping throws for token_url_mismatch as for an undefined code + { AgentIdentityErrorCode.TokenUrlMismatch, StatusCode.Unauthenticated }, }; // Most rows follow the closures of the channel; each row's marker names the source that decides it. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs index 63b66264d8..7b82297c42 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs @@ -160,6 +160,7 @@ private static string MessageFor(AgentIdentityErrorCode code) AgentIdentityErrorCode.ClockSkew => "The signature time is outside the accepted window.", AgentIdentityErrorCode.InvalidRequest => "The request body, CSR or metadata is not valid.", AgentIdentityErrorCode.Unavailable => "The server cannot serve the request now.", + AgentIdentityErrorCode.TokenUrlMismatch => "The CSR does not name the enrollment token's URL.", _ => throw AgentIdentityErrorCodeExtensions.UndefinedCode(code), }; } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs index 97d1db68fd..b5369a29c7 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs @@ -63,4 +63,9 @@ public enum AgentIdentityErrorCode /// The server can't serve the request now, because it is overloaded or a part it needs fails. /// Unavailable = 10, + + /// + /// The CSR of an enrollment names no enrollment URL, or not the presented token's `u`. + /// + TokenUrlMismatch = 11, } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs index 0cc7efd7e6..1cd1a55658 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs @@ -27,6 +27,7 @@ public static string ToWireValue(this AgentIdentityErrorCode code) AgentIdentityErrorCode.ClockSkew => "clock_skew", AgentIdentityErrorCode.InvalidRequest => "invalid_request", AgentIdentityErrorCode.Unavailable => "unavailable", + AgentIdentityErrorCode.TokenUrlMismatch => "token_url_mismatch", _ => throw UndefinedCode(code), }; } @@ -55,6 +56,7 @@ public static int ToHttpStatusCode(this AgentIdentityErrorCode code) AgentIdentityErrorCode.ClockSkew => 401, AgentIdentityErrorCode.InvalidRequest => 400, AgentIdentityErrorCode.Unavailable => 500, + AgentIdentityErrorCode.TokenUrlMismatch => 403, _ => throw UndefinedCode(code), }; } @@ -74,6 +76,7 @@ public static StatusCode ToGrpcStatusCode(this AgentIdentityErrorCode code) AgentIdentityErrorCode.TokenInvalid or AgentIdentityErrorCode.TokenExhausted or AgentIdentityErrorCode.TokenExpired + or AgentIdentityErrorCode.TokenUrlMismatch or AgentIdentityErrorCode.IdentityUnknown or AgentIdentityErrorCode.CertificateExpired or AgentIdentityErrorCode.SignatureInvalid From 6877876b0d91f9633930b0bd3b012dd50ae93489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 02:47:12 +0900 Subject: [PATCH 87/93] feat(dotnet-agent-identity): read the enrollment URL a CSR requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's CSR requests the enrollment URL extension in an extensionRequest, naming the base URL it sends the request to, and the server reads it there while ignoring every other attribute and requested extension (CONTRACT.agent-identity.md §4). CertificateSigningRequest.EnrollmentUrl holds the value of the one enrollment URL extension that the attributes request, as an IA5String read as text, or null when there is none, there are several, its value is not one IA5String, or the attributes cannot be read; a CSR stays valid either way, since enrollment, not validation, refuses it. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../CertificateSigningRequestTests.cs | 78 +++++++++++++++ .../Support/TestEnrollmentUrl.cs | 99 +++++++++++++++++++ .../Certificates/CertificateSigningRequest.cs | 81 +++++++++++++-- .../Certificates/EnrollmentUrlExtension.cs | 49 +++++++++ 4 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index bb84ebc97a..412bb204e2 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -499,6 +499,84 @@ public void AcceptsAnySubjectAndAttributes(string request) Assert.Equal(key.ExportSubjectPublicKeyInfo(), csr.SubjectPublicKeyInfo.ToArray()); } + // The server reads the enrollment URL extension from the CSR's extensionRequest and ignores every other attribute + // or requested extension; a CSR that names no single enrollment URL is still valid, and enrollment refuses it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + [Theory] + [MemberData(nameof(EnrollmentUrlRequestNames))] + public void ReadsTheEnrollmentUrlOfTheExtensionRequest(string request) + { + (Func build, string? expected) = EnrollmentUrlRequests[request]; + using ECDsa key = NewKey(); + + CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(build(key))); + + Assert.Equal(expected, csr.EnrollmentUrl); + } + + public static TheoryData EnrollmentUrlRequestNames => new(EnrollmentUrlRequests.Keys); + + // Requests by what their attributes hold, with the enrollment URL each names, or null when they name none. + private static Dictionary Build, string? Expected)> EnrollmentUrlRequests => new() + { + // The agent's CSR: one extensionRequest holding the enrollment URL extension alone. + ["the extension alone"] = (key => TestEnrollmentUrl.Request(key, "https://host/dvls"), "https://host/dvls"), + // The value is read as it is; whether it matches the token is the enrollment rules' to decide. + ["a value with a trailing slash"] = + (key => TestEnrollmentUrl.Request(key, "https://host/dvls/"), "https://host/dvls/"), + ["the extension among other requested extensions"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.Attributes( + TestEnrollmentUrl.Extension( + new X509BasicConstraintsExtension(true, false, 0, true).RawData, + "2.5.29.19"), + TestEnrollmentUrl.Extension("https://host/dvls"))), + "https://host/dvls"), + ["the extension written by .NET"] = ( + key => + { + CertificateRequest request = new("CN=agent", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add( + new X509Extension(TestEnrollmentUrl.Oid, TestEnrollmentUrl.IA5String("https://host/dvls"), false)); + return request.CreateSigningRequest(); + }, + "https://host/dvls"), + // The agent's CSR before it named an enrollment URL: an empty attribute set, A0 00. + ["no attributes"] = (TestCsr.For, null), + ["an extensionRequest without the extension"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.Attributes( + TestEnrollmentUrl.Extension(TestEnrollmentUrl.IA5String("x"), "2.5.29.17"))), + null), + // A leaf carries the extension exactly once, so a request naming two values names no single one. + ["the extension twice"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.Attributes( + TestEnrollmentUrl.Extension("https://host/dvls"), + TestEnrollmentUrl.Extension("https://host/dvls"))), + null), + // The extension holds an IA5String. + ["a UTF8String value"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.Attributes(TestEnrollmentUrl.Extension([0x0C, 0x04, .. "host"u8]))), + null), + // An IA5String holds the characters 0 to 127 (X.680 §41). + ["an IA5String with a byte above 127"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.Attributes(TestEnrollmentUrl.Extension([0x16, 0x01, 0x80]))), + null), + ["an IA5String followed by other bytes"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.Attributes( + TestEnrollmentUrl.Extension([.. TestEnrollmentUrl.IA5String("https://host/dvls"), 0x05, 0x00]))), + null), + }; // Each request breaks a rule of the CSR profile, and a request that breaks one gets invalid_request; // each row's markers name the rule it breaks. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs new file mode 100644 index 0000000000..85c4834869 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs @@ -0,0 +1,99 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Builds the enrollment URL extension of CONTRACT.agent-identity.md §4 and the CSR attributes that request it. +/// +internal static class TestEnrollmentUrl +{ + // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension. + public const string Oid = "1.3.6.1.4.1.32473.1.1"; + + // RFC 2985 §5.4.2: the extensionRequest attribute. + public const string ExtensionRequestOid = "1.2.840.113549.1.9.14"; + + /// + /// The DER IA5String of a URL. + /// + public static byte[] IA5String(string url) + { + AsnWriter writer = new(AsnEncodingRules.DER); + writer.WriteCharacterString(UniversalTagNumber.IA5String, url); + return writer.Encode(); + } + + /// + /// An enrollment URL extension (RFC 5280 §4.1, Extension), non-critical, whose value is the given bytes. + /// + public static byte[] Extension(byte[] value, string oid = Oid) + { + AsnWriter writer = new(AsnEncodingRules.DER); + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(oid); + writer.WriteOctetString(value); + } + + return writer.Encode(); + } + + /// + /// The enrollment URL extension of a URL, as the agent's CSR requests it. + /// + public static byte[] Extension(string url) + { + return Extension(IA5String(url)); + } + + /// + /// The [0] attributes of a CSR (RFC 2986 §4) holding one extensionRequest of the given extensions. + /// + public static byte[] Attributes(params byte[][] extensions) + { + AsnWriter writer = new(AsnEncodingRules.DER); + using (writer.PushSetOf(new Asn1Tag(TagClass.ContextSpecific, 0))) + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(ExtensionRequestOid); + using (writer.PushSetOf()) + using (writer.PushSequence()) + { + foreach (byte[] extension in extensions) + { + writer.WriteEncodedValue(extension); + } + } + } + + return writer.Encode(); + } + + /// + /// A CSR for the key, signed by it, with the given attributes. + /// + public static byte[] Request(ECDsa key, byte[] attributes) + { + return TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: attributes), key); + } + + /// + /// The CSR an agent sends: one extensionRequest holding the enrollment URL extension alone. + /// + public static byte[] Request(ECDsa key, string url) + { + return Request(key, Attributes(Extension(url))); + } + + /// + /// The validated CSR an agent sends for a new key, with the enrollment URL extension of a URL. + /// + public static CertificateSigningRequest Csr(string url) + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CsrValidationResult result = CertificateSigningRequest.Validate(Request(key, url)); + Assert.True(result.Succeeded, result.FailureDetail); + return result.Csr; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs index 6735b52d8f..0e91b3dd9f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs @@ -10,10 +10,11 @@ namespace Devolutions.AgentIdentity; /// /// Only and the request body parsers create instances, /// so an instance always holds an uncompressed P-256 key that signed its request. -/// The request's subject, attributes and extensions are ignored and not kept, -/// since an identity is never derived from claims in a CSR, -/// so the subject and the attributes need only be BER elements of their type; -/// the rest of the request is DER, as CONTRACT.agent-identity.md §1 requires of a CSR. +/// The request's subject, attributes and requested extensions are ignored and not kept, +/// since an identity is never derived from claims in a CSR, except the enrollment URL that its extensionRequest names, +/// which enrollment compares with the presented token (CONTRACT.agent-identity.md §4); +/// so the subject and the attributes need only be BER elements of their type, +/// and the rest of the request is DER, as CONTRACT.agent-identity.md §1 requires of a CSR. /// public sealed class CertificateSigningRequest { @@ -21,6 +22,9 @@ public sealed class CertificateSigningRequest private const string Secp256r1 = "1.2.840.10045.3.1.7"; private const string EcdsaWithSha256 = "1.2.840.10045.4.3.2"; + // RFC 2985 §5.4.2: the extensionRequest attribute. + private const string ExtensionRequest = "1.2.840.113549.1.9.14"; + private static readonly Asn1Tag AttributesTag = new(TagClass.ContextSpecific, 0, isConstructed: true); // SEC 2 §2.4.2: secp256r1 is y² = x³ - 3x + b over the prime field of order p. @@ -30,10 +34,11 @@ public sealed class CertificateSigningRequest private static readonly BigInteger P256B = Unsigned( "5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B"); - private CertificateSigningRequest(byte[] subjectPublicKeyInfo) + private CertificateSigningRequest(byte[] subjectPublicKeyInfo, string? enrollmentUrl) { SubjectPublicKeyInfo = subjectPublicKeyInfo; SubjectPublicKeyInfoSha256 = SHA256.HashData(subjectPublicKeyInfo); + EnrollmentUrl = enrollmentUrl; } /// @@ -50,6 +55,19 @@ private CertificateSigningRequest(byte[] subjectPublicKeyInfo) /// public ReadOnlyMemory SubjectPublicKeyInfoSha256 { get; } + /// + /// The value of the enrollment URL extension that the request's extensionRequest attribute holds, + /// or null when the request names no single enrollment URL. + /// + /// + /// The value is read as text, as the request holds it. + /// It is null when no extensionRequest holds the extension, when the extension appears more than once, + /// or when its value is not one IA5String; a request whose attributes cannot be read names none either. + /// compares it with the presented token's u, + /// and a renewal ignores it (CONTRACT.agent-identity.md §4). + /// + public string? EnrollmentUrl { get; } + /// /// Validates a PKCS #10 certificate signing request (RFC 2986). /// @@ -107,7 +125,7 @@ private static CsrValidationResult Parse(byte[] der) ReadOnlyMemory subjectPublicKeyInfo = fields[..length]; AsnReader attributes = new(fields[length..], AsnEncodingRules.BER); - _ = attributes.ReadSetOf(AttributesTag); + AsnReader attributeSet = attributes.ReadSetOf(skipSortOrderValidation: true, expectedTag: AttributesTag); attributes.ThrowIfNotEmpty(); AsnReader subjectPublicKeyInfoReader = new AsnReader(subjectPublicKeyInfo, AsnEncodingRules.DER).ReadSequence(); @@ -139,7 +157,56 @@ private static CsrValidationResult Parse(byte[] der) return CsrValidationResult.Failure("The CSR self-signature does not verify."); } - return CsrValidationResult.Success(new CertificateSigningRequest(subjectPublicKeyInfo.ToArray())); + return CsrValidationResult.Success( + new CertificateSigningRequest(subjectPublicKeyInfo.ToArray(), ReadEnrollmentUrl(attributeSet))); + } + + // RFC 2986 §4: Attribute ::= SEQUENCE { type OBJECT IDENTIFIER, values SET OF ANY }; + // RFC 2985 §5.4.2: an extensionRequest value is Extensions ::= SEQUENCE OF Extension; + // RFC 5280 §4.1: Extension ::= SEQUENCE { extnID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }. + // Returns the value of the one enrollment URL extension among the requested extensions, + // or null when there is none, there are several, or the attributes cannot be read, + // since the server otherwise ignores the attributes (CONTRACT.agent-identity.md §4). + private static string? ReadEnrollmentUrl(AsnReader attributeSet) + { + try + { + List values = []; + + while (attributeSet.HasData) + { + AsnReader attribute = attributeSet.ReadSequence(); + string type = attribute.ReadObjectIdentifier(); + AsnReader attributeValues = attribute.ReadSetOf(skipSortOrderValidation: true); + + while (type == ExtensionRequest && attributeValues.HasData) + { + AsnReader extensions = attributeValues.ReadSequence(); + + while (extensions.HasData) + { + AsnReader extension = extensions.ReadSequence(); + string id = extension.ReadObjectIdentifier(); + if (extension.PeekTag().HasSameClassAndValue(Asn1Tag.Boolean)) + { + _ = extension.ReadBoolean(); + } + + byte[] value = extension.ReadOctetString(); + if (id == EnrollmentUrlExtension.Oid) + { + values.Add(value); + } + } + } + } + + return values is [byte[] single] ? EnrollmentUrlExtension.ReadValue(single) : null; + } + catch (AsnContentException) + { + return null; + } } // RFC 5480 §2: SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs new file mode 100644 index 0000000000..123206402e --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs @@ -0,0 +1,49 @@ +using System.Formats.Asn1; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity; + +/// +/// The enrollment URL extension of CONTRACT.agent-identity.md §4: a non-critical extension whose value is an IA5String +/// holding the u of the token that enrolled an identity, without trailing slashes. +/// +/// +/// Every certificate issued for an identity carries it once with the same value, +/// and the agent's CSR requests it in an extensionRequest with the base URL it sends the request to. +/// +internal static class EnrollmentUrlExtension +{ + // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension. + public const string Oid = "1.3.6.1.4.1.32473.1.1"; + + /// + /// Creates the extension, non-critical, for an enrollment URL. + /// + /// The enrollment URL, which is ASCII and has no trailing slash. + public static X509Extension Create(string enrollmentUrl) + { + AsnWriter writer = new(AsnEncodingRules.DER); + writer.WriteCharacterString(UniversalTagNumber.IA5String, enrollmentUrl); + + return new X509Extension(Oid, writer.Encode(), critical: false); + } + + /// + /// Reads the enrollment URL from the value of the extension, or returns null when the value is not one IA5String. + /// + /// The contents of the extension's extnValue OCTET STRING. + public static string? ReadValue(ReadOnlyMemory value) + { + try + { + AsnReader reader = new(value, AsnEncodingRules.DER); + string enrollmentUrl = reader.ReadCharacterString(UniversalTagNumber.IA5String); + reader.ThrowIfNotEmpty(); + return enrollmentUrl; + } + catch (AsnContentException) + { + return null; + } + } +} From b8d76bf01cf17d89fbab3dc448488d11c6310362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 02:52:16 +0900 Subject: [PATCH 88/93] feat(dotnet-agent-identity)!: refuse an enrollment at another URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token works only at the base URL it names: at enrollment, a CSR that names no enrollment URL, or not the presented token's u without trailing slashes compared as text, gets token_url_mismatch, right after invalid_request and before the key lookup (CONTRACT.agent-identity.md §2.1 and §4), so that a server relaying a token from another address is refused. EnrollmentTokenState takes the token's u, which EnrollmentToken now exposes as ProductBaseUrl for the host to store, and EnrollmentRules.Decide takes the request's CSR. A decision to create an identity carries the token's u without trailing slashes as EnrollmentUrl, which every certificate of the identity names. BREAKING CHANGE: EnrollmentTokenState takes the token's product base URL after revoked, and EnrollmentRules.Decide takes the request's CSR after the token state. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../IdentityCertificateIssuerTests.cs | 17 ++- .../Enrollment/EnrollmentRulesTests.cs | 131 ++++++++++++++++-- .../Enrollment/EnrollmentTokenTests.cs | 14 ++ .../Support/TestEnrollmentUrl.cs | 13 +- .../Enrollment/EnrollmentDecision.cs | 19 ++- .../Enrollment/EnrollmentRules.cs | 26 +++- .../Enrollment/EnrollmentToken.cs | 18 ++- .../Enrollment/EnrollmentTokenState.cs | 23 ++- dotnet/AgentIdentity/README.md | 6 +- 9 files changed, 235 insertions(+), 32 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 623ae9fc16..c8f54b6089 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -42,15 +42,26 @@ public sealed class IdentityCertificateIssuerTests : IDisposable public static TheoryData SerialNumberDrawNames => new(SerialNumberDraws.Keys); // A token that can create an identity. - private static EnrollmentTokenState UsableToken => new(false, Now.AddDays(1), maxUses: 10, identitiesCreated: 0); + // The `u` of the token that enrolls the identity, and the CSR of the enrollment, which names it. + private const string TokenUrl = "https://host/dvls"; + + private static readonly CertificateSigningRequest EnrollingCsr = TestEnrollmentUrl.Csr(TokenUrl); + + private static EnrollmentTokenState UsableToken => + new(false, TokenUrl, Now.AddDays(1), maxUses: 10, identitiesCreated: 0); // A decision of each outcome but the creation of an identity: an expired token's refusal and a replay. private static Dictionary DecisionsButCreateIdentity => new() { [EnrollmentOutcome.Reject] = - EnrollmentRules.Decide(new(false, Now, maxUses: 10, identitiesCreated: 0), null, Now), + EnrollmentRules.Decide( + new(false, TokenUrl, Now, maxUses: 10, identitiesCreated: 0), + EnrollingCsr, + null, + Now), [EnrollmentOutcome.ReturnExistingIdentity] = EnrollmentRules.Decide( UsableToken, + EnrollingCsr, IssuedKeyMatch.ForIdentity(Guid.Parse(IdentityIdText), true, false, CertificateStatus.Current), Now), }; @@ -62,7 +73,7 @@ public sealed class IdentityCertificateIssuerTests : IDisposable private readonly CertificateSigningRequest _csr; // The decision that creates the identity whose certificate each test issues, unless the test passes another. - private readonly EnrollmentDecision _creating = EnrollmentRules.Decide(UsableToken, null, Now); + private readonly EnrollmentDecision _creating = EnrollmentRules.Decide(UsableToken, EnrollingCsr, null, Now); // The ID that the decision minted, as CONTRACT.agent-identity.md §1 writes IDs: // Guid.ToString("D") writes a UUID lowercase and hyphenated. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index d4a9f8bda8..ab1735ca45 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -6,6 +6,11 @@ public class EnrollmentRulesTests private static readonly Guid IdentityId = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"); + // The `u` of the presented token, and the CSR an agent enrolling with it sends, which names that URL. + private const string TokenUrl = "https://host/dvls"; + + private static readonly CertificateSigningRequest Csr = TestEnrollmentUrl.Csr(TokenUrl); + public static TheoryData KeysButTheReplayNames => new(KeysButTheReplay.Keys); // The issued keys that each key rule of CONTRACT.agent-identity.md §2.1 matches, except the replay, by name. @@ -24,9 +29,10 @@ public class EnrollmentRulesTests // A decision of each outcome but the creation of an identity: an expired token's refusal and a replay. private static Dictionary DecisionsButCreateIdentity => new() { - [EnrollmentOutcome.Reject] = EnrollmentRules.Decide(Token(expired: true), null, Now), + [EnrollmentOutcome.Reject] = EnrollmentRules.Decide(Token(expired: true), Csr, null, Now), [EnrollmentOutcome.ReturnExistingIdentity] = EnrollmentRules.Decide( Token(), + Csr, IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current), Now), }; @@ -43,9 +49,9 @@ public class EnrollmentRulesTests [InlineData(TimeSpan.TicksPerDay, 9)] public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int identitiesCreated) { - EnrollmentTokenState token = new(false, Now.AddTicks(ticksToExpiry), maxUses: 10, identitiesCreated); + EnrollmentTokenState token = new(false, TokenUrl, Now.AddTicks(ticksToExpiry), maxUses: 10, identitiesCreated); - EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); + EnrollmentDecision decision = EnrollmentRules.Decide(token, Csr, null, Now); Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); Assert.Null(decision.ExistingIdentityId); @@ -58,7 +64,7 @@ public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int [Fact] public void CreateIdentityCarriesAnIdentityId() { - EnrollmentDecision decision = EnrollmentRules.Decide(Token(), null, Now); + EnrollmentDecision decision = EnrollmentRules.Decide(Token(), Csr, null, Now); Guid identityId = Assert.NotNull(decision.NewIdentityId); Assert.NotEqual(Guid.Empty, identityId); @@ -70,8 +76,8 @@ public void CreateIdentityCarriesAnIdentityId() [Fact] public void EachCreateIdentityCarriesADifferentIdentityId() { - EnrollmentDecision first = EnrollmentRules.Decide(Token(), null, Now); - EnrollmentDecision second = EnrollmentRules.Decide(Token(), null, Now); + EnrollmentDecision first = EnrollmentRules.Decide(Token(), Csr, null, Now); + EnrollmentDecision second = EnrollmentRules.Decide(Token(), Csr, null, Now); Assert.NotEqual(first.NewIdentityId, second.NewIdentityId); } @@ -118,9 +124,9 @@ public void RejectsANewKeyWithTheEarliestTokenOutcome( int identitiesCreated, AgentIdentityErrorCode expected) { - EnrollmentTokenState token = new(revoked, Now.AddTicks(ticksToExpiry), maxUses, identitiesCreated); + EnrollmentTokenState token = new(revoked, TokenUrl, Now.AddTicks(ticksToExpiry), maxUses, identitiesCreated); - EnrollmentDecision decision = EnrollmentRules.Decide(token, null, Now); + EnrollmentDecision decision = EnrollmentRules.Decide(token, Csr, null, Now); AssertRejected(expected, decision); } @@ -140,7 +146,7 @@ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool revoked, { IssuedKeyMatch keyMatch = IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current); - EnrollmentDecision decision = EnrollmentRules.Decide(Token(revoked, expired, exhausted), keyMatch, Now); + EnrollmentDecision decision = EnrollmentRules.Decide(Token(revoked, expired, exhausted), Csr, keyMatch, Now); Assert.Equal(EnrollmentOutcome.ReturnExistingIdentity, decision.Outcome); Assert.Equal(IdentityId, decision.ExistingIdentityId); @@ -183,7 +189,7 @@ public void RejectsAnIssuedKeyByTheFirstMatchingRule( IssuedKeyMatch.ForIdentity(IdentityId, enrolledWithPresentedToken, identityRevoked, status); EnrollmentDecision decision = - EnrollmentRules.Decide(Token(expired: expired, exhausted: exhausted), keyMatch, Now); + EnrollmentRules.Decide(Token(expired: expired, exhausted: exhausted), Csr, keyMatch, Now); AssertRejected(expected, decision); } @@ -200,6 +206,7 @@ public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, boo { EnrollmentDecision decision = EnrollmentRules.Decide( Token(expired: expired, exhausted: exhausted), + Csr, IssuedKeyMatch.ForDeletedIdentity(), Now); @@ -213,11 +220,96 @@ public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, boo [MemberData(nameof(KeysButTheReplayNames))] public void RejectsARevokedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(string key) { - EnrollmentDecision decision = EnrollmentRules.Decide(Token(revoked: true), KeysButTheReplay[key], Now); + EnrollmentDecision decision = EnrollmentRules.Decide(Token(revoked: true), Csr, KeysButTheReplay[key], Now); AssertRejected(AgentIdentityErrorCode.TokenInvalid, decision); } + // At enrollment, a CSR that lacks the enrollment URL extension, or whose value is not the presented token's `u` + // without trailing slashes, gets token_url_mismatch, compared as text. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + [Theory] + [MemberData(nameof(CsrsNotNamingTheTokenUrlNames))] + public void RejectsACsrNotNamingTheTokenUrlWithTokenUrlMismatch(string csr) + { + EnrollmentDecision decision = EnrollmentRules.Decide(Token(), CsrsNotNamingTheTokenUrl[csr](), null, Now); + + AssertRejected(AgentIdentityErrorCode.TokenUrlMismatch, decision); + } + + public static TheoryData CsrsNotNamingTheTokenUrlNames => new(CsrsNotNamingTheTokenUrl.Keys); + + // CSRs whose enrollment URL is not https://host/dvls, by what they name. + private static Dictionary> CsrsNotNamingTheTokenUrl => new() + { + ["no enrollment URL"] = TestEnrollmentUrl.CsrWithoutEnrollmentUrl, + ["another path"] = () => TestEnrollmentUrl.Csr("https://host/psu"), + ["another host"] = () => TestEnrollmentUrl.Csr("https://other/dvls"), + // Compared as text, a trailing slash, an uppercase host and a port make another value. + ["a trailing slash"] = () => TestEnrollmentUrl.Csr("https://host/dvls/"), + ["an uppercase host"] = () => TestEnrollmentUrl.Csr("https://HOST/dvls"), + ["the default port"] = () => TestEnrollmentUrl.Csr("https://host:443/dvls"), + }; + + // (2) `token_url_mismatch` follows `invalid_request` and comes before (3) the key lookup, + // (4) `token_invalid` for a revoked token and `token_expired`, and (5) `token_exhausted`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + [Theory] + [MemberData(nameof(LaterOutcomeNames))] + public void TokenUrlMismatchWinsOverTheLaterOutcomes(string laterOutcome) + { + (EnrollmentTokenState token, IssuedKeyMatch? keyMatch) = LaterOutcomes[laterOutcome]; + + EnrollmentDecision decision = + EnrollmentRules.Decide(token, TestEnrollmentUrl.Csr("https://host/psu"), keyMatch, Now); + + AssertRejected(AgentIdentityErrorCode.TokenUrlMismatch, decision); + } + + public static TheoryData LaterOutcomeNames => new(LaterOutcomes.Keys); + + // Token states and key matches that a CSR naming the token's URL would get, by that outcome. + private static Dictionary LaterOutcomes => new() + { + ["a new identity"] = (Token(), null), + ["the replay of an identity"] = ( + Token(revoked: true, expired: true, exhausted: true), + IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current)), + ["the key of a revoked identity"] = + (Token(), IssuedKeyMatch.ForIdentity(IdentityId, true, true, CertificateStatus.Current)), + ["a revoked token"] = (Token(revoked: true), null), + ["an expired token"] = (Token(expired: true), null), + ["an exhausted token"] = (Token(exhausted: true), null), + }; + + // The CSR names the token's `u` without trailing slashes, whatever trailing slashes the stored `u` has, + // and the decision to create an identity carries that URL, which every certificate of the identity names. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + [Theory] + [InlineData("https://host/dvls")] + [InlineData("https://host/dvls/")] + [InlineData("https://host/dvls//")] + public void CreateIdentityCarriesTheTokenUrlWithoutTrailingSlashes(string storedUrl) + { + EnrollmentTokenState token = new(false, storedUrl, Now.AddDays(1), maxUses: 10, identitiesCreated: 3); + + EnrollmentDecision decision = EnrollmentRules.Decide(token, Csr, null, Now); + + Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome); + Assert.Equal(TokenUrl, decision.EnrollmentUrl); + } + + // Only the decision to create an identity issues a certificate. + // oracle: intent dotnet/AgentIdentity/INTENT.md §Authenticated identity + [Theory] + [InlineData(EnrollmentOutcome.Reject)] + [InlineData(EnrollmentOutcome.ReturnExistingIdentity)] + public void OtherDecisionsCarryNoEnrollmentUrl(EnrollmentOutcome outcome) + { + Assert.Null(DecisionsButCreateIdentity[outcome].EnrollmentUrl); + } + // A bad argument throws the most derived ArgumentException, whose ParamName names it, // here ArgumentOutOfRangeException for a count below zero. // oracle: spec Framework Design Guidelines §Validating Arguments @@ -228,7 +320,7 @@ public void RejectsARevokedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(strin public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, string parameter) { ArgumentOutOfRangeException exception = Assert.Throws( - () => new EnrollmentTokenState(false, Now, maxUses, identitiesCreated)); + () => new EnrollmentTokenState(false, TokenUrl, Now, maxUses, identitiesCreated)); Assert.Equal(parameter, exception.ParamName); } @@ -256,12 +348,24 @@ public void KeyMatchRejectsAnUndefinedCertificateStatus(int status) public void DecideRejectsANullToken() { ArgumentNullException exception = Assert.Throws( - () => EnrollmentRules.Decide(null!, null, Now)); + () => EnrollmentRules.Decide(null!, Csr, null, Now)); // oracle: spec Framework Design Guidelines §Using Standard Exception Types Assert.Equal("token", exception.ParamName); } + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments + [Fact] + public void DecideRejectsANullCsr() + { + ArgumentNullException exception = Assert.Throws( + () => EnrollmentRules.Decide(Token(), null!, null, Now)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("csr", exception.ParamName); + } + private static void AssertRejected(AgentIdentityErrorCode expected, EnrollmentDecision decision) { Assert.Equal(EnrollmentOutcome.Reject, decision.Outcome); @@ -277,6 +381,7 @@ private static EnrollmentTokenState Token(bool revoked = false, bool expired = f { return new EnrollmentTokenState( revoked, + TokenUrl, expiresAt: expired ? Now : Now.AddDays(1), maxUses: 10, identitiesCreated: exhausted ? 10 : 3); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index 520a02250d..228e5b6787 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -103,6 +103,20 @@ public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl) Assert.Equal(("u", productBaseUrl), Assert.Single(BagMembers(token))); } + // The authority keeps the token's `u` to compare it with the enrollment URL of each CSR the token presents + // (CONTRACT.agent-identity.md §2.1), so ProductBaseUrl is the `u` that the bag holds, after any normalization. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + [Theory] + [InlineData("https://host/dvls")] + [InlineData("https://host/dvls/")] + [InlineData(" https://host/dvls\t")] + public void ProductBaseUrlIsTheUOfTheBag(string productBaseUrl) + { + EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes)); + + Assert.Equal(Assert.Single(BagMembers(token), member => member.Name == "u").Value, token.ProductBaseUrl); + } + // Create may reject a URL that is not in the form of `u`, // but a token it creates holds in `u` the form that the source of the row decides. [Theory] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs index 85c4834869..0117911c63 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs @@ -96,4 +96,15 @@ public static CertificateSigningRequest Csr(string url) Assert.True(result.Succeeded, result.FailureDetail); return result.Csr; } -} + + /// + /// A validated CSR for a new key that names no enrollment URL, with an empty attribute set. + /// + public static CertificateSigningRequest CsrWithoutEnrollmentUrl() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CsrValidationResult result = CertificateSigningRequest.Validate(TestCsr.For(key)); + Assert.True(result.Succeeded, result.FailureDetail); + return result.Csr; + } +} \ No newline at end of file diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs index 7128985744..1436952549 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs @@ -8,12 +8,14 @@ public sealed class EnrollmentDecision private EnrollmentDecision( EnrollmentOutcome outcome, Guid? newIdentityId, + string? enrollmentUrl, Guid? existingIdentityId, AgentIdentityError? error, string? failureDetail) { Outcome = outcome; NewIdentityId = newIdentityId; + EnrollmentUrl = enrollmentUrl; ExistingIdentityId = existingIdentityId; Error = error; FailureDetail = failureDetail; @@ -35,6 +37,16 @@ private EnrollmentDecision( /// public Guid? NewIdentityId { get; } + /// + /// The enrollment URL that every certificate of the new identity names, the token's u without trailing + /// slashes, when is ; otherwise null. + /// + /// + /// The overload that takes this decision writes it into the certificate, + /// and the authority keeps it with the identity so that each renewal names the same URL. + /// + public string? EnrollmentUrl { get; } + /// /// The identity whose enrollment the request replays, /// when is ; otherwise null. @@ -53,14 +65,14 @@ private EnrollmentDecision( /// public string? FailureDetail { get; } - internal static EnrollmentDecision CreateIdentity(Guid newIdentityId) + internal static EnrollmentDecision CreateIdentity(Guid newIdentityId, string enrollmentUrl) { - return new EnrollmentDecision(EnrollmentOutcome.CreateIdentity, newIdentityId, null, null, null); + return new EnrollmentDecision(EnrollmentOutcome.CreateIdentity, newIdentityId, enrollmentUrl, null, null, null); } internal static EnrollmentDecision ReturnExistingIdentity(Guid identityId) { - return new EnrollmentDecision(EnrollmentOutcome.ReturnExistingIdentity, null, identityId, null, null); + return new EnrollmentDecision(EnrollmentOutcome.ReturnExistingIdentity, null, null, identityId, null, null); } internal static EnrollmentDecision Reject(AgentIdentityErrorCode code, string failureDetail) @@ -69,6 +81,7 @@ internal static EnrollmentDecision Reject(AgentIdentityErrorCode code, string fa EnrollmentOutcome.Reject, null, null, + null, AgentIdentityError.From(code), failureDetail); } diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs index 9c1fc7832e..c4db3bc046 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs @@ -14,12 +14,15 @@ public static class EnrollmentRules /// The host calls this once and its token lookup succeed /// and the request body, CSR and metadata are valid. /// When several outcomes apply, the earliest of these wins: + /// token_url_mismatch when the CSR names no enrollment URL, or not the token's u + /// without trailing slashes, compared as text; /// the result of the CSR key lookup, /// except that a revoked token gets token_invalid for anything but a replay; /// token_invalid for a revoked token, /// then token_expired once reaches its expiry; /// token_exhausted once it has created identities; - /// a new identity, whose ID, , is minted at random for this decision. + /// a new identity, whose ID, , is minted at random for this decision, + /// and whose certificates name . /// /// /// The CSR key lookup takes the first matching rule: @@ -38,12 +41,27 @@ public static class EnrollmentRules /// /// /// The state of the token that the request presents. + /// The request's validated CSR. /// The issued certificate whose public key is the CSR key, or null when none has it. /// The authority's current time, read once for the request. - /// is null. - public static EnrollmentDecision Decide(EnrollmentTokenState token, IssuedKeyMatch? keyMatch, DateTimeOffset now) + /// or is null. + public static EnrollmentDecision Decide( + EnrollmentTokenState token, + CertificateSigningRequest csr, + IssuedKeyMatch? keyMatch, + DateTimeOffset now) { ArgumentNullException.ThrowIfNull(token); + ArgumentNullException.ThrowIfNull(csr); + + // CONTRACT.agent-identity.md §2.1 (2) and §4: a token works only at the base URL it names. + string enrollmentUrl = token.ProductBaseUrl.TrimEnd('/'); + if (!string.Equals(csr.EnrollmentUrl, enrollmentUrl, StringComparison.Ordinal)) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.TokenUrlMismatch, + "The CSR names no enrollment URL, or not the token's."); + } if (keyMatch is not null) { @@ -79,7 +97,7 @@ public static EnrollmentDecision Decide(EnrollmentTokenState token, IssuedKeyMat "The token has created its maximum number of identities."); } - return EnrollmentDecision.CreateIdentity(Guid.NewGuid()); + return EnrollmentDecision.CreateIdentity(Guid.NewGuid(), enrollmentUrl); } // The key rules after the replay, in their order; a deleted identity matches only its own rule. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs index f659b9b921..b11e4dfcbc 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs @@ -32,10 +32,11 @@ public sealed class EnrollmentToken private const int SecretByteCount = 32; - private EnrollmentToken(string value, byte[] secretHash) + private EnrollmentToken(string value, byte[] secretHash, string productBaseUrl) { Value = value; SecretHash = secretHash; + ProductBaseUrl = productBaseUrl; } /// @@ -55,6 +56,15 @@ private EnrollmentToken(string value, byte[] secretHash) /// public ReadOnlyMemory SecretHash { get; } + /// + /// The token's u: the product base URL that its bag holds, in normalized form without trailing slashes. + /// + /// + /// The authority stores it with the token and passes it to , + /// since an enrollment's CSR must name it (CONTRACT.agent-identity.md §2.1). + /// + public string ProductBaseUrl { get; } + /// /// Creates a token for the authority that serves the agent-facing API under a product base URL. /// @@ -89,10 +99,12 @@ public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerato ArrayBufferWriter json = new(); + string u = BaseUrl.Normalize(productBaseUrl, "The product base URL", nameof(productBaseUrl)); + using (Utf8JsonWriter writer = new(json)) { writer.WriteStartObject(); - writer.WriteString("u", BaseUrl.Normalize(productBaseUrl, "The product base URL", nameof(productBaseUrl))); + writer.WriteString("u", u); writer.WriteEndObject(); } @@ -109,7 +121,7 @@ public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerato randomNumberGenerator.GetBytes(secretBytes); string secret = StrictBase64.EncodeBase64Url(secretBytes); - return new EnrollmentToken(Prefix + "." + bag + "." + secret, HashSecret(secretBytes)); + return new EnrollmentToken(Prefix + "." + bag + "." + secret, HashSecret(secretBytes), u); } /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs index 83c0dbb167..7162587fb5 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs @@ -12,18 +12,30 @@ public sealed class EnrollmentTokenState /// Creates the state of a token. /// /// Whether the token was revoked. + /// + /// The token's u, the product base URL that gave when the token + /// was created. + /// /// The instant from which the token is expired. /// The number of identities that the token may create. /// The number of identities that the token has created. + /// is null. /// /// or is negative. /// - public EnrollmentTokenState(bool revoked, DateTimeOffset expiresAt, int maxUses, int identitiesCreated) + public EnrollmentTokenState( + bool revoked, + string productBaseUrl, + DateTimeOffset expiresAt, + int maxUses, + int identitiesCreated) { + ArgumentNullException.ThrowIfNull(productBaseUrl); ArgumentOutOfRangeException.ThrowIfNegative(maxUses); ArgumentOutOfRangeException.ThrowIfNegative(identitiesCreated); Revoked = revoked; + ProductBaseUrl = productBaseUrl; ExpiresAt = expiresAt; MaxUses = maxUses; IdentitiesCreated = identitiesCreated; @@ -33,10 +45,17 @@ public EnrollmentTokenState(bool revoked, DateTimeOffset expiresAt, int maxUses, /// Whether the token was revoked. /// /// - /// A revoked token keeps its secret hash, so that a device can still recover the enrollment response of an identity that the token created. + /// A revoked token keeps its secret hash, + /// so that a device can still recover the enrollment response of an identity that the token created. /// public bool Revoked { get; } + /// + /// The token's u, which the CSR of an enrollment must name, without trailing slashes, + /// and which every certificate of the identities the token creates names (CONTRACT.agent-identity.md §4). + /// + public string ProductBaseUrl { get; } + /// /// The instant from which the token is expired. /// diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 964282c1ff..24b747a160 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -6,7 +6,7 @@ The contract is `docs/agent-identity/CONTRACT.agent-identity.md` in the [devolut ## What the library covers - Enrollment tokens: `EnrollmentToken.Create` creates a token, `FriendlyNameFormat` parses and evaluates its friendly-name format, and `EnrollmentAuthorization.ReadBearerToken` reads it from an `enroll` request. - The host stores the token's `SecretHash`, never its `Value`, which it shows once and never logs. + The host stores the token's `SecretHash` and its `ProductBaseUrl`, never its `Value`, which it shows once and never logs. - Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.agent-identity.md [§6]). - Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`), and `ConfirmRequestBody` checks that a `confirm` body is empty. - Decisions: `EnrollmentRules.Decide` decides an enrollment and `RenewalRules.Decide` a renewal, given the issued certificate that holds the CSR key. @@ -74,10 +74,10 @@ Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normall 3. Parse the body with `EnrollRequestBody.Parse`. 4. Look up `Csr.SubjectPublicKeyInfoSha256` among the keys of every certificate the authority has issued, deleted identities included. Describe the match with `IssuedKeyMatch.ForIdentity` or `IssuedKeyMatch.ForDeletedIdentity`, or use null when there is none. -5. Pass the token's `EnrollmentTokenState`, the match and the current time to `EnrollmentRules.Decide`, and act on its `Outcome`: +5. Pass the token's `EnrollmentTokenState`, with the `ProductBaseUrl` stored for it, the body's `Csr`, the match and the current time to `EnrollmentRules.Decide`, and act on its `Outcome`; a CSR that names no enrollment URL, or not the token's, gets `token_url_mismatch` before any key rule applies (CONTRACT.agent-identity.md [§2.1]): - `Reject`: answer with `Error`. - `ReturnExistingIdentity`: take identity `ExistingIdentityId`, and change nothing, not even its `last_seen_at`, since a replay proves no current possession of the device key. - - `CreateIdentity`: take `NewIdentityId`, which the library minted, as the identity's ID, issue its certificate with the `IdentityCertificateIssuer.Issue` overload that takes the decision, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata, the key's `SubjectPublicKeyInfoSha256`, and the current time as its `last_seen_at`. + - `CreateIdentity`: take `NewIdentityId`, which the library minted, as the identity's ID, issue its certificate with the `IdentityCertificateIssuer.Issue` overload that takes the decision, which names the decision's `EnrollmentUrl` in it, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata, its `EnrollmentUrl`, the key's `SubjectPublicKeyInfoSha256`, and the current time as its `last_seen_at`. Create the identity and count the token use atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent enrollments. 6. Answer with `ResponseBodies.Enroll`, given the authority ID and the ID taken in step 5, with that identity's current certificate chain, leaf first, and config (CONTRACT.agent-identity.md [§5.2]). From 6f35c214c9c8a2f7a9906ac19500e91fc20587a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 02:57:59 +0900 Subject: [PATCH 89/93] feat(dotnet-agent-identity)!: name the enrollment URL in every certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every certificate issued for an identity carries the enrollment URL extension once, non-critical, holding as an IA5String the u of the token that enrolled it, without trailing slashes, so that an agent can refuse a certificate from an authority at another address (CONTRACT.agent-identity.md §4). The enrollment overload of IdentityCertificateIssuer.Issue takes the URL from the decision, and the renewal overload takes the identity's enrollment URL, which the host keeps from that decision; it throws ArgumentException for one that is empty, ends in a slash or is not ASCII. The extension's OID is one named constant. BREAKING CHANGE: the renewal overload of IdentityCertificateIssuer.Issue takes the identity's enrollment URL after the renewing identity. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../IdentityCertificateIssuerTests.cs | 80 ++++++++++++++++++- .../Certificates/IdentityCertificateIssuer.cs | 37 +++++++-- dotnet/AgentIdentity/README.md | 2 +- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index c8f54b6089..1520fa6f2d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -194,8 +194,8 @@ public void SubjectKeyIdentifierIsNotCritical() Assert.False(extension.Critical); } - // The leaf carries SAN, EKU and KeyUsage, and no other extension except an authority key identifier and a subject - // key identifier, so no basicConstraints. + // The leaf carries SAN, EKU and KeyUsage, and no other extension except an authority key identifier, a subject + // key identifier and the enrollment URL extension, so no basicConstraints. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates [Fact] public void HasExactlyTheProfileExtensions() @@ -205,6 +205,7 @@ public void HasExactlyTheProfileExtensions() IEnumerable extensions = leaf.Extensions.Select(extension => extension.Oid?.Value); string[] expected = [ + TestEnrollmentUrl.Oid, SubjectKeyIdentifierOid, KeyUsageOid, SubjectAlternativeNameOid, @@ -217,6 +218,60 @@ public void HasExactlyTheProfileExtensions() Assert.Equal(3, leaf.Version); } + // The enrollment URL extension is non-critical and holds as an IA5String the `u` of the token that enrolled the + // identity, without trailing slashes, and a leaf carries it exactly once. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + [Fact] + public void NamesTheEnrollmentUrlOfTheDecision() + { + using X509Certificate2 leaf = Issue(); + + Assert.Equal((false, TokenUrl), Assert.Single(EnrollmentUrls(leaf))); + } + + // Every certificate issued for an identity carries the same enrollment URL, which the authority keeps with it, + // so a renewal names the URL it is given. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + [Fact] + public async Task RenewalNamesTheEnrollmentUrlOfTheIdentity() + { + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + + using X509Certificate2 leaf = IssueForRenewal(renewing, "https://host/psu"); + + Assert.Equal((false, "https://host/psu"), Assert.Single(EnrollmentUrls(leaf))); + } + + // The value is an IA5String, which holds only ASCII, of a URL without trailing slashes; an empty one names nothing. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: spec Framework Design Guidelines §Validating Arguments + [Theory] + [InlineData("")] + [InlineData("https://host/dvls/")] + [InlineData("https://bücher.example/dvls")] + public async Task RenewalThrowsForAnEnrollmentUrlOutsideTheExtensionForm(string enrollmentUrl) + { + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + + ArgumentException exception = Assert.Throws(() => IssueForRenewal(renewing, enrollmentUrl)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("enrollmentUrl", exception.ParamName); + } + + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments + [Fact] + public async Task RenewalThrowsForANullEnrollmentUrl() + { + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + + ArgumentNullException exception = Assert.Throws(() => IssueForRenewal(renewing, null!)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("enrollmentUrl", exception.ParamName); + } + // A positive INTEGER, so never zero, of at most 20 octets, whatever the injected generator draws. // oracle: spec RFC 5280 §4.1.2.2 [Theory] @@ -580,10 +635,11 @@ public void RenewalThrowsForANullIdentity(string renewal) [nameof(RenewalRules)] = renewing => RenewalRules.Decide(renewing, null), }; - private X509Certificate2 IssueForRenewal(AuthenticatedIdentity renewing) + private X509Certificate2 IssueForRenewal(AuthenticatedIdentity renewing, string enrollmentUrl = TokenUrl) { return IdentityCertificateIssuer.Issue( renewing, + enrollmentUrl, _csr, new TestRootSigner(_root, _rootKey), Lifetime, @@ -625,6 +681,24 @@ private X509Certificate2 CreateRoot( return request.CreateSelfSigned(notBefore, notAfter); } + // RFC 5280 §4.1: Extension ::= SEQUENCE { extnID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }; + // the enrollment URL extensions of a certificate, each with its criticality and its IA5String value. + private static (bool Critical, string Value)[] EnrollmentUrls(X509Certificate2 certificate) + { + return + [ + .. certificate.Extensions + .Where(extension => extension.Oid?.Value == TestEnrollmentUrl.Oid) + .Select(extension => + { + AsnReader value = new(extension.RawData, AsnEncodingRules.DER); + string url = value.ReadCharacterString(UniversalTagNumber.IA5String); + value.ThrowIfNotEmpty(); + return (extension.Critical, url); + }), + ]; + } + // RFC 5280 §4.1: TBSCertificate ::= SEQUENCE { [0] EXPLICIT version, serialNumber INTEGER, ... }. private static byte[] SerialNumber(X509Certificate2 certificate) { diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs index 6417e5abf4..89b880214b 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; namespace Devolutions.AgentIdentity; @@ -53,7 +54,8 @@ public static class IdentityCertificateIssuer /// It is valid from the current time, truncated to the second, for , /// or until the issuer's notAfter if that comes first; a fraction of a second in the lifetime is dropped. /// Its serial number is 16 octets from , positive and never zero, - /// and signs it with SHA-256. + /// and signs it with the ECDSA algorithm of its key's curve. + /// It names in the enrollment URL extension. /// /// /// Taking the decision rather than an ID keeps the authority from certifying a key for an identity @@ -98,13 +100,21 @@ public static X509Certificate2 Issue( throw new ArgumentException("The enrollment decision does not create an identity.", nameof(creating)); } - // A decision to create an identity always carries the ID minted for it. - return Issue(creating.NewIdentityId!.Value, csr, issuer, lifetime, timeProvider, random); + // A decision to create an identity always carries the ID minted for it and the token's URL. + return Issue( + creating.NewIdentityId!.Value, + creating.EnrollmentUrl!, + csr, + issuer, + lifetime, + timeProvider, + random); } // Private so that a certificate is issued only for an identity ID taken from a proof of that identity. private static X509Certificate2 Issue( Guid identityId, + string enrollmentUrl, CertificateSigningRequest csr, IRootSigner issuer, TimeSpan lifetime, @@ -198,6 +208,7 @@ private static X509Certificate2 Issue( extensions.Add(alternativeName.Build(critical: false)); extensions.Add(new X509SubjectKeyIdentifierExtension(publicKey, keyIdMethod, critical: false)); extensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(issuerKeyId)); + extensions.Add(EnrollmentUrlExtension.Create(enrollmentUrl)); return request.Create( issuerCertificate.SubjectName, @@ -235,7 +246,7 @@ private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignaturePro /// /// /// The certificate is the one that the other overload issues, with - /// as the identity ID. + /// as the identity ID and in the enrollment URL extension. /// Taking the identity that the signature check produced, rather than an ID, /// keeps a renewal from certifying a key for an identity that a request only named. /// @@ -243,6 +254,10 @@ private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignaturePro /// The identity that a renew request proved, as /// returns it for . /// + /// + /// The identity's enrollment URL, the of the decision that created + /// it, which every certificate of the identity names. + /// /// The validated CSR whose key the certificate is for. /// The root that signs the certificate. /// The certificate lifetime, usually . @@ -251,6 +266,7 @@ private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignaturePro /// An argument is null. /// /// was authenticated for another operation than renew; + /// or is empty, ends in a slash or is not ASCII; /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second; /// or the issuer is outside the chain profile of CONTRACT.agent-identity.md §4: /// its certificate has neither a P-256 nor a P-384 key, no basicConstraints with cA set or no key usage with @@ -262,6 +278,7 @@ private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignaturePro /// public static X509Certificate2 Issue( AuthenticatedIdentity renewing, + string enrollmentUrl, CertificateSigningRequest csr, IRootSigner issuer, TimeSpan lifetime, @@ -275,7 +292,17 @@ public static X509Certificate2 Issue( throw new ArgumentException("The identity was not authenticated by a renew request.", nameof(renewing)); } - return Issue(renewing.IdentityId, csr, issuer, lifetime, timeProvider, random); + ArgumentNullException.ThrowIfNull(enrollmentUrl); + + // CONTRACT.agent-identity.md §4: the extension holds the token's `u`, which is ASCII, without trailing slashes. + if (enrollmentUrl.Length == 0 || enrollmentUrl.EndsWith('/') || !Ascii.IsValid(enrollmentUrl)) + { + throw new ArgumentException( + "The enrollment URL is empty, ends in a slash or is not ASCII.", + nameof(enrollmentUrl)); + } + + return Issue(renewing.IdentityId, enrollmentUrl, csr, issuer, lifetime, timeProvider, random); } private static DateTimeOffset TruncateToSecond(DateTimeOffset value) diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md index 24b747a160..5e4dbba30d 100644 --- a/dotnet/AgentIdentity/README.md +++ b/dotnet/AgentIdentity/README.md @@ -98,7 +98,7 @@ Each then acts for the result's `Identity` only. 2. Pass `Identity` and the match to `RenewalRules.Decide`, and act on its `Outcome`: - `Reject`: answer with `Error`. - `ReturnPendingCertificate`: keep the identity's pending certificate, which holds the key. - - `IssuePendingCertificate`: issue a certificate with the `IdentityCertificateIssuer.Issue` overload that takes the `AuthenticatedIdentity`, and store it as the identity's pending certificate, retiring the previous one. + - `IssuePendingCertificate`: issue a certificate with the `IdentityCertificateIssuer.Issue` overload that takes the `AuthenticatedIdentity`, passing the `EnrollmentUrl` stored with the identity so that every certificate of the identity names the same URL, and store it as the identity's pending certificate, retiring the previous one; the renew CSR's own enrollment URL is ignored (CONTRACT.agent-identity.md [§4]). Store it atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent renewals. 3. Replace the identity's metadata, set its `last_seen_at` to the current time, and answer with `ResponseBodies.Renew`, given the pending certificate's chain (CONTRACT.agent-identity.md [§5.3]). From 71072e8f35ef558ddad10e483cf59ff9810dcf1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 03:11:01 +0900 Subject: [PATCH 90/93] fix(dotnet-agent-identity): tighten the enrollment URL and issuer checks The server ignores every CSR attribute but the enrollment URL, so the parser now skips the values of other attributes unread, and a malformed value of another attribute no longer hides a well-formed enrollment URL. IdentityCertificateIssuer.Issue refuses a generator over another key than the issuer certificate's, whose certificates would not chain, and EnrollmentTokenState refuses a product base URL that is empty once its trailing slashes are removed. Tests pin a critical flag on the requested extension, an attribute that is not a SEQUENCE and an attribute set of indefinite length, and the docs say the attributes are read for the enrollment URL. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../CertificateSigningRequestTests.cs | 25 ++++++++++++ .../IdentityCertificateIssuerTests.cs | 20 +++++++++- .../Enrollment/EnrollmentRulesTests.cs | 28 +++++++++++++ .../Enrollment/EnrollmentTokenTests.cs | 7 +++- .../Errors/AgentIdentityErrorCodeTests.cs | 2 +- .../Support/TestEnrollmentUrl.cs | 39 +++++++++++++++++++ .../Certificates/CertificateSigningRequest.cs | 20 +++++++--- .../Certificates/IdentityCertificateIssuer.cs | 21 ++++++++-- .../Enrollment/EnrollmentTokenState.cs | 8 ++++ 9 files changed, 156 insertions(+), 14 deletions(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 412bb204e2..28f00e174c 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -542,8 +542,33 @@ public void ReadsTheEnrollmentUrlOfTheExtensionRequest(string request) return request.CreateSigningRequest(); }, "https://host/dvls"), + // A critical flag on a requested extension changes nothing the server reads. + // oracle: spec RFC 5280 §4.1 + ["the extension marked critical"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.Attributes(TestEnrollmentUrl.CriticalExtension("https://host/dvls"))), + "https://host/dvls"), + // The server ignores every other attribute, whatever its contents. + ["after another attribute whose values are not a SET"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.AttributesAfter( + TestCsr.Sequence(TestCsr.Oid("1.2.840.113549.1.9.7"), [0x04, 0x00]), + TestEnrollmentUrl.Extension("https://host/dvls"))), + "https://host/dvls"), + // The attributes need not be DER, such as an indefinite length. + ["in an attribute set of indefinite length"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.IndefiniteLength( + TestEnrollmentUrl.Attributes(TestEnrollmentUrl.Extension("https://host/dvls")))), + "https://host/dvls"), // The agent's CSR before it named an enrollment URL: an empty attribute set, A0 00. ["no attributes"] = (TestCsr.For, null), + // An attribute that is not a SEQUENCE names nothing, and the server ignores it. + ["an attribute that is not a SEQUENCE"] = + (key => TestEnrollmentUrl.Request(key, [0xA0, 0x02, 0x04, 0x00]), null), ["an extensionRequest without the extension"] = ( key => TestEnrollmentUrl.Request( key, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs index 1520fa6f2d..7ecd246a77 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs @@ -447,6 +447,12 @@ public void ThrowsForAnIssuerOutsideTheChainProfile(string issuer) public static TheoryData IssuersOutsideTheChainProfileNames => new(IssuersOutsideTheChainProfile.Keys); + private static Dictionary Curves => new() + { + ["P-256"] = ECCurve.NamedCurves.nistP256, + ["P-384"] = ECCurve.NamedCurves.nistP384, + }; + // A root of each allowed curve signs the certificate with the signature algorithm of its curve, // and the certificate chains to it: ecdsa-with-SHA256 for P-256 and ecdsa-with-SHA384 for P-384 (RFC 5758 §3.2). // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates @@ -456,8 +462,7 @@ public void ThrowsForAnIssuerOutsideTheChainProfile(string issuer) [InlineData("P-384", "1.2.840.10045.4.3.3")] public void SignsWithTheAlgorithmOfTheIssuerCurve(string curve, string signatureAlgorithm) { - using OwnedRootSigner root = EcdsaProfileRoot( - curve == "P-256" ? ECCurve.NamedCurves.nistP256 : ECCurve.NamedCurves.nistP384); + using OwnedRootSigner root = EcdsaProfileRoot(Curves[curve]); using X509Certificate2 leaf = IdentityCertificateIssuer.Issue( _creating, @@ -500,6 +505,17 @@ public void SignsWithTheAlgorithmOfTheIssuerCurve(string curve, string signature X509SignatureGenerator.CreateForRSA(key, RSASignaturePadding.Pkcs1), key); }, + // Its signer holds another key than its certificate's, so the certificates it signs do not chain to it. + // oracle: spec RFC 5280 §6.1 + ["a P-256 certificate whose signer holds another P-256 key"] = () => + { + ECDsa certificateKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + X509Certificate2 certificate = + ProfileRoot(new CertificateRequest(RootName, certificateKey, HashAlgorithmName.SHA256)); + certificateKey.Dispose(); + return new OwnedRootSigner(certificate, X509SignatureGenerator.CreateForECDsa(key), key); + }, ["no basicConstraints"] = () => EcdsaProfileRoot(ECCurve.NamedCurves.nistP256, [CaKeyUsage()]), ["basicConstraints without cA"] = () => EcdsaProfileRoot( ECCurve.NamedCurves.nistP256, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs index ab1735ca45..7d8bf2c739 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs @@ -325,6 +325,34 @@ public void TokenStateRejectsANegativeCount(int maxUses, int identitiesCreated, Assert.Equal(parameter, exception.ParamName); } + // The token's `u` is an absolute https URL (CONTRACT.agent-identity.md §2), so none is empty once its trailing + // slashes are removed, and the CSR's enrollment URL is compared with it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: spec Framework Design Guidelines §Validating Arguments + [Theory] + [InlineData("")] + [InlineData("/")] + public void TokenStateRejectsAnEmptyProductBaseUrl(string productBaseUrl) + { + ArgumentException exception = Assert.Throws( + () => new EnrollmentTokenState(false, productBaseUrl, Now, maxUses: 10, identitiesCreated: 0)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("productBaseUrl", exception.ParamName); + } + + // A null argument throws ArgumentNullException, whose ParamName names it. + // oracle: spec Framework Design Guidelines §Validating Arguments + [Fact] + public void TokenStateRejectsANullProductBaseUrl() + { + ArgumentNullException exception = Assert.Throws( + () => new EnrollmentTokenState(false, null!, Now, maxUses: 10, identitiesCreated: 0)); + + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + Assert.Equal("productBaseUrl", exception.ParamName); + } + // Statuses start at 1, so a status that the host never set is not a valid one, // and a value after the last status is not one either. // Framework Design Guidelines: validate enum arguments, and throw the most derived ArgumentException naming them. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs index 228e5b6787..ba6c25349f 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs @@ -104,8 +104,10 @@ public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl) } // The authority keeps the token's `u` to compare it with the enrollment URL of each CSR the token presents - // (CONTRACT.agent-identity.md §2.1), so ProductBaseUrl is the `u` that the bag holds, after any normalization. + // (CONTRACT.agent-identity.md §2.1), so ProductBaseUrl is the `u` that the bag holds: `https://host/dvls` for each + // row, since `u` has no trailing slash and white space around a URI is not part of it (RFC 3986 Appendix C). // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token [Theory] [InlineData("https://host/dvls")] [InlineData("https://host/dvls/")] @@ -114,7 +116,8 @@ public void ProductBaseUrlIsTheUOfTheBag(string productBaseUrl) { EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes)); - Assert.Equal(Assert.Single(BagMembers(token), member => member.Name == "u").Value, token.ProductBaseUrl); + Assert.Equal("https://host/dvls", token.ProductBaseUrl); + Assert.Equal(token.ProductBaseUrl, Assert.Single(BagMembers(token), member => member.Name == "u").Value); } // Create may reject a URL that is not in the form of `u`, diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs index c972a24a04..93167f9d99 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs @@ -118,7 +118,7 @@ public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, Statu Assert.Equal(expected, code.ToGrpcStatusCode()); } - // CONTRACT.agent-identity.md §5.4 lists exactly these ten codes. + // CONTRACT.agent-identity.md §5.4 lists exactly these eleven codes. // A code added without a contract row would otherwise go untested in every table above. // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors [Fact] diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs index 0117911c63..ab736af198 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs @@ -39,6 +39,45 @@ public static byte[] Extension(byte[] value, string oid = Oid) return writer.Encode(); } + /// + /// The enrollment URL extension of a URL with its critical flag set. + /// + public static byte[] CriticalExtension(string url) + { + AsnWriter writer = new(AsnEncodingRules.DER); + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(Oid); + writer.WriteBoolean(true); + writer.WriteOctetString(IA5String(url)); + } + + return writer.Encode(); + } + + /// + /// The [0] attributes of a CSR holding another attribute, given whole, then one extensionRequest. + /// + public static byte[] AttributesAfter(byte[] otherAttribute, params byte[][] extensions) + { + byte[] attributes = Attributes(extensions); + AsnReader reader = new AsnReader(attributes, AsnEncodingRules.DER) + .ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0)); + byte[] extensionRequest = reader.ReadEncodedValue().ToArray(); + + return TestCsr.Tlv(0xA0, otherAttribute, extensionRequest); + } + + /// + /// The same constructed element with an indefinite length (X.690 §8.1.3.6): its tag, 80, its contents, then 00 00. + /// + public static byte[] IndefiniteLength(byte[] element) + { + AsnDecoder.ReadEncodedValue(element, AsnEncodingRules.DER, out int contentOffset, out int contentLength, out _); + + return [element[0], 0x80, .. element.AsSpan(contentOffset, contentLength), 0x00, 0x00]; + } + /// /// The enrollment URL extension of a URL, as the agent's CSR requests it. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs index 0e91b3dd9f..2711a0f464 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs @@ -73,7 +73,10 @@ private CertificateSigningRequest(byte[] subjectPublicKeyInfo, string? enrollmen /// /// /// A valid request has version 0 and no trailing bytes, and is DER except for the subject and the attributes, - /// which the server ignores: each need only be a BER element of its type, a SEQUENCE and a constructed [0]. + /// which the server ignores but for the enrollment URL that holds: + /// each need only be a BER element of its type, a SEQUENCE and a constructed [0], + /// and the attributes never make the request invalid; the values of attributes other than extensionRequest stay + /// unread. /// Its key is id-ecPublicKey on the named curve P-256, as an uncompressed point on the curve (RFC 5480). /// Its signature algorithm is ecdsa-with-SHA256 without parameters (RFC 5758), /// and its signature verifies under its key over the exact bytes of its certificationRequestInfo. @@ -115,8 +118,9 @@ private static CsrValidationResult Parse(byte[] der) return CsrValidationResult.Failure("The CSR version is not 0."); } - // The subject and the attributes are read only as BER elements of their type, and never parsed, - // because nothing the server decides depends on them; the self-signature still covers their bytes. + // The subject and the attributes are read only as BER elements of their type, since nothing the server decides + // depends on them but the enrollment URL, whose reading never makes the request invalid; + // the self-signature still covers their bytes. fields = fields[length..]; AsnDecoder.ReadSequence(fields.Span, AsnEncodingRules.BER, out _, out _, out length); @@ -176,10 +180,16 @@ private static CsrValidationResult Parse(byte[] der) while (attributeSet.HasData) { AsnReader attribute = attributeSet.ReadSequence(); - string type = attribute.ReadObjectIdentifier(); + + // The values of every other attribute stay unread, since the server ignores them. + if (attribute.ReadObjectIdentifier() != ExtensionRequest) + { + continue; + } + AsnReader attributeValues = attribute.ReadSetOf(skipSortOrderValidation: true); - while (type == ExtensionRequest && attributeValues.HasData) + while (attributeValues.HasData) { AsnReader extensions = attributeValues.ReadSequence(); diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs index 89b880214b..6114d303ef 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs @@ -80,7 +80,7 @@ public static class IdentityCertificateIssuer /// or the issuer is outside the chain profile of CONTRACT.agent-identity.md §4: /// its certificate has neither a P-256 nor a P-384 key, no basicConstraints with cA set or no key usage with /// keyCertSign, or its generator does not sign with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a - /// P-384 key. + /// P-384 key, or holds another key than its certificate's. /// /// /// is shorter than one second. @@ -145,7 +145,9 @@ private static X509Certificate2 Issue( "The issuer certificate's key is neither a P-256 nor a P-384 key.", nameof(issuer)); - byte[] signatureAlgorithm = issuer.SignatureGenerator.GetSignatureAlgorithmIdentifier(hash); + // Read once, so that every check acts on the generator that signs. + X509SignatureGenerator generator = issuer.SignatureGenerator; + byte[] signatureAlgorithm = generator.GetSignatureAlgorithmIdentifier(hash); if (!signatureAlgorithm.AsSpan().SequenceEqual(expectedAlgorithm)) { throw new ArgumentException( @@ -153,6 +155,16 @@ private static X509Certificate2 Issue( nameof(issuer)); } + // A generator over another key would sign certificates that do not chain to the issuer certificate. + PublicKey signerKey = generator.PublicKey; + byte[] issuerKeyValue = issuerCertificate.PublicKey.EncodedKeyValue.RawData; + if (!signerKey.EncodedKeyValue.RawData.AsSpan().SequenceEqual(issuerKeyValue)) + { + throw new ArgumentException( + "The issuer's generator signs with another key than its certificate's.", + nameof(issuer)); + } + if (issuerCertificate.Extensions[BasicConstraintsOid] is not X509BasicConstraintsExtension { CertificateAuthority: true, @@ -212,7 +224,7 @@ private static X509Certificate2 Issue( return request.Create( issuerCertificate.SubjectName, - issuer.SignatureGenerator, + generator, notBefore, notAfter, NewSerialNumber(random)); @@ -241,6 +253,7 @@ private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignaturePro return null; } + /// /// Issues the pending certificate of a renewal, for the identity that signed the renew request. /// @@ -271,7 +284,7 @@ private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignaturePro /// or the issuer is outside the chain profile of CONTRACT.agent-identity.md §4: /// its certificate has neither a P-256 nor a P-384 key, no basicConstraints with cA set or no key usage with /// keyCertSign, or its generator does not sign with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a - /// P-384 key. + /// P-384 key, or holds another key than its certificate's. /// /// /// is shorter than one second. diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs index 7162587fb5..dfc83f7fba 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs @@ -20,6 +20,9 @@ public sealed class EnrollmentTokenState /// The number of identities that the token may create. /// The number of identities that the token has created. /// is null. + /// + /// is empty once its trailing slashes are removed. + /// /// /// or is negative. /// @@ -31,6 +34,11 @@ public EnrollmentTokenState( int identitiesCreated) { ArgumentNullException.ThrowIfNull(productBaseUrl); + if (productBaseUrl.TrimEnd('/').Length == 0) + { + throw new ArgumentException("The product base URL is empty.", nameof(productBaseUrl)); + } + ArgumentOutOfRangeException.ThrowIfNegative(maxUses); ArgumentOutOfRangeException.ThrowIfNegative(identitiesCreated); From b293e680158d64e4db68a60ec21954a65213fe61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 14:08:26 +0900 Subject: [PATCH 91/93] docs(dotnet-agent-identity): say where the enrollment URL OID sits The enrollment URL extension's OID sits under the enterprise number that RFC 5612 reserves for documentation, and the contract moves it under Devolutions' own enterprise number before release. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Certificates/EnrollmentUrlExtension.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs index 123206402e..fe1b5c4992 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs @@ -13,7 +13,8 @@ namespace Devolutions.AgentIdentity; /// internal static class EnrollmentUrlExtension { - // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension. + // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension, under the enterprise number that RFC 5612 + // reserves for documentation; the contract moves it under Devolutions' own enterprise number before release. public const string Oid = "1.3.6.1.4.1.32473.1.1"; /// From 796ccbdee3689e0a7817443af19b0214b4fee8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 25 Oct 2026 14:08:27 +0900 Subject: [PATCH 92/93] test(dotnet-agent-identity): pin each CSR vector's outcome per endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared vectors now give each CSR's outcome at enroll and at renew in expected_at, and name the enrollment URL a valid CSR carries. The loader reads both members, and a theory runs every CSR vector through validation and then EnrollmentRules.Decide, with a token whose u is the entry's enrollment URL, or RenewalRules.Decide; so a CSR with an empty extensionRequest is refused at enrollment with token_url_mismatch and accepted at renewal. A CSR row also pins that the enrollment URL extension requested in two extensionRequest attributes names no enrollment URL (CONTRACT.agent-identity.md §4). Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../CertificateSigningRequestTests.cs | 16 ++++ .../Enrollment/CsrVectorEndpointTests.cs | 79 +++++++++++++++++++ .../Support/TestEnrollmentUrl.cs | 27 +++++-- .../Support/TestVectors.cs | 4 +- 4 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/CsrVectorEndpointTests.cs diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs index 28f00e174c..89d3b97990 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs @@ -583,6 +583,22 @@ public void ReadsTheEnrollmentUrlOfTheExtensionRequest(string request) TestEnrollmentUrl.Extension("https://host/dvls"), TestEnrollmentUrl.Extension("https://host/dvls"))), null), + // Several extensionRequest attributes requesting the extension name no enrollment URL either. + ["the extension in two extensionRequest attributes"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.AttributesWithAnExtensionRequestEach( + TestEnrollmentUrl.Extension("https://host/dvls"), + TestEnrollmentUrl.Extension("https://host/dvls"))), + null), + // A request for the extension once, among several extensionRequest attributes, names its value. + ["the extension in one of two extensionRequest attributes"] = ( + key => TestEnrollmentUrl.Request( + key, + TestEnrollmentUrl.AttributesWithAnExtensionRequestEach( + TestEnrollmentUrl.Extension("https://host/dvls"), + TestEnrollmentUrl.Extension(TestEnrollmentUrl.IA5String("x"), "2.5.29.17"))), + "https://host/dvls"), // The extension holds an IA5String. ["a UTF8String value"] = ( key => TestEnrollmentUrl.Request( diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/CsrVectorEndpointTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/CsrVectorEndpointTests.cs new file mode 100644 index 0000000000..bf652c1c6a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/CsrVectorEndpointTests.cs @@ -0,0 +1,79 @@ +namespace Devolutions.AgentIdentity.Tests; + +// test-vectors.json csr..expected_at gives each CSR's outcome at each endpoint: +// `enroll` with a valid token whose `u` is the entry's `enrollment_url`, or any valid token when it has none, +// and `renew` signed by an identity that holds no certificate for the CSR's key. +public class CsrVectorEndpointTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + + // Any valid token, for an entry that names no enrollment URL. + private const string AnyTokenUrl = "https://example.test/dvls"; + + public static TheoryData CsrEndpoints + { + get + { + TheoryData rows = []; + foreach ((string name, CsrVector vector) in TestVectors.Instance.Csr) + { + foreach (string endpoint in vector.ExpectedAt.Keys) + { + rows.Add(name, endpoint); + } + } + + return rows; + } + } + + // oracle: reference docs/agent-identity/test-vectors.json csr + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + [Theory] + [MemberData(nameof(CsrEndpoints))] + public async Task CsrVectorHasItsExpectedOutcomeAtEachEndpoint(string name, string endpoint) + { + CsrVector vector = TestVectors.Instance.Csr[name]; + + string outcome = await Endpoints[endpoint](vector); + + Assert.Equal(vector.ExpectedAt[endpoint], outcome); + } + + // The outcome of a CSR at each endpoint, through the public API: `ok` or the wire value of the error code. + private static Dictionary>> Endpoints => new() + { + ["enroll"] = vector => Task.FromResult(Enroll(vector)), + ["renew"] = Renew, + }; + + private static string Enroll(CsrVector vector) + { + CsrValidationResult result = CertificateSigningRequest.Validate(vector.Csr); + if (!result.Succeeded) + { + return result.Error.Code.ToWireValue(); + } + + EnrollmentTokenState token = + new(false, vector.EnrollmentUrl ?? AnyTokenUrl, Now.AddDays(1), maxUses: 10, identitiesCreated: 0); + EnrollmentDecision decision = EnrollmentRules.Decide(token, result.Csr, null, Now); + + return decision.Outcome == EnrollmentOutcome.CreateIdentity ? "ok" : decision.Error!.Code.ToWireValue(); + } + + private static async Task Renew(CsrVector vector) + { + CsrValidationResult result = CertificateSigningRequest.Validate(vector.Csr); + if (!result.Succeeded) + { + return result.Error.Code.ToWireValue(); + } + + AuthenticatedIdentity renewing = await TestIdentity.AuthenticateAsync(AgentOperation.Renew); + RenewalDecision decision = RenewalRules.Decide(renewing, null); + + return decision.Outcome == RenewalOutcome.IssuePendingCertificate ? "ok" : decision.Error!.Code.ToWireValue(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs index ab736af198..1970e5d744 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs @@ -8,7 +8,8 @@ namespace Devolutions.AgentIdentity.Tests; /// internal static class TestEnrollmentUrl { - // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension. + // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension, under RFC 5612's documentation enterprise + // number until the contract moves it. public const string Oid = "1.3.6.1.4.1.32473.1.1"; // RFC 2985 §5.4.2: the extensionRequest attribute. @@ -60,12 +61,7 @@ public static byte[] CriticalExtension(string url) /// public static byte[] AttributesAfter(byte[] otherAttribute, params byte[][] extensions) { - byte[] attributes = Attributes(extensions); - AsnReader reader = new AsnReader(attributes, AsnEncodingRules.DER) - .ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0)); - byte[] extensionRequest = reader.ReadEncodedValue().ToArray(); - - return TestCsr.Tlv(0xA0, otherAttribute, extensionRequest); + return TestCsr.Tlv(0xA0, otherAttribute, ExtensionRequestAttribute(extensions)); } /// @@ -109,6 +105,23 @@ public static byte[] Attributes(params byte[][] extensions) return writer.Encode(); } + /// + /// The [0] attributes of a CSR holding one extensionRequest per given extension. + /// + public static byte[] AttributesWithAnExtensionRequestEach(params byte[][] extensions) + { + return TestCsr.Tlv(0xA0, [.. extensions.Select(extension => ExtensionRequestAttribute(extension))]); + } + + // The one extensionRequest Attribute (RFC 2986 §4) that Attributes writes for the given extensions. + private static byte[] ExtensionRequestAttribute(params byte[][] extensions) + { + return new AsnReader(Attributes(extensions), AsnEncodingRules.DER) + .ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0)) + .ReadEncodedValue() + .ToArray(); + } + /// /// A CSR for the key, signed by it, with the given attributes. /// diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs index 0c51bb88ff..b5ca79fedc 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs @@ -74,9 +74,11 @@ public sealed record RootVector(byte[] Certificate, string Thumbprint, byte[] Pu public sealed record CsrVector( byte[] Csr, string Expected, + IReadOnlyDictionary ExpectedAt, string? Description = null, byte[]? PublicKeySec1 = null, - byte[]? PrivateKeyPkcs8 = null); + byte[]? PrivateKeyPkcs8 = null, + string? EnrollmentUrl = null); public sealed record HttpSignatureVectors( IReadOnlyList Registered, From 83e18b75b358853fe373c436128e3ee942a93466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 14:20:13 +0900 Subject: [PATCH 93/93] docs(dotnet-agent-identity): say that a token's URL may differ from the entry Creating a token normalizes the product base URL, so a host that shows the URL as typed shows a different string than the bag holds. Issue: DGW-479 Co-Authored-By: Claude Opus 5.5 --- .../Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs index b11e4dfcbc..745105e56d 100644 --- a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs @@ -72,6 +72,8 @@ private EnrollmentToken(string value, byte[] secretHash, string productBaseUrl) /// The bag holds the JSON object {"u": <productBaseUrl>}, /// with the URL in normalized absolute form and without trailing slashes, /// since the agent appends /api/agent-identity/v1/... to it. + /// Normalizing can change the text, such as a host's case or a default port, + /// so the URL as the administrator entered it may differ from , which the bag holds. /// The secret is 32 bytes from . /// ///