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 }}
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/Authentication/AuthenticationTypeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs
new file mode 100644
index 0000000000..c20b9ba2a7
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/AuthenticationTypeTests.cs
@@ -0,0 +1,105 @@
+using System.Reflection;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+///
+/// 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,
+/// so that an identity is never taken from a value the agent or a caller sent.
+///
+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),
+ // 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
+ typeof(ChannelOpeningResult),
+ // oracle: mutant a public ChannelHandshakeResult constructor
+ typeof(ChannelHandshakeResult),
+ ];
+
+ public static TheoryData AuthenticationTypeData => new(AuthenticationTypes);
+
+ // 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, 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]
+ [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);
+ }
+
+ // 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,
+ // or an EnrollmentDecision outside the enrollment rules
+ [Fact]
+ public void NoOtherPublicMemberProducesTheseTypes()
+ {
+ 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",
+ "ChannelHandshakeResult.get_Identity",
+ "ChannelOpening.FirstMessageUnreadableAsync",
+ "ChannelOpening.HelloTimedOutAsync",
+ "ChannelOpening.VerifyHelloAsync",
+ "ChannelOpeningResult.get_Opening",
+ "EnrollmentRules.Decide",
+ "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/Authentication/RegisteredCertificateTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs
new file mode 100644
index 0000000000..818124149e
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RegisteredCertificateTests.cs
@@ -0,0 +1,23 @@
+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.
+ // The default is the value of an uninitialized status, and 4 follows the last of the three statuses.
+ // 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)]
+ public void RefusesAnUndefinedStatus(CertificateStatus undefined)
+ {
+ KeyVector key = TestVectors.Instance.Key("device-a");
+
+ Assert.Throws(
+ () => new RegisteredCertificate(key.IdentityId, key.Certificate, undefined, identityRevoked: false));
+ }
+}
diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs
new file mode 100644
index 0000000000..0dcfa69d7d
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorProfileTests.cs
@@ -0,0 +1,393 @@
+using System.Globalization;
+using System.Security.Cryptography;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+///
+/// 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,
+/// 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";
+
+ // 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()
+ {
+ // 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}"]),
+
+ // 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},"]),
+
+ ["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"),
+
+ // 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}")}"]),
+
+ ["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"),
+
+ // 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""")}"]),
+
+ // 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(),
+
+ // 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()
+ {
+ ["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.
+ // 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();
+ 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.
+ // oracle: spec RFC 9421 §2.1
+ ["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 "]);
+ },
+
+ // 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}")}"]),
+ };
+
+ // 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()
+ {
+ // The member is an inner list.
+ // oracle: spec RFC 9421 §3.2
+ ["an integer member"] = () => RenewWithSignatureInput(_ => ["sig=1"]),
+
+ // 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", ""),
+
+ // 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"),
+
+ // 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\\"),
+ };
+
+ 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);
+
+ 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");
+
+ private static string Created => Now.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
+
+ 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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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);
+ }
+
+ // 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 §signatures
+ [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);
+ }
+
+ // 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 §signatures
+ [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);
+ }
+
+ // 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 §signatures
+ [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.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 §signatures
+ [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);
+ }
+
+ 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)
+ {
+ 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/Authentication/RequestAuthenticatorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs
new file mode 100644
index 0000000000..68c8d164b6
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorTests.cs
@@ -0,0 +1,487 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+///
+/// 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.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;
+
+ 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();
+ },
+ };
+
+ // 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,
+ };
+
+ 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
+ {
+ 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 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");
+
+ // 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;
+
+ // A body larger than 64 KiB returns 413 invalid_request before any authentication outcome.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ [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);
+ }
+
+ // Servers accept request bodies up to 64 KiB.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ [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));
+ }
+
+ // 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 §signatures
+ [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, TestRequest failing) = TwoFailures[request]();
+
+ Assert.Equal(expected, await authority.OutcomeAsync(failing));
+ }
+
+ // 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 §signatures
+ [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, TestRequest failing) = TwoFailures[request]();
+
+ Assert.Equal(expected, await authority.OutcomeAsync(failing));
+ }
+
+ // Table: every operation accepts a current or pending certificate.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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)));
+ }
+
+ // Step 4: a certificate that is neither current nor pending draws identity_unknown.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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)));
+ }
+
+ // 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 §signatures
+ [Theory]
+ [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(ticksAfterNotAfter));
+
+ Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(operation)));
+ }
+
+ // 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 §signatures
+ [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)));
+ }
+
+ // Table: renew accepts a certificate while now < notAfter + (notAfter − notBefore).
+ // 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")]
+ [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(RenewalGraceInstants[instant]());
+ authority.Registry.Register(DeviceA, status);
+
+ Assert.Equal(expected, await authority.OutcomeAsync(authority.Valid(AgentOperation.Renew)));
+ }
+
+ // 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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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)));
+ }
+
+ // Step 3: 0 < expires − created ≤ 300.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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.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 §signatures
+ // oracle: spec RFC 8941 §3.3.1
+ // oracle: spec RFC 9421 §2.3
+ [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.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 §signatures
+ // oracle: spec RFC 8941 §3.3.5
+ [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);
+ }
+
+ // Step 6: a pair the store already holds fails, whichever request recorded it.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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)));
+ }
+
+ // A header the operation does not cover, such as Content-Digest on confirm, is ignored.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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);
+ }
+
+ // 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 §signatures
+ [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);
+ }
+
+ // 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 §signatures
+ [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);
+ }
+
+ // 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 §signatures
+ [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.
+ // 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)
+ {
+ TestAuthority authority = new(Now);
+ SignedHttpRequest request = authority.Valid(AgentOperation.Connect).ToHttpRequest();
+
+ await Assert.ThrowsAsync(
+ () => authority.Authenticator.AuthenticateAsync(operation, request).AsTask());
+ }
+
+ // 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 §errors
+ // oracle: spec Framework Design Guidelines §Exception Throwing
+ // oracle: spec Framework Design Guidelines §Using Standard Exception Types
+ [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.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.
+ // 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]
+ [MemberData(nameof(RegisteredBytesOtherThanAP256CertificateNames))]
+ public async Task RegistryReturningSomethingOtherThanAP256CertificateThrows(string registered)
+ {
+ byte[] der = RegisteredBytesOtherThanAP256Certificate[registered]();
+ 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/Authentication/RequestAuthenticatorVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs
new file mode 100644
index 0000000000..1c9be424df
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/RequestAuthenticatorVectorTests.cs
@@ -0,0 +1,202 @@
+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.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)
+ {
+ HttpSignatureCaseVector vector = Vectors.Case(name);
+ VectorAuthority authority = new();
+
+ VectorOutcome outcome = await authority.RunAsync(vector);
+
+ Assert.Equal(vector.Expected, outcome.Outcome);
+ }
+
+ // 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)
+ {
+ 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);
+ }
+
+ // 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)
+ {
+ 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.
+ // oracle: spec RFC 9421 §B.2.4
+ // oracle: spec RFC 9421 §B.1.3
+ [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);
+ }
+
+ // 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 §signatures
+ [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.
+ // oracle: intent dotnet/AgentIdentity/INTENT.md §Failure details
+ [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.All(headers.Concat(values), value => Assert.DoesNotContain(value, outcome.FailureDetail));
+ }
+
+ // 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 §signatures
+ // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Request authentication
+ [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.
+ // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [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);
+ // 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);
+ }
+
+ // 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/Authentication/StructuredFieldTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs
new file mode 100644
index 0000000000..8326833e92
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Authentication/StructuredFieldTests.cs
@@ -0,0 +1,179 @@
+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]
+ // Step 2: leading spaces are discarded.
+ // oracle: spec RFC 8941 §4.2
+ [InlineData(" sig=1", "sig=Integer(1)")]
+ // 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)")]
+ // A key may start with an asterisk.
+ // oracle: spec RFC 8941 §4.2.3.3
+ [InlineData("*=1", "*=Integer(1)")]
+ // 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)")]
+ // 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)")]
+ // 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)")]
+ // 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)
+ {
+ Assert.True(TryParseDictionary(input, out IReadOnlyList? dictionary));
+ Assert.Equal(expected, Describe(dictionary));
+ }
+
+ [Theory]
+ // Step 1: a field value is ASCII.
+ // oracle: spec RFC 8941 §4.2
+ [InlineData("sig\u0080=1")]
+ // A key starts with a lowercase letter or an asterisk.
+ // oracle: spec RFC 8941 §4.2.3.3
+ [InlineData("Sig=1")]
+ // 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")]
+ // 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 §signatures
+ [InlineData("sig=1, sig=2")]
+ // 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,")]
+ // 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\"")]
+ // 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.")]
+ // 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")]
+ // 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!==:")]
+ // 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 _));
+ }
+
+ // 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\"")]
+ [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
new file mode 100644
index 0000000000..149e153fd3
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/AgentMetadataTests.cs
@@ -0,0 +1,339 @@
+using System.Collections;
+
+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 §metadata
+ [Fact]
+ public void AcceptsNoEntries()
+ {
+ AgentMetadata metadata = AssertValid(AgentMetadata.Validate([]));
+
+ Assert.Empty(metadata);
+ }
+
+ // Metadata is a JSON object of string to string, and keeps each entry received.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Fact]
+ public void KeepsEveryEntryReceived()
+ {
+ KeyValuePair[] entries =
+ [
+ new("os_name", "Windows"),
+ new("hostname", "host"),
+ new("agent_version", "2026.3.0"),
+ ];
+
+ AgentMetadata metadata = AssertValid(AgentMetadata.Validate(entries));
+
+ Assert.Equal(
+ entries.OrderBy(entry => entry.Key, StringComparer.Ordinal),
+ metadata.OrderBy(entry => entry.Key, StringComparer.Ordinal));
+ 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.
+ // oracle: mutant the non-generic GetEnumerator returns no enumerator instead of the entries
+ [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.
+ // oracle: spec .NET API reference §IReadOnlyDictionary.ContainsKey
+ [Fact]
+ public void ContainsOnlyTheKeysReceived()
+ {
+ AgentMetadata metadata = AssertValid(AgentMetadata.Validate([new("hostname", "host")]));
+
+ Assert.True(metadata.ContainsKey("hostname"));
+ Assert.False(metadata.ContainsKey("absent"));
+ }
+
+ // At most 32 keys.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Fact]
+ public void Accepts32Keys()
+ {
+ AgentMetadata metadata = AssertValid(AgentMetadata.Validate(NumberedEntries(32)));
+
+ Assert.Equal(32, metadata.Count);
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Fact]
+ public void Rejects33Keys()
+ {
+ AssertInvalid(AgentMetadata.Validate(NumberedEntries(33)));
+ }
+
+ // A key matches ^[a-z][a-z0-9_]{0,63}$.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Theory]
+ [InlineData("a")]
+ [InlineData("hostname")]
+ [InlineData("os_version")]
+ [InlineData("z9_")]
+ [InlineData("abcdefghijklmnopqrstuvwxyz0123456789_")]
+ public void AcceptsKeyMatchingThePattern(string key)
+ {
+ AssertValid(AgentMetadata.Validate([new(key, "value")]));
+ }
+
+ // 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 §metadata
+ [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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [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);
+ }
+
+ // 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 §metadata
+ [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)]));
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [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)]));
+ }
+
+ // CONTRACT.agent-identity.md §3 gives a value no minimum length.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Fact]
+ public void AcceptsEmptyValue()
+ {
+ AssertValid(AgentMetadata.Validate([new("domain", "")]));
+ }
+
+ // 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 §metadata
+ [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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Theory]
+ [InlineData(0x20)]
+ [InlineData(0x7E)]
+ [InlineData(0xA0)]
+ public void AcceptsValueWithCharacterNextToAControlRange(int codePoint)
+ {
+ AssertValid(AgentMetadata.Validate([new("hostname", $"host{(char)codePoint}name")]));
+ }
+
+ // 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 §metadata
+ // oracle: spec RFC 3629 §3
+ [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);
+ }
+
+ // 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 §metadata
+ [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")));
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Fact]
+ public void RejectsObjectOf16KiBAndOneByte()
+ {
+ // The same with a last key of 4 bytes: 16385 bytes.
+ AssertInvalid(AgentMetadata.Validate(EntriesOf1024Bytes(lastKey: "k150")));
+ }
+
+ // 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 §conventions
+ [Fact]
+ public void RejectsRepeatedKey()
+ {
+ AssertInvalid(AgentMetadata.Validate([new("hostname", "a"), new("hostname", "b")]));
+ }
+
+ // 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(FailureNames))]
+ public void FailureDetailHoldsNoKeyOrValue(string failure)
+ {
+ MetadataValidationResult result = AgentMetadata.Validate(Failures[failure]);
+
+ // 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);
+ 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([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 §metadata
+ AssertInvalid(result);
+ 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.
+ // 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)]
+ public void ThrowsForNullKeyOrValue(string? key, string? value)
+ {
+ Assert.Throws(() => AgentMetadata.Validate([new(key!, value!)]));
+ }
+
+ private static AgentMetadata AssertValid(MetadataValidationResult result)
+ {
+ Assert.True(result.Succeeded, result.FailureDetail);
+
+ return result.Metadata;
+ }
+
+ private static void AssertInvalid(MetadataValidationResult result, string? form = null)
+ {
+ Assert.False(result.Succeeded, form);
+ // 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
+ 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"));
+ }
+
+ 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/Bodies/ConfirmRequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ConfirmRequestBodyTests.cs
new file mode 100644
index 0000000000..37c33d174d
--- /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 §confirm
+ [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 §confirm
+ [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/Bodies/IdentityConfigTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs
new file mode 100644
index 0000000000..20ef15c8cd
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/IdentityConfigTests.cs
@@ -0,0 +1,375 @@
+using System.Text.Json;
+
+using Google.Protobuf;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+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 §enroll
+ [Fact]
+ public void JsonHasTheContractMembers()
+ {
+ IdentityConfig config = IdentityConfig.Create(3, "https://host/dvls");
+
+ Dictionary members = TestJson.Members(config.ToUtf8Json());
+
+ 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);
+ 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.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 §conventions
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll
+ [Fact]
+ public void JsonOmitsAnAbsentAgentChannelUrl()
+ {
+ IdentityConfig config = IdentityConfig.Create(3);
+
+ Dictionary members = TestJson.Members(config.ToUtf8Json());
+
+ Assert.Equal(["revision", "version"], TestJson.Names(members));
+ Assert.Null(config.AgentChannelUrl);
+ }
+
+ // `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 §enroll
+ [Theory]
+ [InlineData(0UL, "0")]
+ [InlineData(9007199254740993UL, "9007199254740993")]
+ [InlineData(18446744073709551615UL, "18446744073709551615")]
+ public void JsonWritesEveryRevisionExactly(ulong revision, string expected)
+ {
+ IdentityConfig config = IdentityConfig.Create(revision);
+
+ Dictionary members = TestJson.Members(config.ToUtf8Json());
+
+ Assert.Equal(expected, members["revision"].GetRawText());
+ Assert.Equal(revision, config.Revision);
+ }
+
+ // oracle: spec Framework Design Guidelines §Validating Arguments
+ [Theory]
+ [MemberData(nameof(UnacceptableUrls.Rows), MemberType = typeof(UnacceptableUrls))]
+ 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);
+ }
+
+ // `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]
+ [InlineData("https://host/dvls")]
+ [InlineData("https://host")]
+ public void AgentChannelUrlIsWrittenAsGiven(string agentChannelUrl)
+ {
+ IdentityConfig config = IdentityConfig.Create(1, agentChannelUrl);
+
+ Assert.Equal(agentChannelUrl, TestJson.Members(config.ToUtf8Json())["agent_channel_url"].GetString());
+ 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 §enroll
+ [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", TestJson.Parse(ChannelUrl)), new("policy_limits", TestJson.Parse(Limits))]);
+
+ Dictionary members = TestJson.Members(config.ToUtf8Json());
+ Assert.Equal(
+ ["agent_channel_url", "policy_limits", "psu_channel_url", "revision", "version"],
+ TestJson.Names(members));
+ Assert.True(JsonElement.DeepEquals(TestJson.Parse(ChannelUrl), members["psu_channel_url"]));
+ Assert.True(JsonElement.DeepEquals(TestJson.Parse(Limits), members["policy_limits"]));
+ }
+
+ // 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 §conventions
+ [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"))]);
+
+ Assert.Equal(JsonValueKind.True, TestJson.Members(config.ToUtf8Json())[name].ValueKind);
+ }
+
+ [Theory]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [InlineData("")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [InlineData("Channel")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [InlineData("psuChannel")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [InlineData("psu-channel")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [InlineData("psu channel")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [InlineData("psu\n")]
+ public void CreateRejectsAProductFieldNameThatIsNotSnakeCase(string name)
+ {
+ AssertRejectsProductFields([new(name, TestJson.Parse("true"))]);
+ }
+
+ // 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 §enroll
+ // oracle: spec RFC 8259 §4
+ [Theory]
+ [InlineData("version")]
+ [InlineData("revision")]
+ [InlineData("agent_channel_url")]
+ public void CreateRejectsAProductFieldNamedAfterAContractMember(string name)
+ {
+ AssertRejectsProductFields([new(name, TestJson.Parse("2"))]);
+ }
+
+ // 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)]);
+ }
+
+ // A member name is a string, which a null name is not.
+ // oracle: spec RFC 8259 §4
+ [Fact]
+ public void CreateRejectsAProductFieldWithoutAName()
+ {
+ AssertRejectsProductFields([new(null!, TestJson.Parse("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.
+ [Theory]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [InlineData("""{"\ud800": 1}""")]
+ public void CreateRejectsAProductFieldValueThatIsNotUnicodeText(string value)
+ {
+ AssertRejectsProductFields([new("label", TestJson.Parse(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 §conventions
+ [Theory]
+ [InlineData("""{"maxSessions": 4}""")]
+ [InlineData("""{"limits": {"max_sessions": 4, "Max_paths": 2}}""")]
+ [InlineData("""[{"Path": "a"}]""")]
+ [InlineData("""{"paths": [["a"], [{"pathName": "b"}]]}""")]
+ 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.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()
+ {
+ ["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}]}"""))],
+ // 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)
+ {
+ AssertRejectsProductFields(RepeatedMemberNames[form]);
+ }
+
+ // An absent field is omitted, never written as null, at any depth.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [Theory]
+ [InlineData("psu_channel_url", "null")]
+ [InlineData("policy_limits", """{"proxy": null}""")]
+ [InlineData("policy_limits", """[{"proxy": null}]""")]
+ 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.
+ // 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]
+ public void CreateAcceptsAValueWhoseMembersFollowTheConventions()
+ {
+ 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,
+ // 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()
+ {
+ const string Limits = """{"paths": ["a", "b"]}""";
+ IdentityConfig config;
+
+ using (JsonDocument document = JsonDocument.Parse(Limits))
+ {
+ config = IdentityConfig.Create(1, null, [new("policy_limits", document.RootElement)]);
+ }
+
+ JsonElement written = TestJson.Members(config.ToUtf8Json())["policy_limits"];
+ 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 §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 §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(
+ () => IdentityConfig.Create(1, "https://host/dvls", productFields));
+
+ // 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/Bodies/RequestBodyTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs
new file mode 100644
index 0000000000..9c398d8f1e
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/RequestBodyTests.cs
@@ -0,0 +1,590 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+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.agent-identity.md §1 and §3);
+/// each derived class runs them on one body type.
+///
+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.
+ ///
+ protected abstract string Body(string? metadata, string extraMembers);
+
+ 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 §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()
+ {
+ AgentMetadata metadata = AssertValid(ParseText(Body(ValidMetadata, ""))).Metadata!;
+
+ Assert.Equal([new KeyValuePair("hostname", "host")], metadata);
+ }
+
+ // 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 §conventions
+ [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)));
+ }
+
+ // An escape stands for its character.
+ // oracle: spec RFC 8259 §7
+ [Fact]
+ public void ReturnsTheMetadataUnescaped()
+ {
+ RequestBodyOutcome outcome = ParseText(Body("""{"os_name": "Windows", "host\u006eame": "h\u00e9"}""", ""));
+ KeyValuePair[] expected = [new("hostname", "hé"), new("os_name", "Windows")];
+
+ Assert.Equal(expected, AssertValid(outcome).Metadata!.OrderBy(entry => entry.Key, StringComparer.Ordinal));
+ }
+
+ // Metadata may be an empty object.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Fact]
+ public void AcceptsEmptyMetadata()
+ {
+ Assert.Empty(AssertValid(ParseText(Body("{}", ""))).Metadata!);
+ }
+
+ // 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 §conventions
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Theory]
+ [InlineData(null)]
+ [InlineData("null")]
+ public void RejectsMissingMetadata(string? metadata)
+ {
+ AssertInvalid(ParseText(Body(metadata, "")));
+ }
+
+ // Metadata is a JSON object of string to string.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Theory]
+ [InlineData(""" "host" """)]
+ [InlineData("[]")]
+ [InlineData("""[{"hostname": "host"}]""")]
+ [InlineData("1")]
+ [InlineData("true")]
+ public void RejectsMetadataThatIsNotAnObject(string metadata)
+ {
+ AssertInvalid(ParseText(Body(metadata, "")));
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §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.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\"")]) },
+ { "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"}""" },
+ };
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Theory]
+ [MemberData(nameof(MetadataBeyondTheLimits))]
+ 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 §conventions
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ 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 §conventions
+ [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""")]
+ [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(metadata, extraMembers)), form);
+ }
+
+ // 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 §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)
+ {
+ AssertInvalid(ParseText(prefix + Body(ValidMetadata, "") + suffix), form);
+ }
+
+ // 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()
+ {
+ AssertInvalid(ParseText(Body(ValidMetadata, "")[..^1]));
+ }
+
+ // 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 §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" """)]
+ [InlineData("42")]
+ [InlineData("null")]
+ [InlineData("true")]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void RejectsBodyThatIsNotAnObject(string json)
+ {
+ AssertInvalid(ParseText(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 §conventions
+ [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.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 §conventions
+ // oracle: spec RFC 3629 §4
+ [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)..]]));
+ }
+
+ // 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 §conventions
+ [Fact]
+ public void AcceptsNestingOf64Levels()
+ {
+ AssertValid(ParseText(Body(ValidMetadata, NestedArrays(63))));
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [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);
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ 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);
+ // 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);
+ // 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));
+ }
+
+ private static string NestedArrays(int depth)
+ {
+ return $"\"extra\": {new string('[', depth)}{new string(']', depth)}";
+ }
+}
+
+///
+/// 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
+{
+ 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);
+ }
+
+ // 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()
+ {
+ CsrVector vector = TestVectors.Instance.Csr["valid"];
+ Assert.NotNull(vector.PublicKeySec1);
+ byte[] expected = [.. CertificateSigningRequestTests.P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1];
+
+ CertificateSigningRequest csr = AssertValid(ParseText(Body(ValidMetadata, ""))).Csr!;
+
+ Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray());
+ // 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 §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")]
+ public void RejectsMissingCsr(string? csr)
+ {
+ AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata)));
+ }
+
+ // A CSR is DER encoded base64 in a JSON string.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [Theory]
+ [InlineData("1")]
+ [InlineData("{}")]
+ [InlineData("[]")]
+ [InlineData("true")]
+ public void RejectsCsrThatIsNotAString(string csr)
+ {
+ AssertInvalid(ParseText(BodyWithCsr(csr, ValidMetadata)));
+ }
+
+ // 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 §conventions
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ public void RejectsCsrWithoutPadding(int octetsInFinalQuantum)
+ {
+ string canonical = CanonicalCsr(octetsInFinalQuantum);
+
+ AssertOnlyCanonicalCsrIsValid(canonical, canonical.TrimEnd('='));
+ }
+
+ // 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 §conventions
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ public void RejectsCsrWithNonZeroPadBit(int octetsInFinalQuantum)
+ {
+ const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ string canonical = CanonicalCsr(octetsInFinalQuantum);
+ int last = canonical.TrimEnd('=').Length - 1;
+ char withPadBit = Alphabet[Alphabet.IndexOf(canonical[last]) | 1];
+
+ 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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ // oracle: spec RFC 4648 §3.3
+ [Theory]
+ [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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [Fact]
+ public void RejectsCsrInTheBase64UrlAlphabet()
+ {
+ string canonical = CanonicalCsr(1);
+
+ AssertOnlyCanonicalCsrIsValid(canonical, canonical.Replace('+', '-').Replace('/', '_'));
+ }
+
+ // 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()
+ {
+ string csr = Convert.ToBase64String(TestVectors.Instance.Csr["bad_self_signature"].Csr);
+
+ AssertInvalid(ParseText(BodyWithCsr(Quote(csr), ValidMetadata)));
+ }
+
+ // A CSR is a DER SEQUENCE, which no empty byte string encodes.
+ // oracle: spec RFC 2986 §4
+ [Fact]
+ public void RejectsEmptyCsr()
+ {
+ AssertInvalid(ParseText(BodyWithCsr(Quote(""), ValidMetadata)));
+ }
+
+ // CONTRACT.agent-identity.md §1 makes csr sent twice invalid_request.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [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 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)
+ {
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ 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)
+ {
+ 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
+{
+ // A check-in body has no csr, so a csr member is an unknown member.
+ // 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()
+ {
+ 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.Tests/Bodies/ResponseBodiesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs
new file mode 100644
index 0000000000..008ee77d60
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Bodies/ResponseBodiesTests.cs
@@ -0,0 +1,344 @@
+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.agent-identity.md §5,
+/// each parsed with the BCL and checked against the contract.
+///
+public class ResponseBodiesTests
+{
+ // 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";
+ private const string NilIdText = "00000000-0000-0000-0000-000000000000";
+
+ 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);
+
+ // Each root has `certificate`, `thumbprint`, `not_before` and `not_after`, all strings.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor
+ [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.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 §conventions
+ // oracle: reference docs/agent-identity/test-vectors.json root
+ [Fact]
+ public void TrustAnchorGivesTheDerAndThumbprintOfTheRoot()
+ {
+ byte[] body = ResponseBodies.TrustAnchor([VectorRoot]);
+
+ 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());
+ }
+
+ // 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 §conventions
+ // oracle: spec RFC 3339 §5.6
+ [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(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());
+ }
+
+ // The response lists one root normally and two during a rotation.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor
+ [Fact]
+ public void TrustAnchorListsEveryRoot()
+ {
+ using X509Certificate2 newRoot = SelfSignedRoot(Now.AddDays(-1), Now.AddYears(10));
+
+ byte[] body = ResponseBodies.TrustAnchor([VectorRoot, newRoot]);
+
+ 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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor
+ [Fact]
+ public void TrustAnchorRejectsNoRoots()
+ {
+ ArgumentException exception = Assert.Throws(() => ResponseBodies.TrustAnchor([]));
+
+ // oracle: spec Framework Design Guidelines §Using Standard Exception Types
+ Assert.Equal("roots", exception.ParamName);
+ }
+
+ // Each root has a certificate, which a null root lacks.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor
+ [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 §conventions
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll
+ [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, 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 §enroll
+ [Theory]
+ [InlineData(NilIdText, IdentityIdText, "authorityId")]
+ [InlineData(AuthorityIdText, NilIdText, "identityId")]
+ public void EnrollRejectsANilId(string authorityId, string identityId, string parameter)
+ {
+ 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);
+ }
+
+ // `200`: `{ "certificate_chain": [ ... ] }`.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew
+ [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);
+ }
+
+ // `config` and `renewal_requested`, the identity's request-renewal flag as a JSON boolean.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in
+ [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.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 §conventions
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [Theory]
+ [MemberData(nameof(ChainBodyNames))]
+ public void ChainIsWrittenLeafFirst(string endpoint)
+ {
+ byte[] body = ChainBodies[endpoint]([DeviceA, VectorRoot]);
+
+ JsonElement[] chain = Chain(body);
+ Assert.Equal(
+ [TestVectors.Instance.Key("device-a").Certificate, TestVectors.Instance.Root.Certificate],
+ chain.Select(TestJson.CanonicalBase64));
+ }
+
+ // A chain has any length, so the leaf alone is a chain too.
+ // 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)
+ {
+ byte[] body = ChainBodies[endpoint]([DeviceA]);
+
+ JsonElement[] chain = Chain(body);
+ Assert.Equal([TestVectors.Instance.Key("device-a").Certificate], chain.Select(TestJson.CanonicalBase64));
+ }
+
+ // 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 §certificates
+ [Theory]
+ [MemberData(nameof(ChainBodyNames))]
+ 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 §certificates
+ [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]));
+
+ // 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 §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)
+ {
+ ArgumentException exception = Assert.Throws(() => ChainBodies[endpoint]([]));
+
+ // oracle: spec Framework Design Guidelines §Using Standard Exception Types
+ Assert.Equal("certificateChain", exception.ParamName);
+ }
+
+ // Each certificate of a chain is written as its DER, which a null certificate lacks.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [Theory]
+ [MemberData(nameof(ChainBodyNames))]
+ 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 §enroll
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in
+ [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());
+ }
+
+ // 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 §enroll
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in
+ [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"]);
+ }
+
+ 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);
+ 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/Certificates/CertificateSigningRequestTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs
new file mode 100644
index 0000000000..89d3b97990
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/CertificateSigningRequestTests.cs
@@ -0,0 +1,832 @@
+using System.Formats.Asn1;
+using System.Numerics;
+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 }.
+ 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.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 =>
+ {
+ 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();
+ },
+
+ // The agent's CSR carries an empty attribute set, A0 00.
+ // 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 §certificates
+ ["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);
+ },
+
+ // 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 §certificates
+ ["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);
+ },
+
+ // 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);
+ },
+ };
+
+ // 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.
+ // 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.
+ // oracle: spec X.690 §10.1
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ ["CertificationRequestInfo with a longer length"] = WithNewKey(key =>
+ {
+ byte[] spki = key.ExportSubjectPublicKeyInfo();
+ byte[] info = TestCsr.TlvWithLongerLength(
+ 0x30,
+ [.. TestCsr.Integer(0), .. TestCsr.EmptySubject, .. spki, .. TestCsr.EmptyAttributes]);
+ return TestCsr.Request(info, key);
+ }),
+ // oracle: spec X.690 §10.1
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ ["version with a longer length"] = WithNewKey(key =>
+ {
+ 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 with a longer length"] = WithNewKey(key =>
+ {
+ byte[] spki = TestCsr.TlvWithLongerLength(
+ 0x30,
+ [.. TestCsr.P256Algorithm(), .. TestCsr.BitString(TestCsr.UncompressedPoint(key))]);
+ return TestCsr.Request(TestCsr.RequestInfo(spki), key);
+ }),
+
+ // 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
+ ["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),
+ TestCsr.EmptySubject,
+ key.ExportSubjectPublicKeyInfo(),
+ TestCsr.EmptyAttributes,
+ TestCsr.Null);
+ return TestCsr.Request(info, key);
+ }),
+ // oracle: spec RFC 2986 §4.2
+ ["CertificationRequest with an extra element"] = WithNewKey(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.
+ // oracle: spec RFC 5480 §2
+ ["AlgorithmIdentifier of the key with an extra element"] = WithNewKey(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);
+ }),
+ // oracle: spec RFC 5480 §2
+ ["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);
+ }),
+
+ // The key is a P-256 key.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ ["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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ ["P-256 point named secp256k1"] = WithNewKey(key =>
+ {
+ 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.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.
+ // 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.
+ // 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 point"] = WithNewKey(key =>
+ {
+ ECParameters parameters = key.ExportParameters(includePrivateParameters: false);
+ 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 point"] = WithNewKey(key =>
+ {
+ byte[] point = TestCsr.UncompressedPoint(key);
+ point[0] = (byte)(0x06 | (point[^1] & 1));
+ 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
+ ["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"] = WithNewKey(key => RequestWithPoint(key, [0x00])),
+
+ // 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 with unused bits"] = () =>
+ {
+ 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);
+ },
+
+ // 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 with unused bits"] = WithNewKey(key =>
+ {
+ (byte[] info, byte[] signature) = SignedInfoWhere(key, (_, s) => (s[^1] & 1) == 0);
+ return TestCsr.Sequence(
+ info,
+ TestCsr.SignatureAlgorithm(TestCsr.EcdsaWithSha256),
+ TestCsr.BitString(signature, unusedBits: 1));
+ }),
+
+ // 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);
+ }),
+
+ // 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);
+ }),
+
+ // 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);
+ }),
+
+ // 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 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
+ [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());
+ }
+
+ // 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()
+ {
+ CsrVector vector = TestVectors.Instance.Csr["valid"];
+ Assert.NotNull(vector.PublicKeySec1);
+ byte[] expected = [.. P256SubjectPublicKeyInfoPrefix, .. vector.PublicKeySec1];
+
+ CertificateSigningRequest csr = AssertValid(CertificateSigningRequest.Validate(vector.Csr));
+
+ Assert.Equal(expected, csr.SubjectPublicKeyInfo.ToArray());
+ // 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 §certificates
+ [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());
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [Theory]
+ [MemberData(nameof(RequestWithIgnoredContentsNames))]
+ public void AcceptsAnySubjectAndAttributes(string request)
+ {
+ using ECDsa key = NewKey();
+
+ CertificateSigningRequest csr =
+ AssertValid(CertificateSigningRequest.Validate(RequestsWithIgnoredContents[request](key)));
+
+ 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"),
+ // 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,
+ 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),
+ // 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(
+ 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
+ [Theory]
+ [MemberData(nameof(InvalidRequestNames))]
+ public void RejectsInvalidRequest(string request)
+ {
+ AssertInvalid(CertificateSigningRequest.Validate(InvalidRequests[request]()));
+ }
+
+ private static CertificateSigningRequest AssertValid(CsrValidationResult result)
+ {
+ Assert.True(result.Succeeded, result.FailureDetail);
+ Assert.Null(result.Error);
+
+ return result.Csr;
+ }
+
+ // 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);
+ // 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 §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));
+ }
+
+ // 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;
+ // oracle: reference docs/agent-identity/test-vectors.json csr.valid
+ 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.
+ // oracle: spec X.690 §10.1
+ Assert.Equal(valid, TestCsr.Sequence(contents));
+
+ return contents;
+ }
+
+ 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 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);
+
+ 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)
+ {
+ 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());
+ }
+
+ // 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
new file mode 100644
index 0000000000..7ecd246a77
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/IdentityCertificateIssuerTests.cs
@@ -0,0 +1,788 @@
+using System.Formats.Asn1;
+using System.Numerics;
+using System.Reflection;
+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.agent-identity.md §4 and RFC 5280.
+public sealed class IdentityCertificateIssuerTests : IDisposable
+{
+ // 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.
+ 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";
+
+ // 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);
+
+ // 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);
+
+ // A token that can create an identity.
+ // 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, 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),
+ };
+
+ 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, 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.
+ private string NewIdentityIdText => Assert.NotNull(_creating.NewIdentityId).ToString("D");
+
+ 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();
+ }
+
+ // The certificate is for the P-256 key of the CSR.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [Fact]
+ public void CertifiesTheKeyOfTheCsr()
+ {
+ using X509Certificate2 leaf = Issue();
+
+ Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo());
+ }
+
+ // CN=, and the root's subject as the issuer.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [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(NewIdentityIdText, 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 §certificates
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ // oracle: spec RFC 5280 §4.2.1.6
+ [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:{NewIdentityIdText}", uri);
+ Assert.False(names.HasData);
+ Assert.False(extension.Critical);
+ }
+
+ // EKU clientAuth.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [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));
+ }
+
+ // CONTRACT.agent-identity.md §4: KeyUsage digitalSignature; RFC 5280 §4.2.1.3: marked critical.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [Fact]
+ public void KeyUsageIsCriticalDigitalSignatureOnly()
+ {
+ using X509Certificate2 leaf = Issue();
+ 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);
+ }
+
+ // 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()
+ {
+ 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.False(authority.Critical);
+ }
+
+ // A subject key identifier, in a non-critical extension.
+ // oracle: spec RFC 5280 §4.2.1.2
+ [Fact]
+ public void SubjectKeyIdentifierIsNotCritical()
+ {
+ using X509Certificate2 leaf = Issue();
+ X509SubjectKeyIdentifierExtension extension =
+ Assert.IsType(leaf.Extensions[SubjectKeyIdentifierOid]);
+
+ Assert.False(extension.Critical);
+ }
+
+ // 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()
+ {
+ using X509Certificate2 leaf = Issue();
+
+ IEnumerable extensions = leaf.Extensions.Select(extension => extension.Oid?.Value);
+ string[] expected =
+ [
+ TestEnrollmentUrl.Oid,
+ SubjectKeyIdentifierOid,
+ KeyUsageOid,
+ SubjectAlternativeNameOid,
+ AuthorityKeyIdentifierOid,
+ ExtendedKeyUsageOid,
+ ];
+
+ Assert.Equal(expected, extensions.Order(StringComparer.Ordinal));
+ // oracle: spec RFC 5280 §4.1.2.1
+ 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]
+ [MemberData(nameof(SerialNumberDrawNames))]
+ public void SerialNumberIsAPositiveIntegerOfAtMost20Octets(string draws)
+ {
+ using X509Certificate2 leaf = Issue(random: new ScriptedRandomNumberGenerator(SerialNumberDraws[draws]));
+
+ byte[] serialNumber = SerialNumber(leaf);
+ Assert.True(new BigInteger(serialNumber, isUnsigned: false, isBigEndian: true) > 0);
+ Assert.InRange(serialNumber.Length, 1, 20);
+ }
+
+ // 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 SerialNumberIsTheRandomOctetsWithTheTopBitCleared()
+ {
+ byte[] draw = [.. Enumerable.Repeat((byte)0xFF, 16)];
+
+ using X509Certificate2 leaf = Issue(random: new ScriptedRandomNumberGenerator(draw));
+
+ Assert.Equal([0x7F, .. draw[1..]], SerialNumber(leaf));
+ }
+
+ // The lifetime is a server setting.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [Fact]
+ public void NotAfterIsNotBeforePlusTheLifetime()
+ {
+ using X509Certificate2 leaf = Issue(lifetime: TimeSpan.FromDays(30));
+
+ Assert.Equal(TimeSpan.FromDays(30), leaf.NotAfter.ToUniversalTime() - leaf.NotBefore.ToUniversalTime());
+ }
+
+ // The lifetime is capped at the issuing root's notAfter.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [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());
+ }
+
+ // The lifetime is a server setting with a default of 90 days.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ [Fact]
+ public void DefaultLifetimeIs90Days()
+ {
+ Assert.Equal(TimeSpan.FromDays(90), IdentityCertificateIssuer.DefaultLifetime);
+ }
+
+ // 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 §certificates
+ [Fact]
+ public void ReadsTheClockOnce()
+ {
+ using X509Certificate2 leaf = Issue(clock: new SteppingTimeProvider(Now, Now.AddHours(1), Now.AddHours(2)));
+
+ Assert.Equal(Lifetime, leaf.NotAfter.ToUniversalTime() - leaf.NotBefore.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 §certificates
+ [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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [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={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]);
+ 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.
+ // oracle: spec RFC 5280 §4.1.2.5
+ // oracle: spec Framework Design Guidelines §Using Standard Exception Types
+ [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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates
+ // oracle: spec Framework Design Guidelines §Validating Arguments
+ [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));
+ }
+
+ // 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()
+ {
+ using X509Certificate2 root = CreateRoot(Now.AddDays(-1), Now.AddYears(10), withSubjectKeyIdentifier: false);
+
+ 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);
+
+ 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
+ // 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(Curves[curve]);
+
+ 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);
+ },
+ // 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,
+ [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.
+ // 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
+ // 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()
+ {
+ // 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);
+
+ using X509Certificate2 leaf = IssueForRenewal(renewing);
+
+ Assert.Equal($"CN={IdentityIdText}", leaf.Subject);
+ Assert.Equal(_agentKey.ExportSubjectPublicKeyInfo(), leaf.PublicKey.ExportSubjectPublicKeyInfo());
+ }
+
+ // 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 §renew
+ // oracle: spec Framework Design Guidelines §Validating Arguments
+ [Theory]
+ [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(() => 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))]
+ 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);
+ }
+
+ // 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, string enrollmentUrl = TokenUrl)
+ {
+ return IdentityCertificateIssuer.Issue(
+ renewing,
+ enrollmentUrl,
+ _csr,
+ new TestRootSigner(_root, _rootKey),
+ Lifetime,
+ new FixedTimeProvider(Now),
+ _random);
+ }
+
+ private X509Certificate2 Issue(
+ EnrollmentDecision? creating = null,
+ TimeSpan? lifetime = null,
+ TimeProvider? clock = null,
+ RandomNumberGenerator? random = null,
+ X509Certificate2? root = null,
+ CertificateSigningRequest? csr = null)
+ {
+ return IdentityCertificateIssuer.Issue(
+ creating ?? _creating,
+ 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: 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)
+ {
+ AsnReader certificateReader = new AsnReader(certificate.RawData, AsnEncodingRules.DER).ReadSequence();
+ AsnReader tbsCertificate = certificateReader.ReadSequence();
+ _ = tbsCertificate.ReadEncodedValue();
+
+ 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;
+
+ public X509SignatureGenerator SignatureGenerator => X509SignatureGenerator.CreateForECDsa(key);
+ }
+}
diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs
new file mode 100644
index 0000000000..0021dbf652
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Certificates/RenewalRulesTests.cs
@@ -0,0 +1,88 @@
+namespace Devolutions.AgentIdentity.Tests;
+
+///
+/// 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.
+///
+public class RenewalRulesTests
+{
+ private static readonly Guid OtherIdentityId = TestVectors.Instance.Key("device-b").IdentityId;
+
+ // 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()
+ {
+ ["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);
+
+ // 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 §renew
+ [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);
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew
+ [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);
+ // 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
+ Assert.False(string.IsNullOrEmpty(decision.FailureDetail));
+ }
+
+ // 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 §renew
+ [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);
+ }
+}
diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs
new file mode 100644
index 0000000000..51ab0cb70f
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeTests.cs
@@ -0,0 +1,994 @@
+using Devolutions.Agent.Channel.V1;
+
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+
+using Grpc.Core;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+///
+/// 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
+{
+ 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.
+ // 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
+ {
+ 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,
+ // by the name of the message failure that each makes of a valid Hello.
+ private static readonly Dictionary> LaterFailures = new()
+ {
+ ["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()
+ {
+ ["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.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["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.
+ private static readonly Dictionary>>
+ HandshakeEndsBeforeNotAfter = new()
+ {
+ ["a valid Hello"] = (opening, hello) => opening.VerifyHelloAsync(hello),
+ ["no Hello in time"] = (opening, _) => opening.HelloTimedOutAsync(),
+ ["the first message could not be decoded"] = (opening, _) => opening.FirstMessageUnreadableAsync(),
+ };
+
+ public static TheoryData FailureNames => new(Failures.Keys);
+
+ public static TheoryData InvalidMetadataEntryNames => new(InvalidMetadataEntries.Keys);
+
+ public static TheoryData ScenarioNames => new(Scenarios.Keys);
+
+ // Each wrong answer of the registry, to each of the 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");
+ cases.Add(answer, "the first message could not be decoded");
+ }
+
+ return cases;
+ }
+ }
+
+ // Each pair of failures that the handshake checks one after the other.
+ public static TheoryData FailurePairNames
+ {
+ get
+ {
+ TheoryData cases = new();
+
+ 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);
+
+ private static byte[] ValidProof => TestVectors.Instance.ChannelProof.Case("valid").Proof;
+
+ // 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()
+ {
+ // 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,
+ 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)
+ {
+ // 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,
+ StatusCode.Unauthenticated),
+ };
+
+ // 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
+ .Select(failure => KeyValuePair.Create(
+ failure.Key,
+ new Failure(
+ 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(
+ new Scenario
+ {
+ FirstMessage = opening => Altered(
+ VectorChannel.ValidHelloMessage(opening),
+ LaterFailures["a Hello with invalid metadata"]),
+ },
+ AgentIdentityErrorCode.InvalidRequest,
+ StatusCode.InvalidArgument)))
+ .ToDictionary();
+
+ // 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 §channel-sequence
+ ["the first message could not be decoded"] = new() { FirstMessageUnreadable = true },
+ };
+
+ // 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)
+ .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.
+ 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 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
+ {
+ 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);
+
+ foreach (string end in NoHelloEnds.Keys)
+ {
+ AddOrderedPairs(pairs, [.. Closures.Keys.Select(closure => WithClosure(end, closure))], [end]);
+ }
+
+ 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;
+
+ 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 =>
+ TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter;
+
+ // 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
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [Theory]
+ [MemberData(nameof(FailureNames))]
+ public async Task FailureClosesTheStreamWithItsCode(string failure)
+ {
+ (Scenario scenario, AgentIdentityErrorCode expectedCode, StatusCode expectedStatus) = Failures[failure];
+
+ ChannelHandshakeResult result = await HandshakeAsync(scenario);
+
+ AssertFailed(result, expectedCode, expectedStatus);
+ }
+
+ // 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 §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);
+ }
+
+ // 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,
+ // 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.
+ // 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.
+ // 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(FailurePairNames))]
+ 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);
+ }
+
+ // 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`, in no stated order.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ [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);
+ // 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
+ .Select(entry => (entry.Key, entry.Value))
+ .OrderBy(entry => entry.Key, StringComparer.Ordinal));
+ }
+
+ // 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.
+ // 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]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [InlineData(CertificateStatus.Pending, CertificateStatus.Pending)]
+ // 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 §channel-termination
+ [InlineData(CertificateStatus.Current, CertificateStatus.Retired)]
+ public async Task IdentityHasTheCertificateStatusAtTheEndOfTheHandshake(
+ CertificateStatus opened,
+ CertificateStatus atHandshake)
+ {
+ VectorAuthority authority = new();
+ 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(atHandshake, result.Identity.CertificateStatus);
+ }
+
+ // 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 §channel-termination
+ [Fact]
+ public async Task RevocationWinsOverARetiredCertificate()
+ {
+ ChannelHandshakeResult result = await HandshakeAsync(
+ new Scenario { Status = CertificateStatus.Retired, IdentityRevoked = true });
+
+ AssertFailed(result, AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied);
+ }
+
+ // 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 §channel-termination
+ [Theory]
+ [MemberData(nameof(ScenarioNames))]
+ public async Task HandshakeReadsTheRegistryForTheOpeningCertificate(string scenario)
+ {
+ VectorAuthority openingOnly = new();
+ await VectorChannel.OpenAsync(openingOnly);
+ VectorAuthority authority = new();
+
+ await HandshakeAsync(Scenarios[scenario], authority);
+
+ // 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));
+ }
+
+ // 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 VectorChannel.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`,
+ // 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)
+ {
+ VectorAuthority authority = new();
+ ChannelOpening opening = await VectorChannel.OpenAsync(authority);
+ authority.Registry.Register(opening.CertificateThumbprint, WrongRegistryAnswers[answer]());
+
+ await Assert.ThrowsAsync(async () => await HandshakeEnds[call](opening));
+ }
+
+ // 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 §channel-sequence
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ [Fact]
+ public async Task HelloDecidesTheStreamWhenTheTimeoutFiresDuringItsLookup()
+ {
+ 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);
+
+ _ = opening.HelloTimedOutAsync();
+ answer.SetResult();
+
+ Assert.True((await verifying).Succeeded);
+ }
+
+ // 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.
+ // 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 §channel-sequence
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ [Fact]
+ public async Task TimeoutDecidesTheStreamWhenAHelloArrivesDuringItsLookup()
+ {
+ 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 AssertYieldsNoIdentityAsync(() => verifying);
+ 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 §channel-sequence
+ [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);
+ }
+
+ // 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 §channel-sequence
+ // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a
+ [Fact]
+ public async Task ValidHelloAuthenticatesTheIdentityOfTheOpeningCertificate()
+ {
+ RegisteredCertificateVector registered =
+ TestVectors.Instance.HttpSignature.RegisteredCertificate(TestVectors.Instance.ChannelProof.Device);
+ ChannelOpening opening = await VectorChannel.OpenAsync();
+
+ ChannelHandshakeResult result = await opening.VerifyHelloAsync(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);
+ // 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 §metadata
+ [Fact]
+ public async Task ValidHelloYieldsItsMetadata()
+ {
+ ChannelOpening opening = await VectorChannel.OpenAsync();
+
+ ChannelHandshakeResult result = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening));
+
+ Assert.True(result.Succeeded, result.FailureDetail);
+ Assert.Equal(
+ VectorChannel.ValidMetadata.OrderBy(entry => entry.Key, StringComparer.Ordinal),
+ result.Metadata.OrderBy(entry => entry.Key, StringComparer.Ordinal));
+ }
+
+ // 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 §metadata
+ [Fact]
+ public async Task HelloWithoutMetadataSucceeds()
+ {
+ ChannelOpening opening = await VectorChannel.OpenAsync();
+ AgentMessage hello = VectorChannel.ValidHelloMessage(opening);
+ hello.Hello.Metadata.Clear();
+
+ ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello);
+
+ Assert.True(result.Succeeded, result.FailureDetail);
+ Assert.Empty(result.Metadata);
+ }
+
+ // 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 §channel-sequence
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [Fact]
+ public async Task WelcomeAnswersTheHello()
+ {
+ ChannelOpening opening = await VectorChannel.OpenAsync();
+ AgentMessage hello = VectorChannel.ValidHelloMessage(opening);
+ hello.Id = NewId().ToUpperInvariant();
+
+ ChannelHandshakeResult result = await opening.VerifyHelloAsync(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);
+ }
+
+ // 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 §channel-service
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence
+ [Fact]
+ public async Task WelcomeCarriesTheServerTime()
+ {
+ 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);
+ Assert.Equal(Timestamp.FromDateTimeOffset(answered), result.Welcome.Welcome.ServerTime);
+ }
+
+ // 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 §channel-sequence
+ [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 = await opening.VerifyHelloAsync(hello);
+
+ Assert.True(result.Succeeded, result.FailureDetail);
+ }
+
+ // 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 §signatures
+ [Fact]
+ public async Task HandshakeSucceedsAtTheCertificateNotAfter()
+ {
+ (ChannelOpening opening, AgentMessage hello) = await OpenBeforeNotAfterAsync(DeviceANotAfter);
+
+ ChannelHandshakeResult result = await opening.VerifyHelloAsync(hello);
+
+ Assert.True(result.Succeeded, result.FailureDetail);
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service
+ Assert.Equal(Timestamp.FromDateTimeOffset(DeviceANotAfter), result.Welcome.Welcome.ServerTime);
+ }
+
+ // 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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures
+ [Theory]
+ // 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 §channel-sequence
+ [InlineData("no Hello in time", 0, AgentIdentityErrorCode.SignatureInvalid)]
+ // 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 §channel-sequence
+ [InlineData("the first message could not be decoded", 0, AgentIdentityErrorCode.SignatureInvalid)]
+ // 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,
+ long ticksAfterNotAfter,
+ AgentIdentityErrorCode expectedCode)
+ {
+ (ChannelOpening opening, AgentMessage hello) =
+ await OpenBeforeNotAfterAsync(DeviceANotAfter.AddTicks(ticksAfterNotAfter));
+
+ ChannelHandshakeResult result = await HandshakeEndsBeforeNotAfter[end](opening, hello);
+
+ AssertFailed(result, expectedCode, StatusCode.Unauthenticated);
+ }
+
+ // Step 4: the server waits 10 s for the Hello.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence
+ [Fact]
+ public void HelloTimeoutIs10Seconds()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(10), ChannelHandshake.HelloTimeout);
+ }
+
+ // Step 4: the Hello must arrive within 10 s of the opening, 10 s included.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence
+ [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 = await opening.VerifyHelloAsync(VectorChannel.ValidHelloMessage(opening));
+
+ Assert.True(result.Succeeded, result.FailureDetail);
+ }
+
+ // 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 §channel-sequence
+ [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 = await opening.VerifyHelloAsync(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.
+ // 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 §channel-sequence
+ [Theory]
+ [MemberData(nameof(HandshakeEndNames))]
+ public async Task ValidHelloAfterTheHandshakeEndedYieldsNoIdentity(string end)
+ {
+ ChannelOpening opening = await VectorChannel.OpenAsync();
+ await HandshakeEnds[end](opening);
+
+ 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.
+ // 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")]
+ [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,
+ string expectedCode)
+ {
+ DateTimeOffset serverTime = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero);
+ ChannelOpening opening = await VectorChannel.OpenAsync();
+ ChannelHandshakeResult result = await HandshakeEnds[end](opening);
+ Assert.False(result.Succeeded);
+
+ RpcException exception = result.Error.ToRpcException(serverTime);
+
+ Assert.Equal(expectedStatus, exception.StatusCode);
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ Assert.Equal(
+ [("error-code", expectedCode), ("server-time", "2026-09-24T12:00:00Z")],
+ 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.
+ private static AgentIdentityError 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);
+ // 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 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;
+ }
+
+ // 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);
+ 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 ends with no Hello, and that the closure ends first.
+ private static string WithClosure(string end, string closure)
+ {
+ return $"{end}, and {closure}";
+ }
+
+ // 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(
+ Scenario scenario,
+ VectorAuthority? authority = null)
+ {
+ authority ??= new VectorAuthority();
+ DateTimeOffset opened = VectorChannel.OpeningTime;
+ 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);
+ }
+
+ if (scenario.FirstMessageUnreadable)
+ {
+ return await opening.FirstMessageUnreadableAsync();
+ }
+
+ 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,
+ // 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));
+
+ ChannelOpening opening = await authority.OpenChannelAsync();
+
+ return (opening, TestAuthority.ValidHello(opening));
+ }
+
+ 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; }
+
+ // 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)
+ {
+ return new Scenario
+ {
+ CertificateRemoved = CertificateRemoved || later.CertificateRemoved,
+ IdentityRevoked = IdentityRevoked || later.IdentityRevoked,
+ Status = Status,
+ HandshakeTime = HandshakeTime ?? later.HandshakeTime,
+ FirstMessage = later.FirstMessage,
+ FirstMessageUnreadable = later.FirstMessageUnreadable,
+ };
+ }
+ }
+
+ // 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);
+ }
+ }
+}
diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs
new file mode 100644
index 0000000000..1e2547da9a
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelHandshakeVectorTests.cs
@@ -0,0 +1,40 @@
+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;
+
+ // 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 §channel-sequence
+ // oracle: reference docs/agent-identity/test-vectors.json channel_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 =
+ 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);
+ }
+
+ // 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 §channel-sequence
+ // oracle: reference docs/agent-identity/test-vectors.json channel_proof.message_hex
+ [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/Channel/ChannelMessagesTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs
new file mode 100644
index 0000000000..e8a3bdd457
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelMessagesTests.cs
@@ -0,0 +1,116 @@
+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.agent-identity.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),
+ };
+
+ // 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.agent-identity.md §1 and §7.3: envelope ids are lowercase hyphenated UUIDs.
+ // 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)
+ {
+ ServerMessage built = Messages[message]();
+
+ Assert.Matches(TestJson.LowercaseUuidPattern, built.Id);
+ }
+
+ // 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 §channel-sequence
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [Theory]
+ [MemberData(nameof(MessageNames))]
+ public void EachMessageHasItsOwnId(string message)
+ {
+ Assert.NotEqual(Messages[message]().Id, Messages[message]().Id);
+ }
+
+ // 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 §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()
+ {
+ 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.agent-identity.md §8.3 sends "rotation" at the start of a root rotation.
+ // 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 §root-rotation
+ [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);
+ }
+
+ // The streams of a retired certificate get Reconnect{reason:"certificate_rotated"}.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ [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.
+ // 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)]
+ [InlineData("Reconnect", 0)]
+ [InlineData("Reconnect", 2)]
+ public void MessageRejectsAnUndefinedReason(string message, int reason)
+ {
+ ArgumentOutOfRangeException exception =
+ Assert.Throws(() => MessagesWithAReason[message](reason));
+
+ Assert.Equal("reason", exception.ParamName);
+ }
+}
diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs
new file mode 100644
index 0000000000..46664dd714
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Channel/ChannelOpeningTests.cs
@@ -0,0 +1,129 @@
+using System.Security.Cryptography;
+
+using Devolutions.Agent.Channel.V1;
+using Google.Protobuf;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+///
+/// 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
+{
+ 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.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 §channel-sequence
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [Fact]
+ public async Task ChallengeHolds32Bytes()
+ {
+ TestAuthority authority = new(TestAuthority.DefaultNow);
+
+ ChannelOpening opening = await authority.OpenChannelAsync();
+
+ ServerMessage challenge = opening.Challenge;
+ Assert.Equal(ServerMessage.PayloadOneofCase.Challenge, challenge.PayloadCase);
+ Assert.Equal(32, challenge.Challenge.Challenge_.Length);
+ Assert.Matches(UuidPattern, challenge.Id);
+ }
+
+ // 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 §channel-sequence
+ [Fact]
+ public async Task EachOpeningHasItsOwnChallenge()
+ {
+ TestAuthority authority = new(TestAuthority.DefaultNow);
+
+ 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);
+ // 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(),
+ second.Challenge.Challenge.Challenge_.ToByteArray());
+ }
+
+ // 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 §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]
+ 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 VectorAuthority.OpenChannelAsync(authority.Authenticator, connect);
+
+ 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);
+ }
+
+ // 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 §channel-sequence
+ [Fact]
+ public async Task KeptChallengeDoesNotFollowEditsOfTheMessage()
+ {
+ TestAuthority authority = new(TestAuthority.DefaultNow);
+ ChannelOpening opening = await authority.OpenChannelAsync();
+ 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());
+ }
+
+ // 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 §channel-termination
+ // oracle: reference docs/agent-identity/test-vectors.json http_signature.registered.device-a
+ [Fact]
+ public async Task OpeningGivesTheNotAfterOfItsCertificate()
+ {
+ TestAuthority authority = new(TestAuthority.DefaultNow);
+
+ ChannelOpening opening = await authority.OpenChannelAsync();
+
+ Assert.Equal(
+ TestVectors.Instance.HttpSignature.RegisteredCertificate("device-a").NotAfter,
+ opening.CertificateNotAfter);
+ }
+
+ // 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/ConstructionTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs
new file mode 100644
index 0000000000..81676fd9ff
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ConstructionTests.cs
@@ -0,0 +1,57 @@
+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 an unchecked value in its place.
+public class ConstructionTests
+{
+ private static readonly Type[] Validated =
+ [
+ typeof(AgentMetadata),
+ typeof(CertificateSigningRequest),
+ typeof(EnrollRequestBody),
+ typeof(RenewRequestBody),
+ typeof(CheckInRequestBody),
+ typeof(ConfirmRequestBody),
+ ];
+
+ 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)]);
+
+ // oracle: mutant a public IdentityConfig constructor that takes already-serialized JSON
+ [Theory]
+ [MemberData(nameof(CheckedTypes))]
+ 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.
+ // oracle: mutant a public AgentMetadata factory that skips validation
+ [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.Tests/Devolutions.AgentIdentity.Tests.csproj b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj
new file mode 100644
index 0000000000..1e07dbe17f
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Devolutions.AgentIdentity.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ NU1901;NU1902;NU1903;NU1904
+
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
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/Enrollment/EnrollmentAuthorizationTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs
new file mode 100644
index 0000000000..2a2ca95358
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentAuthorizationTests.cs
@@ -0,0 +1,232 @@
+using System.Security.Cryptography;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+public class EnrollmentAuthorizationTests
+{
+ // 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;
+
+ // 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.
+ private static Dictionary FieldLinesWithoutABearerToken
+ {
+ get
+ {
+ Dictionary fieldLines = new()
+ {
+ // (1): `token_invalid` when the `Authorization` header is missing.
+ // 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 §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 §token-rules
+ ["Basic"] = ["Basic " + Token],
+ ["DPoP"] = ["DPoP " + Token],
+ ["Bearers"] = ["Bearers " + Token],
+ ["Bear"] = ["Bear " + Token],
+ ["no scheme"] = [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],
+ ["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`.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules
+ foreach ((string name, string token) in MalformedTokens)
+ {
+ fieldLines.Add(name, ["Bearer " + token]);
+ }
+
+ return fieldLines;
+ }
+ }
+
+ // 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 §enrollment-token
+ 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.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]
+ [InlineData("a bearer token", "Bearer " + Token)]
+ // 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)]
+ // `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 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"}.
+ // 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)]
+ // 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")]
+ [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, name);
+ Assert.Equal(SecretHash, result.SecretHash.ToArray());
+ }
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules
+ [Theory]
+ [MemberData(nameof(FieldLinesWithoutABearerTokenNames))]
+ public void RejectsFieldLinesWithoutABearerToken(string fieldLines)
+ {
+ EnrollmentAuthorizationResult result =
+ EnrollmentAuthorization.ReadBearerToken(FieldLinesWithoutABearerToken[fieldLines]);
+
+ AssertTokenInvalid(result);
+ }
+
+ // 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 §enrollment-token
+ [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(SecretHash, result.SecretHash.ToArray());
+ }
+
+ // 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 §enrollment-token
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules
+ [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);
+ }
+
+ // 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" },
+ new[] { "Bearer dvaet1." + Bag + "." + Secret[..42] + "9" },
+ };
+
+ // 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 §token-rules
+ Assert.False(result.Succeeded);
+ EnrollmentTokenAssert.RevealsNoPart(result.FailureDetail, Bag, Secret);
+ EnrollmentTokenAssert.RevealsNoPart(result.Error.Message, Bag, Secret);
+ EnrollmentTokenAssert.RevealsNoPart($"{result}", Bag, Secret);
+ }
+
+ // 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);
+ }
+
+ // CONTRACT.agent-identity.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);
+ // 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
new file mode 100644
index 0000000000..7d8bf2c739
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentRulesTests.cs
@@ -0,0 +1,417 @@
+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");
+
+ // 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.
+ private static Dictionary KeysButTheReplay => new()
+ {
+ ["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(),
+ };
+
+ // 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), Csr, null, Now),
+ [EnrollmentOutcome.ReturnExistingIdentity] = EnrollmentRules.Decide(
+ Token(),
+ Csr,
+ IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current),
+ Now),
+ };
+
+ // 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
+ [Theory]
+ [InlineData(TimeSpan.TicksPerDay, 3)]
+ // `token_expired` only when `now ≥ expires_at`.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ [InlineData(1L, 3)]
+ [InlineData(TimeSpan.TicksPerDay, 9)]
+ public void CreatesAnIdentityForANewKeyWithAUsableToken(long ticksToExpiry, int identitiesCreated)
+ {
+ EnrollmentTokenState token = new(false, TokenUrl, Now.AddTicks(ticksToExpiry), maxUses: 10, identitiesCreated);
+
+ EnrollmentDecision decision = EnrollmentRules.Decide(token, Csr, null, Now);
+
+ Assert.Equal(EnrollmentOutcome.CreateIdentity, decision.Outcome);
+ Assert.Null(decision.ExistingIdentityId);
+ 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(), Csr, 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(), Csr, null, Now);
+ EnrollmentDecision second = EnrollmentRules.Decide(Token(), Csr, 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 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`.
+ // 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 §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 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)]
+ [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 §token-rules
+ [InlineData(false, 0L, 10, 10, AgentIdentityErrorCode.TokenExpired)]
+ public void RejectsANewKeyWithTheEarliestTokenOutcome(
+ bool revoked,
+ long ticksToExpiry,
+ int maxUses,
+ int identitiesCreated,
+ AgentIdentityErrorCode expected)
+ {
+ EnrollmentTokenState token = new(revoked, TokenUrl, Now.AddTicks(ticksToExpiry), maxUses, identitiesCreated);
+
+ EnrollmentDecision decision = EnrollmentRules.Decide(token, Csr, null, Now);
+
+ AssertRejected(expected, decision);
+ }
+
+ // 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 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)]
+ [InlineData(true, false, false)]
+ [InlineData(false, true, false)]
+ [InlineData(false, false, true)]
+ [InlineData(true, true, true)]
+ public void ReturnsTheIdentityOfAReplayedKeyWhateverTheTokenState(bool revoked, bool expired, bool exhausted)
+ {
+ IssuedKeyMatch keyMatch = IssuedKeyMatch.ForIdentity(IdentityId, true, false, CertificateStatus.Current);
+
+ EnrollmentDecision decision = EnrollmentRules.Decide(Token(revoked, expired, exhausted), Csr, keyMatch, Now);
+
+ Assert.Equal(EnrollmentOutcome.ReturnExistingIdentity, decision.Outcome);
+ Assert.Equal(IdentityId, decision.ExistingIdentityId);
+ Assert.Null(decision.Error);
+ }
+
+ // 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 §token-rules
+ [Theory]
+ [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)]
+ // (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 §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)]
+ [InlineData(true, true, CertificateStatus.Retired, false, true, AgentIdentityErrorCode.IdentityRevoked)]
+ [InlineData(true, false, CertificateStatus.Pending, true, true, AgentIdentityErrorCode.InvalidRequest)]
+ public void RejectsAnIssuedKeyByTheFirstMatchingRule(
+ 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), Csr, keyMatch, Now);
+
+ AssertRejected(expected, decision);
+ }
+
+ // 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 §token-rules
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public void RejectsTheKeyOfADeletedIdentityWithIdentityRevoked(bool expired, bool exhausted)
+ {
+ EnrollmentDecision decision = EnrollmentRules.Decide(
+ Token(expired: expired, exhausted: exhausted),
+ Csr,
+ IssuedKeyMatch.ForDeletedIdentity(),
+ Now);
+
+ AssertRejected(AgentIdentityErrorCode.IdentityRevoked, decision);
+ }
+
+ // (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 RejectsARevokedTokenWithTokenInvalidForAnIssuedKeyButTheReplay(string key)
+ {
+ 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
+ // oracle: spec Framework Design Guidelines §Using Standard Exception Types
+ [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, TokenUrl, Now, maxUses, 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.
+ // 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)]
+ public void KeyMatchRejectsAnUndefinedCertificateStatus(int status)
+ {
+ ArgumentOutOfRangeException exception = Assert.Throws(
+ () => IssuedKeyMatch.ForIdentity(IdentityId, true, false, (CertificateStatus)status));
+
+ Assert.Equal("status", exception.ParamName);
+ }
+
+ // 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!, 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);
+ Assert.Equal(expected, decision.Error?.Code);
+ Assert.Null(decision.ExistingIdentityId);
+ // 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 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(
+ revoked,
+ TokenUrl,
+ expiresAt: expired ? Now : Now.AddDays(1),
+ maxUses: 10,
+ identitiesCreated: exhausted ? 10 : 3);
+ }
+}
diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenAssert.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenAssert.cs
new file mode 100644
index 0000000000..68a334c129
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/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/Enrollment/EnrollmentTokenTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs
new file mode 100644
index 0000000000..ba6c25349f
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/EnrollmentTokenTests.cs
@@ -0,0 +1,279 @@
+using System.Buffers.Text;
+using System.Security.Cryptography;
+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";
+
+ // `..`, where `secret` is base64url of 32 random bytes.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token
+ [Fact]
+ public void CreateEncodesTheGeneratorBytesAsTheSecret()
+ {
+ EnrollmentToken token =
+ EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(SecretBytes));
+
+ Assert.StartsWith("dvaet1.", token.Value, StringComparison.Ordinal);
+ Assert.EndsWith("." + SecretText, token.Value, StringComparison.Ordinal);
+ }
+
+ // 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
+ [Fact]
+ public void SecretHashIsTheSha256OfTheSecretBytes()
+ {
+ EnrollmentToken token =
+ EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(SecretBytes));
+
+ Assert.Equal(SHA256.HashData(SecretBytes), token.SecretHash.ToArray());
+ }
+
+ // 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 §enrollment-token
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions
+ [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 ScriptedRandomNumberGenerator(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));
+ }
+ }
+
+ // The authority stores SecretHash when it creates a token,
+ // and finds the token of an enroll request by the hash that ReadBearerToken returns.
+ // 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]
+ 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 ScriptedRandomNumberGenerator(bytes));
+ byte[] expected = SHA256.HashData(bytes);
+
+ EnrollmentAuthorizationResult result =
+ EnrollmentAuthorization.ReadBearerToken(["Bearer " + token.Value]);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(expected, token.SecretHash.ToArray());
+ Assert.Equal(expected, result.SecretHash.ToArray());
+ }
+ }
+ }
+
+ // `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 §enrollment-token
+ [Theory]
+ [InlineData("https://host/dvls")]
+ [InlineData("https://host")]
+ public void CreatePutsTheProductBaseUrlInTheBag(string productBaseUrl)
+ {
+ 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
+ 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: `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/")]
+ [InlineData(" https://host/dvls\t")]
+ public void ProductBaseUrlIsTheUOfTheBag(string productBaseUrl)
+ {
+ EnrollmentToken token = EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes));
+
+ 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`,
+ // 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 §enrollment-token
+ [InlineData("https://host/dvls/", "https://host/dvls")]
+ // 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 §enrollment-token
+ [InlineData("https://host/", "https://host")]
+ // White space around a URI is not part of it.
+ // oracle: spec RFC 3986 §Appendix C
+ [InlineData(" https://host/dvls\t", "https://host/dvls")]
+ // 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 CreateRejectsOrNormalizesAUrlNotInTheFormOfU(string productBaseUrl, string normalized)
+ {
+ Creation creation = CreateFrom(productBaseUrl);
+
+ Assert.Contains(creation, (Creation[])[Creation.Holding(normalized), Creation.Rejection]);
+ }
+
+ // 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),
+ "https://bücher.example/dvls",
+ "https://admin@host/dvls",
+ ]);
+
+ // oracle: spec Framework Design Guidelines §Validating Arguments
+ [Theory]
+ [MemberData(nameof(RejectedUrlRows))]
+ public void CreateRejectsAnotherUrl(string productBaseUrl)
+ {
+ ArgumentException exception = Assert.Throws(
+ () => EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(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 §enrollment-token
+ // oracle: intent dotnet/AgentIdentity/INTENT.md §Enrollment tokens
+ [Fact]
+ public void CreateAcceptsTheLongestUrlThatFits()
+ {
+ EnrollmentToken token =
+ EnrollmentToken.Create(UrlOfLength(3025), new ScriptedRandomNumberGenerator(SecretBytes));
+
+ Assert.Equal(4095, token.Value.Length);
+ }
+
+ // 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()
+ {
+ EnrollmentToken token =
+ EnrollmentToken.Create("https://host/dvls", new ScriptedRandomNumberGenerator(SecretBytes));
+ string[] segments = token.Value.Split('.');
+
+ EnrollmentTokenAssert.RevealsNoPart(token.ToString(), segments[1], segments[2]);
+ }
+
+ // 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)
+ {
+ ArgumentException exception = Assert.Throws(
+ () => EnrollmentToken.Create(productBaseUrl, new ScriptedRandomNumberGenerator(SecretBytes)));
+
+ EnrollmentTokenAssert.RevealsNoPart(exception.Message, SecretText);
+ }
+
+ // A null argument throws ArgumentNullException, whose ParamName names it.
+ // oracle: spec Framework Design Guidelines §Validating Arguments
+ [Theory]
+ [InlineData("productBaseUrl")]
+ [InlineData("randomNumberGenerator")]
+ 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);
+ }
+
+ // 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 ScriptedRandomNumberGenerator(SecretBytes)),
+ ["randomNumberGenerator"] = () => EnrollmentToken.Create("https://host/dvls", null!),
+ };
+
+ private static string UrlOfLength(int length)
+ {
+ const string prefix = "https://host/";
+
+ 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 ScriptedRandomNumberGenerator(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)
+ {
+ string bag = token.Value.Split('.')[1];
+ // 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 §conventions
+ Assert.Equal(bag, Base64Url.EncodeToString(json));
+
+ using JsonDocument document = JsonDocument.Parse(json);
+
+ 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);
+ }
+ }
+}
diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs
new file mode 100644
index 0000000000..8afeae643e
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Enrollment/FriendlyNameFormatTests.cs
@@ -0,0 +1,379 @@
+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 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.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.agent-identity.md §3.
+ private static readonly AgentMetadata Metadata = Validated(new Dictionary
+ {
+ ["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.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 §metadata
+ [InlineData("{hostname}", "pc-042")]
+ // 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 §metadata
+ [InlineData("{domain}", "corp.example")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{os_name}", "Windows")]
+ // 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 §metadata
+ [InlineData("{arch}", "x86_64")]
+ // 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 §metadata
+ [InlineData("{machine_id}", "4c4c4544-0042-3510-8052-b4c04f4a4e32")]
+ [InlineData("{token_name}", "Fleet")]
+ // `{{` 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 §metadata
+ [InlineData("{{{hostname}}}", "{pc-042}")]
+ // 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 §metadata
+ [InlineData("{token_name}/{hostname}{hostname}", "Fleet/pc-042pc-042")]
+ public void EvaluatesThePlaceholdersAndTheLiteralText(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.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("}")]
+ [InlineData("{hostname")]
+ [InlineData("hostname}")]
+ [InlineData("{hostname}}")]
+ [InlineData("{{hostname}")]
+ [InlineData("{{hostname}}}")]
+ [InlineData("{{}")]
+ [InlineData("}{")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{}")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{Hostname}")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{HOSTNAME}")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{ hostname }")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{hostname }")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{host{name}")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{token}")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{name}")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{custom_key}")]
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata
+ [InlineData("{0}")]
+ public void TryParseRejectsAnUnpairedBraceOrAnUnknownPlaceholder(string format)
+ {
+ Assert.False(FriendlyNameFormat.TryParse(format, out _, out _));
+ }
+
+ // 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)]
+ [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.
+ // oracle: mutant a parse error that lists a placeholder without one of its braces
+ [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);
+ }
+ }
+
+ // 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}")]
+ [InlineData("before an unpaired opening brace", "{hostname} " + Token + " {")]
+ [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);
+ }
+
+ // 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);
+
+ EnrollmentTokenAssert.RevealsNoPart($"{parsed}", Bag, Secret);
+ }
+
+ // A missing metadata value evaluates to empty.
+ // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment
+ [Fact]
+ public void EvaluatesAMissingMetadataValueToEmpty()
+ {
+ AgentMetadata metadata =
+ Validated(new Dictionary { ["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.agent-identity.md §3 lets an agent report an unknown key such as `token_name`,
+ // which is never used for authorization.
+ // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment
+ [Fact]
+ public void EvaluatesTokenNameToTheTokenNameRatherThanAReportedValue()
+ {
+ AgentMetadata metadata = Validated(new Dictionary(Metadata) { ["token_name"] = "Reported" });
+
+ Assert.Equal("Fleet", Evaluate("{token_name}", metadata));
+ }
+
+ // 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()
+ {
+ AgentMetadata metadata = Validated(new Dictionary { ["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);
+
+ // 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)
+ {
+ 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.
+ // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment
+ // oracle: spec Unicode PropList.txt §White_Space
+ [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 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.
+ // Leading white space is trimmed before truncation, so it never takes the place of a kept scalar value.
+ private static Dictionary TokenNamesToTruncate => new()
+ {
+ ["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"),
+ // 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)
+ {
+ (string value, string expected) = TokenNamesToTruncate[tokenName];
+
+ Assert.Equal(expected, Evaluate("{token_name}", tokenName: value));
+ }
+
+ // 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 §conventions
+ [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)
+ {
+ AgentMetadata metadata = Validated(new Dictionary { ["hostname"] = "pc-042" });
+
+ Assert.Equal("0f8fad5b-d9cb-469f-a165-70867728950e", Evaluate(format, metadata, tokenName));
+ }
+
+ // 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 §conventions
+ Assert.Equal(
+ "0f8fad5b-d9cb-469f-a165-70867728950e",
+ FriendlyNameFormat.Default.Evaluate(withoutHostname, TokenName, IdentityId));
+ }
+
+ // 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()
+ {
+ ArgumentNullException exception = Assert.Throws(
+ () => FriendlyNameFormat.TryParse(null!, out _, out _));
+
+ Assert.Equal("format", exception.ParamName);
+ }
+
+ // 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")]
+ public void EvaluateRejectsANullArgument(string parameter)
+ {
+ ArgumentNullException exception =
+ Assert.Throws(EvaluationsWithANullArgument[parameter]);
+
+ Assert.Equal(parameter, exception.ParamName);
+ }
+
+ // An evaluation with one null argument, by the name of the parameter that receives it.
+ private static Dictionary EvaluationsWithANullArgument => new()
+ {
+ ["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)
+ {
+ 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.Tests/Errors/AgentIdentityErrorCodeTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs
new file mode 100644
index 0000000000..93167f9d99
--- /dev/null
+++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorCodeTests.cs
@@ -0,0 +1,152 @@
+using Grpc.Core;
+
+namespace Devolutions.AgentIdentity.Tests;
+
+public class AgentIdentityErrorCodeTests
+{
+ // CONTRACT.agent-identity.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" },
+ { AgentIdentityErrorCode.TokenUrlMismatch, "token_url_mismatch" },
+ };
+
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ [Theory]
+ [MemberData(nameof(WireValues))]
+ public void WireValueIsTheContractCode(AgentIdentityErrorCode code, string expected)
+ {
+ Assert.Equal(expected, code.ToWireValue());
+ }
+
+ // 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 §errors
+ { AgentIdentityErrorCode.TokenInvalid, 401 },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ { AgentIdentityErrorCode.TokenExhausted, 403 },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ { AgentIdentityErrorCode.TokenExpired, 401 },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ { AgentIdentityErrorCode.IdentityRevoked, 403 },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ { AgentIdentityErrorCode.IdentityUnknown, 401 },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ { AgentIdentityErrorCode.CertificateExpired, 401 },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ { AgentIdentityErrorCode.SignatureInvalid, 401 },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors
+ { 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,
+ // 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 §errors
+ { 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)
+ {
+ 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)
+ {
+ 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`.
+ // §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()
+ {
+ // 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 §channel-termination
+ { AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ { AgentIdentityErrorCode.IdentityUnknown, StatusCode.Unauthenticated },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ { AgentIdentityErrorCode.CertificateExpired, StatusCode.Unauthenticated },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ { AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated },
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ { AgentIdentityErrorCode.ClockSkew, StatusCode.Unauthenticated },
+ // 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 },
+ // 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.
+ // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination
+ [Theory]
+ [MemberData(nameof(GrpcStatusCodes))]
+ public void GrpcStatusCodeIsTheContractStatus(AgentIdentityErrorCode code, StatusCode expected)
+ {
+ Assert.Equal(expected, code.ToGrpcStatusCode());
+ }
+
+ // 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]
+ public void EveryCodeHasAContractRow()
+ {
+ AgentIdentityErrorCode[] defined = Enum.GetValues();
+
+ Assert.Equal(defined, CodesOf(WireValues));
+ Assert.Equal(defined, CodesOf(HttpStatusCodes).Concat(CodesOf(HttpStatusClasses)).Order());
+ Assert.Equal(defined, CodesOf(GrpcStatusCodes));
+ }
+
+ // 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()
+ {
+ AgentIdentityErrorCode undefined = default;
+
+ Assert.Throws(() => undefined.ToWireValue());
+ Assert.Throws(() => undefined.ToHttpStatusCode());
+ Assert.Throws(() => undefined.ToGrpcStatusCode());
+ }
+
+ private static IEnumerable CodesOf(IEnumerable