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 rows) + { + return rows.Select(row => (AgentIdentityErrorCode)row[0]).Order(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs new file mode 100644 index 0000000000..c353cd3336 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Errors/AgentIdentityErrorTests.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using Grpc.Core; + +namespace Devolutions.AgentIdentity.Tests; + +public class AgentIdentityErrorTests +{ + // 12:00:00 UTC given in a +02:00 offset, + // which CONTRACT.agent-identity.md §1 writes as a UTC timestamp. + private static readonly DateTimeOffset ServerTime = new(2026, 9, 24, 14, 0, 0, TimeSpan.FromHours(2)); + + private const string ServerTimeText = "2026-09-24T12:00:00Z"; + + private static readonly string[] FrameworkLevelErrorNames = + [ + nameof(AgentIdentityError.MalformedBody), + nameof(AgentIdentityError.UnknownRoute), + nameof(AgentIdentityError.MethodNotAllowed), + nameof(AgentIdentityError.BodyTooLarge), + nameof(AgentIdentityError.UnexpectedFailure), + nameof(AgentIdentityError.ServiceUnavailable), + ]; + + public static TheoryData PublicErrorNames => new(PublicErrors.Select(error => error.Name)); + + // Every error that the library hands out: the error of each code, and each framework-level error. + private static IEnumerable<(string Name, AgentIdentityError Error)> PublicErrors => + [ + .. Enum.GetValues().Select(code => ($"From({code})", AgentIdentityError.From(code))), + .. FrameworkLevelErrorNames.Select(name => (name, FrameworkLevelError(name))), + ]; + + // Column "HTTP". + [Theory] + [MemberData(nameof(AgentIdentityErrorCodeTests.HttpStatusCodes), MemberType = typeof(AgentIdentityErrorCodeTests))] + public void FromCarriesTheCodeWithItsContractStatus(AgentIdentityErrorCode code, int expectedStatus) + { + AgentIdentityError error = AgentIdentityError.From(code); + + // The body's `error` is the code. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + Assert.Equal(code, error.Code); + Assert.Equal(expectedStatus, error.HttpStatusCode); + } + + // CONTRACT.agent-identity.md §5.4 has no code for the default value of the enum. + // Framework Design Guidelines, Parameter Design: validate enum parameters, since an enum may hold any integer. + // oracle: spec Framework Design Guidelines §Validating Arguments + // oracle: spec Framework Design Guidelines §Using Standard Exception Types + [Fact] + public void FromRejectsAnUndefinedCode() + { + Assert.Throws(() => AgentIdentityError.From(default)); + } + + // Malformed JSON or body → 400 invalid_request; + // unknown route → 404 invalid_request; wrong method → 405 invalid_request; body too large → 413 invalid_request; + // an unexpected server failure → 500 unavailable; overload or a missing dependency → 503 unavailable. + [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + [InlineData(nameof(AgentIdentityError.MalformedBody), 400, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + [InlineData(nameof(AgentIdentityError.UnknownRoute), 404, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + [InlineData(nameof(AgentIdentityError.MethodNotAllowed), 405, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + [InlineData(nameof(AgentIdentityError.BodyTooLarge), 413, AgentIdentityErrorCode.InvalidRequest)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + [InlineData(nameof(AgentIdentityError.UnexpectedFailure), 500, AgentIdentityErrorCode.Unavailable)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: spec RFC 9110 §15.6.4 + [InlineData(nameof(AgentIdentityError.ServiceUnavailable), 503, AgentIdentityErrorCode.Unavailable)] + public void FrameworkLevelErrorHasItsContractStatusAndCode( + string name, + int expectedStatus, + AgentIdentityErrorCode expectedCode) + { + AgentIdentityError error = FrameworkLevelError(name); + + Assert.Equal(expectedStatus, error.HttpStatusCode); + Assert.Equal(expectedCode, error.Code); + } + + // Every error body, framework-level failures included, carries a `message` of human text. + // The contract leaves its wording free, so only its presence is checked. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + [Theory] + [MemberData(nameof(PublicErrorNames))] + public void ErrorHasAHumanMessage(string name) + { + AgentIdentityError error = PublicErrors.Single(entry => entry.Name == name).Error; + + Assert.False(string.IsNullOrWhiteSpace(error.Message)); + } + + // CONTRACT.agent-identity.md §5.4 body, with the snake_case keys and the timestamp format of §1. + // Parsing the raw bytes also rejects a byte order mark, which RFC 8259 §8.1 forbids in a JSON text. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: spec RFC 8259 §8.1 + [Fact] + public void JsonBodyHasExactlyTheContractMembers() + { + byte[] body = AgentIdentityError.From(AgentIdentityErrorCode.ClockSkew).ToJsonBody(ServerTime); + + using JsonDocument document = JsonDocument.Parse(body); + Dictionary members = document.RootElement + .EnumerateObject() + .ToDictionary(member => member.Name, member => member.Value); + Assert.Equal(["error", "message", "server_time"], members.Keys.Order(StringComparer.Ordinal)); + Assert.Equal("clock_skew", members["error"].GetString()); + Assert.NotEmpty(members["message"].GetString()!); + Assert.Equal(ServerTimeText, members["server_time"].GetString()); + } + + // CONTRACT.agent-identity.md §7.4: a refused channel opening carries the code's status. + // §7.3 step 4: invalid `Hello` metadata gets INVALID_ARGUMENT. + // §7.4: so does a message that cannot be decoded on an authenticated stream. + [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + [InlineData(AgentIdentityErrorCode.SignatureInvalid, StatusCode.Unauthenticated)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + [InlineData(AgentIdentityErrorCode.IdentityRevoked, StatusCode.PermissionDenied)] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + [InlineData(AgentIdentityErrorCode.InvalidRequest, StatusCode.InvalidArgument)] + // oracle: spec gRPC status codes §Status codes and their use in gRPC + [InlineData(AgentIdentityErrorCode.Unavailable, StatusCode.Unavailable)] + public void RpcExceptionHasTheChannelStatus(AgentIdentityErrorCode code, StatusCode expected) + { + RpcException exception = AgentIdentityError.From(code).ToRpcException(ServerTime); + + Assert.Equal(expected, exception.StatusCode); + } + + // Trailers `error-code: <§5.4 code>` and `server-time: `, in no stated order. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + [Fact] + public void RpcExceptionHasExactlyTheContractTrailers() + { + RpcException exception = AgentIdentityError.From(AgentIdentityErrorCode.ClockSkew).ToRpcException(ServerTime); + + Assert.Equal( + [("error-code", "clock_skew"), ("server-time", ServerTimeText)], + exception.Trailers + .Select(entry => (entry.Key, entry.Value)) + .OrderBy(entry => entry.Key, StringComparer.Ordinal)); + } + + private static AgentIdentityError FrameworkLevelError(string name) + { + return name switch + { + nameof(AgentIdentityError.MalformedBody) => AgentIdentityError.MalformedBody, + nameof(AgentIdentityError.UnknownRoute) => AgentIdentityError.UnknownRoute, + nameof(AgentIdentityError.MethodNotAllowed) => AgentIdentityError.MethodNotAllowed, + nameof(AgentIdentityError.BodyTooLarge) => AgentIdentityError.BodyTooLarge, + nameof(AgentIdentityError.UnexpectedFailure) => AgentIdentityError.UnexpectedFailure, + nameof(AgentIdentityError.ServiceUnavailable) => AgentIdentityError.ServiceUnavailable, + _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Not a framework-level error."), + }; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs new file mode 100644 index 0000000000..ead2631a82 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/AgentIdentityProtocolTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; + +namespace Devolutions.AgentIdentity.Tests; + +public class AgentIdentityProtocolTests +{ + public static TheoryData VectorCertificateNames => new(VectorCertificates.Select(vector => vector.Name)); + + // Each certificate of the vector file, named by its path in the file. + private static IEnumerable<(string Name, byte[] Certificate, string Thumbprint)> VectorCertificates => + [ + .. TestVectors.Instance.Keys.Select(key => ($"keys[{key.Name}]", key.Certificate, key.Thumbprint)), + ("root", TestVectors.Instance.Root.Certificate, TestVectors.Instance.Root.Thumbprint), + .. TestVectors.Instance.HttpSignature.Registered.Select( + registered => ($"http_signature.registered[{registered.Name}]", registered.Certificate, registered.KeyId)), + ]; + + // CONTRACT.agent-identity.md §1: timestamps are RFC 3339 UTC strings ending in `Z`, such as 2026-09-24T12:00:00Z. + [Theory] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + [InlineData("2026-09-24T12:00:00+00:00", "2026-09-24T12:00:00Z")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + [InlineData("2026-09-24T14:00:00+02:00", "2026-09-24T12:00:00Z")] + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + [InlineData("2026-09-24T23:30:00-01:00", "2026-09-25T00:30:00Z")] + public void FormatTimestampWritesUtcWholeSeconds(string instant, string expected) + { + DateTimeOffset value = DateTimeOffset.Parse(instant, CultureInfo.InvariantCulture); + + Assert.Equal(expected, AgentIdentityProtocol.FormatTimestamp(value)); + } + + // RFC 3339 dates are Gregorian, and §5.6 writes them in ASCII digits with fixed separators, whatever the culture. + // A culture-sensitive format would write the Thai Buddhist year 2569 and the time separator of this culture. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: spec RFC 3339 §1 + // oracle: spec RFC 3339 §5.6 + [Fact] + public void FormatTimestampIgnoresTheCurrentCulture() + { + CultureInfo culture = (CultureInfo)CultureInfo.GetCultureInfo("th-TH").Clone(); + culture.DateTimeFormat.TimeSeparator = "."; + CultureInfo previous = CultureInfo.CurrentCulture; + DateTimeOffset value = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + + try + { + CultureInfo.CurrentCulture = culture; + + Assert.Equal("2026-09-24T12:00:00Z", AgentIdentityProtocol.FormatTimestamp(value)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + // CONTRACT.agent-identity.md §1: a thumbprint is base64url(SHA-256(certificate DER)). + // CONTRACT.agent-identity.md §6: a request's `keyid` is the thumbprint of the certificate that signs it. + // The vector file gives each certificate with its thumbprint, or a registered certificate with its keyid. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: reference docs/agent-identity/test-vectors.json + [Theory] + [MemberData(nameof(VectorCertificateNames))] + public void CertificateHasTheVectorThumbprint(string name) + { + (_, byte[] certificate, string thumbprint) = VectorCertificates.Single(vector => vector.Name == name); + + Assert.Equal(thumbprint, AgentIdentityProtocol.ComputeThumbprint(certificate)); + } + + // Servers accept request bodies up to 64 KiB. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + [Fact] + public void MaxRequestBodyBytesIs64KiB() + { + Assert.Equal(65536, AgentIdentityProtocol.MaxRequestBodyBytes); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs new file mode 100644 index 0000000000..ad06888389 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Protocol/StrictBase64Tests.cs @@ -0,0 +1,233 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +public class StrictBase64Tests +{ + // RFC 4648 Table 1 and Table 2. + private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + // CONTRACT.agent-identity.md Definitions: base64 is RFC 4648 §4 with padding, + // and base64url is RFC 4648 §5 without padding. + private static readonly Dictionary Codecs = new() + { + ["base64"] = new(StrictBase64.TryDecodeBase64, Base64Alphabet, "==", "=", base64 => base64), + ["base64url"] = new(StrictBase64.TryDecodeBase64Url, Base64UrlAlphabet, "", "", ToBase64Url), + }; + + private delegate bool Decoder(ReadOnlySpan text, [NotNullWhen(true)] out byte[]? bytes, int? exactLength); + + // RFC 4648 §10 test vectors, which base64url writes without padding, as RFC 4648 §3.2 lets an encoding do. + // In the last row of each encoding, the sextets 62, 63 and 60 carry the bytes FB FF and two zero pad bits; + // RFC 4648 Table 1 writes them "+/8" and Table 2 writes them "-_8". + public static TheoryData CanonicalForms => new() + { + { "base64", "", Ascii("") }, + { "base64", "Zg==", Ascii("f") }, + { "base64", "Zm8=", Ascii("fo") }, + { "base64", "Zm9v", Ascii("foo") }, + { "base64", "Zm9vYg==", Ascii("foob") }, + { "base64", "Zm9vYmE=", Ascii("fooba") }, + { "base64", "Zm9vYmFy", Ascii("foobar") }, + { "base64", "+/8=", [0xFB, 0xFF] }, + { "base64url", "", Ascii("") }, + { "base64url", "Zg", Ascii("f") }, + { "base64url", "Zm8", Ascii("fo") }, + { "base64url", "Zm9v", Ascii("foo") }, + { "base64url", "Zm9vYg", Ascii("foob") }, + { "base64url", "Zm9vYmE", Ascii("fooba") }, + { "base64url", "Zm9vYmFy", Ascii("foobar") }, + { "base64url", "-_8", [0xFB, 0xFF] }, + }; + + // The base64url rows of CanonicalForms, which the encoder writes from their bytes. + public static TheoryData Base64UrlForms => new() + { + { "", Ascii("") }, + { "Zg", Ascii("f") }, + { "Zm8", Ascii("fo") }, + { "Zm9v", Ascii("foo") }, + { "Zm9vYg", Ascii("foob") }, + { "Zm9vYmE", Ascii("fooba") }, + { "Zm9vYmFy", Ascii("foobar") }, + { "-_8", [0xFB, 0xFF] }, + }; + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + // oracle: spec RFC 4648 §10 + // oracle: spec RFC 4648 §4 + // oracle: spec RFC 4648 §5 + [Theory] + [MemberData(nameof(CanonicalForms))] + public void DecodesTheCanonicalForm(string encoding, string encoded, byte[] expected) + { + Assert.True(Codecs[encoding].TryDecode(encoded, out byte[]? bytes, null)); + Assert.Equal(expected, bytes); + } + + // Each row is a form that RFC 4648 §4, or §5 without padding, does not produce for any byte string, + // so accepting it would give one value two encodings. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + // oracle: spec RFC 4648 §4 + // oracle: spec RFC 4648 §5 + [Theory] + [InlineData("base64", "missing padding", "Zg")] + [InlineData("base64", "missing one padding character", "Zm9vYg=")] + [InlineData("base64", "padding after a complete quantum", "Zm9v====")] + [InlineData("base64", "padding inside the text", "Zg==Zm9v")] + [InlineData("base64", "padding only", "====")] + [InlineData("base64", "line break between quanta", "Zm9v\r\nYmFy")] + [InlineData("base64", "trailing space", "Zm9vYmFy ")] + [InlineData("base64", "non-zero pad bits after one byte", "Zh==")] + [InlineData("base64", "non-zero pad bits after two bytes", "Zm9=")] + [InlineData("base64", "base64url alphabet", "-_8=")] + [InlineData("base64", "character outside ASCII whose low byte is 'v'", "Zm9\u0176")] + [InlineData("base64url", "padding after one byte", "Zg==")] + [InlineData("base64url", "padding after two bytes", "Zm8=")] + [InlineData("base64url", "impossible length", "Zm9vY")] + [InlineData("base64url", "line break between quanta", "Zm9v\r\nYmFy")] + [InlineData("base64url", "trailing space", "Zm9vYmFy ")] + [InlineData("base64url", "non-zero pad bits after one byte", "Zh")] + [InlineData("base64url", "non-zero pad bits after two bytes", "Zm9")] + [InlineData("base64url", "base64 alphabet", "+/8")] + [InlineData("base64url", "character outside ASCII whose low byte is 'v'", "Zm9\u0176")] + public void RejectsANonCanonicalForm(string encoding, string form, string encoded) + { + Assert.False(Codecs[encoding].TryDecode(encoded, out _, null), form); + } + + // RFC 4648 §10 vectors with the number of bytes they encode; padding characters encode none. + // oracle: spec RFC 4648 §10 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + [Theory] + [InlineData("base64", "", 0)] + [InlineData("base64", "Zg==", 1)] + [InlineData("base64", "Zm8=", 2)] + [InlineData("base64", "Zm9v", 3)] + [InlineData("base64url", "", 0)] + [InlineData("base64url", "Zg", 1)] + [InlineData("base64url", "Zm8", 2)] + [InlineData("base64url", "Zm9v", 3)] + public void AcceptsTheExactDecodedLength(string encoding, string encoded, int length) + { + Assert.True(Codecs[encoding].TryDecode(encoded, out byte[]? bytes, length)); + Assert.Equal(length, bytes.Length); + } + + // RFC 4648 §10 gives the length of each vector, which differs from the length each row requires. + // oracle: spec RFC 4648 §10 + // oracle: mutant the decoder accepts a value whose decoded length differs from the length the caller requires + [Theory] + [InlineData("base64", "", 1)] + [InlineData("base64", "Zg==", 0)] + [InlineData("base64", "Zg==", 3)] + [InlineData("base64", "Zm8=", 3)] + [InlineData("base64", "Zm9v", 2)] + [InlineData("base64", "Zm9v", 4)] + [InlineData("base64url", "", 1)] + [InlineData("base64url", "Zg", 0)] + [InlineData("base64url", "Zg", 2)] + [InlineData("base64url", "Zm8", 3)] + [InlineData("base64url", "Zm9v", 2)] + [InlineData("base64url", "Zm9v", 4)] + public void RejectsAnotherDecodedLength(string encoding, string encoded, int length) + { + Assert.False(Codecs[encoding].TryDecode(encoded, out _, length)); + } + + // CONTRACT.agent-identity.md Definitions: base64url is RFC 4648 §5 without padding. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + // oracle: spec RFC 4648 §5 + // oracle: spec RFC 4648 §10 + [Theory] + [MemberData(nameof(Base64UrlForms))] + public void Base64UrlEncodesWithoutPadding(string expected, byte[] value) + { + Assert.Equal(expected, StrictBase64.EncodeBase64Url(value)); + } + + // The BCL encoder writes canonical base64, + // and RFC 4648 §5 derives base64url from it by swapping the last two characters and dropping the padding. + // Random values of every length up to 96 bytes end in each final-quantum shape many times. + // oracle: spec RFC 4648 §4 + // oracle: spec RFC 4648 §5 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + [Fact] + public void DecodesAndEncodesEveryLengthLikeTheBcl() + { + Random random = new(4648); + + for (int length = 0; length <= 96; length++) + { + byte[] value = new byte[length]; + random.NextBytes(value); + string base64 = Convert.ToBase64String(value); + string base64Url = ToBase64Url(base64); + + Assert.True(StrictBase64.TryDecodeBase64(base64, out byte[]? fromBase64), base64); + Assert.Equal(value, fromBase64); + Assert.True(StrictBase64.TryDecodeBase64Url(base64Url, out byte[]? fromBase64Url), base64Url); + Assert.Equal(value, fromBase64Url); + Assert.Equal(base64Url, StrictBase64.EncodeBase64Url(value)); + } + } + + // Only the final quantum has pad bits, which a canonical encoding sets to zero, + // so trying every final quantum of one or two bytes checks that each value has exactly one accepted encoding. + // oracle: spec RFC 4648 §3.5 + // oracle: spec RFC 4648 §4 + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §definitions + [Theory] + [InlineData("base64")] + [InlineData("base64url")] + public void AcceptsOneEncodingOfEachOneOrTwoByteValue(string encoding) + { + Codec codec = Codecs[encoding]; + IEnumerable finalQuanta = Strings(codec.Alphabet, 2).Select(s => s + codec.OneBytePadding) + .Concat(Strings(codec.Alphabet, 3).Select(s => s + codec.TwoBytePadding)); + IEnumerable canonical = OneOrTwoByteValues() + .Select(value => codec.FromBase64(Convert.ToBase64String(value))); + + IEnumerable accepted = finalQuanta.Where(s => codec.TryDecode(s, out _, null)); + + Assert.Equal(canonical.Order(StringComparer.Ordinal), accepted.Order(StringComparer.Ordinal)); + } + + private static byte[] Ascii(string text) + { + return Encoding.ASCII.GetBytes(text); + } + + private static string ToBase64Url(string base64) + { + return base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } + + private static IEnumerable Strings(string alphabet, int length) + { + return length == 0 + ? [string.Empty] + : Strings(alphabet, length - 1).SelectMany(prefix => alphabet.Select(c => prefix + c)); + } + + private static IEnumerable OneOrTwoByteValues() + { + return Enumerable.Range(0, 1 << 8).Select(v => new[] { (byte)v }) + .Concat(Enumerable.Range(0, 1 << 16).Select(v => new[] { (byte)(v >> 8), (byte)v })); + } + + /// + /// An encoding with its decoder under test, its alphabet, and the padding of a final quantum. + /// + /// Converts canonical base64 to the canonical form of this encoding. + private sealed record Codec( + Decoder TryDecode, + string Alphabet, + string OneBytePadding, + string TwoBytePadding, + Func FromBase64); +} \ No newline at end of file diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResultNullability.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResultNullability.cs new file mode 100644 index 0000000000..3ada056e80 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/ResultNullability.cs @@ -0,0 +1,98 @@ +namespace Devolutions.AgentIdentity.Tests; + +// Never runs: it compiles only while each result's Succeeded tells the compiler which members are not null, +// so a lost [MemberNotNullWhen] fails the build under TreatWarningsAsErrors. +// It is not a test, because no document decides the annotations and the build alone checks them. +internal static class ResultNullability +{ + internal static void Authentication(AuthenticationResult result) + { + if (result.Succeeded) + { + Consume(result.Identity); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void MetadataValidation(MetadataValidationResult result) + { + if (result.Succeeded) + { + Consume(result.Metadata); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void RequestBodyParse(RequestBodyParseResult result) + { + if (result.Succeeded) + { + Consume(result.Body); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void CsrValidation(CsrValidationResult result) + { + if (result.Succeeded) + { + Consume(result.Csr); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void EnrollmentAuthorization(EnrollmentAuthorizationResult result) + { + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void ChannelOpening(ChannelOpeningResult result) + { + if (result.Succeeded) + { + Consume(result.Opening); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + internal static void ChannelHandshake(ChannelHandshakeResult result) + { + if (result.Succeeded) + { + Consume(result.Identity, result.Metadata, result.Welcome); + } + + if (!result.Succeeded) + { + Consume(result.Error, result.FailureDetail); + } + } + + private static void Consume(params object[] values) + { + _ = values; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs new file mode 100644 index 0000000000..dd0b38083f --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestAuthority.cs @@ -0,0 +1,98 @@ +using Devolutions.Agent.Channel.V1; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// An authority for tests that sign their own requests: +/// every vector key is registered, current and not revoked, unless a test registers it otherwise. +/// +internal sealed class TestAuthority +{ + public TestAuthority(DateTimeOffset now, RecordingNonceStore? nonces = null, TimeProvider? clock = null) + { + Now = now; + Nonces = nonces ?? new RecordingNonceStore(); + + foreach (KeyVector key in TestVectors.Instance.Keys) + { + Registry.Register(key); + } + + Authenticator = new RequestAuthenticator(Registry, Nonces, clock ?? new FixedTimeProvider(now), Random); + } + + /// + /// An instant within the validity of the certificate of device-a, + /// as http_signature.registered gives it. + /// + public static DateTimeOffset DefaultNow { get; } = DateTimeOffset.FromUnixTimeSeconds(1_790_000_000); + + public DateTimeOffset Now { get; } + + public FakeCertificateRegistry Registry { get; } = new(); + + public RecordingNonceStore Nonces { get; } + + public CountingRandomNumberGenerator Random { get; } = new(); + + public RequestAuthenticator Authenticator { get; } + + public TestRequest Valid(AgentOperation operation, KeyVector? key = null, string nonce = TestRequest.DefaultNonce) + { + return TestRequest.Valid(operation, Now, key, nonce); + } + + public Task OutcomeAsync(TestRequest request) + { + return OutcomeAsync(request.Operation, request.ToHttpRequest()); + } + + /// + /// Authenticates a request through the public API: + /// connect as a channel opening, the others as signed requests. + /// + /// ok or the wire value of the error code, as the vector file writes outcomes. + public async Task OutcomeAsync(AgentOperation operation, SignedHttpRequest request) + { + if (operation == AgentOperation.Connect) + { + ChannelOpeningResult opening = await AuthenticateChannelOpeningAsync(request); + + return opening.Succeeded ? "ok" : opening.Error.Code.ToWireValue(); + } + + AuthenticationResult result = await Authenticator.AuthenticateAsync(operation, request); + + return result.Succeeded ? "ok" : result.Error.Code.ToWireValue(); + } + + /// + /// Opens a channel through the public API with a valid connect request of device-a. + /// + public async Task OpenChannelAsync(string nonce = TestRequest.DefaultNonce) + { + ChannelOpeningResult result = + await AuthenticateChannelOpeningAsync(Valid(AgentOperation.Connect, nonce: nonce).ToHttpRequest()); + + Assert.True(result.Succeeded, result.FailureDetail); + return result.Opening; + } + + /// + /// A Hello of device-a that answers the Challenge of an opening of device-a. + /// + public static AgentMessage ValidHello(ChannelOpening opening) + { + byte[] proof = TestChannelProof.Sign( + TestVectors.Instance.Key("device-a"), + opening.Challenge.Challenge.Challenge_.Span, + opening.ConnectNonce); + + return VectorChannel.HelloMessage(opening, proof); + } + + private ValueTask AuthenticateChannelOpeningAsync(SignedHttpRequest request) + { + return Authenticator.AuthenticateChannelOpeningAsync(request.Method, request.SignatureInput, request.Signature); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs new file mode 100644 index 0000000000..1547df0209 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestChannelProof.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Channel proofs that a test signs as an agent would, +/// built from CONTRACT.agent-identity.md §7.3 step 3 rather than by the library. +/// +internal static class TestChannelProof +{ + /// + /// Signs the domain, a zero byte, the challenge and the connect nonce with the key, as raw r‖s. + /// + /// + /// ECDSA signing is randomized, so each call returns another valid proof. + /// + public static byte[] Sign(KeyVector key, ReadOnlySpan challenge, string connectNonce) + { + byte[] input = + [ + .. "devolutions-agent-identity/v1/channel-proof"u8, + 0x00, + .. challenge, + .. Encoding.UTF8.GetBytes(connectNonce), + ]; + + using ECDsa privateKey = ECDsa.Create(); + privateKey.ImportPkcs8PrivateKey(key.PrivateKeyPkcs8, out _); + + return privateKey.SignData( + input, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestCsr.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestCsr.cs new file mode 100644 index 0000000000..68bd3ec135 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestCsr.cs @@ -0,0 +1,151 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Builds PKCS #10 requests (RFC 2986 §4) element by element, +/// so that a test can break one rule of a request and still sign it correctly. +/// +/// +/// The builders join the elements they are given without checking them, so that a test can also break DER. +/// +internal static class TestCsr +{ + // RFC 5480 §2.1.1 and §2.1.1.1. + public const string IdEcPublicKey = "1.2.840.10045.2.1"; + public const string Secp256r1 = "1.2.840.10045.3.1.7"; + + // RFC 5758 §3.2. + public const string EcdsaWithSha256 = "1.2.840.10045.4.3.2"; + public const string EcdsaWithSha384 = "1.2.840.10045.4.3.3"; + + // An empty RDNSequence and an empty attribute set, the smallest values of two fields that RFC 2986 §4.1 requires. + public static byte[] EmptySubject => [0x30, 0x00]; + + public static byte[] EmptyAttributes => [0xA0, 0x00]; + + public static byte[] Null => [0x05, 0x00]; + + public static byte[] Tlv(byte tag, params byte[][] contents) + { + byte[] body = [.. contents.SelectMany(content => content)]; + + return [tag, .. Length(body.Length), .. body]; + } + + /// + /// An element whose length takes one octet more than DER allows (X.690 §10.1): + /// the long form for a length below 128, or else a leading zero octet. + /// + public static byte[] TlvWithLongerLength(byte tag, byte[] contents) + { + byte[] length = Length(contents.Length); + byte[] longer = length.Length == 1 ? [0x81, length[0]] : [(byte)(0x80 | length.Length), 0x00, .. length[1..]]; + + return [tag, .. longer, .. contents]; + } + + public static byte[] Sequence(params byte[][] elements) + { + return Tlv(0x30, elements); + } + + public static byte[] Integer(int value) + { + return Der(writer => writer.WriteInteger(value)); + } + + public static byte[] Oid(string oid) + { + return Der(writer => writer.WriteObjectIdentifier(oid)); + } + + public static byte[] BitString(byte[] value, byte unusedBits = 0) + { + return Tlv(0x03, [unusedBits], value); + } + + /// + /// The AlgorithmIdentifier of a P-256 key: id-ecPublicKey with the named curve secp256r1. + /// + public static byte[] P256Algorithm() + { + return Sequence(Oid(IdEcPublicKey), Oid(Secp256r1)); + } + + public static byte[] SubjectPublicKeyInfo(byte[] algorithm, byte[] publicKey, byte unusedBits = 0) + { + return Sequence(algorithm, BitString(publicKey, unusedBits)); + } + + /// + /// The uncompressed point of the key (SEC 1 §2.3.3): 04, then x and y. + /// + public static byte[] UncompressedPoint(ECDsa key) + { + ECParameters parameters = key.ExportParameters(includePrivateParameters: false); + + return [0x04, .. parameters.Q.X!, .. parameters.Q.Y!]; + } + + public static byte[] RequestInfo(byte[] subjectPublicKeyInfo, byte[]? subject = null, byte[]? attributes = null) + { + return Sequence(Integer(0), subject ?? EmptySubject, subjectPublicKeyInfo, attributes ?? EmptyAttributes); + } + + public static byte[] SignatureAlgorithm(string oid) + { + return Sequence(Oid(oid)); + } + + /// + /// The DER ECDSA-Sig-Value (RFC 3279 §2.2.3) of the request info, with SHA-256 unless stated otherwise. + /// + public static byte[] Sign(ECDsa key, byte[] requestInfo, HashAlgorithmName? hash = null) + { + return key.SignData(requestInfo, hash ?? HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + } + + /// + /// A request for the request info, signed by the key with ecdsa-with-SHA256 unless stated otherwise. + /// + public static byte[] Request( + byte[] requestInfo, + ECDsa signer, + byte[]? signatureAlgorithm = null, + HashAlgorithmName? hash = null) + { + return Sequence( + requestInfo, + signatureAlgorithm ?? SignatureAlgorithm(EcdsaWithSha256), + BitString(Sign(signer, requestInfo, hash))); + } + + /// + /// A request for the key, signed by it, in the form that every rule accepts. + /// + public static byte[] For(ECDsa key) + { + return Request(RequestInfo(key.ExportSubjectPublicKeyInfo()), key); + } + + // X.690 §8.1.3: the short form below 128, then the long form in as few octets as the length needs. + private static byte[] Length(int length) + { + return length switch + { + < 0x80 => [(byte)length], + <= 0xFF => [0x81, (byte)length], + _ => [0x82, (byte)(length >> 8), (byte)length], + }; + } + + private static byte[] Der(Action write) + { + AsnWriter writer = new(AsnEncodingRules.DER); + write(writer); + + return writer.Encode(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestDoubles.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestDoubles.cs new file mode 100644 index 0000000000..42aa5b48b5 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestDoubles.cs @@ -0,0 +1,146 @@ +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// A clock that reads , which a test sets. +/// +internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider +{ + public DateTimeOffset Now { get; set; } = now; + + public override DateTimeOffset GetUtcNow() + { + return Now; + } +} + +/// +/// A clock that returns the given instants, one per reading, and then repeats the last one. +/// +internal sealed class SteppingTimeProvider(params DateTimeOffset[] readings) : TimeProvider +{ + private int _next; + + public override DateTimeOffset GetUtcNow() + { + DateTimeOffset reading = readings[Math.Min(_next, readings.Length - 1)]; + _next++; + return reading; + } +} + +/// +/// A registry over a dictionary, which records every lookup and can hold lookups until a test releases them. +/// +internal sealed class FakeCertificateRegistry : ICertificateRegistry +{ + private readonly Dictionary _certificates = new(StringComparer.Ordinal); + + public List Lookups { get; } = []; + + /// + /// A task that every lookup waits for before it answers, when set. + /// + public Task? Gate { get; set; } + + public void Register(string thumbprint, RegisteredCertificate certificate) + { + _certificates[thumbprint] = certificate; + } + + public void Register( + KeyVector key, + CertificateStatus status = CertificateStatus.Current, + bool identityRevoked = false) + { + Register(key.Thumbprint, new RegisteredCertificate(key.IdentityId, key.Certificate, status, identityRevoked)); + } + + public void Unregister(string thumbprint) + { + _certificates.Remove(thumbprint); + } + + public async ValueTask FindByThumbprintAsync( + string thumbprint, + CancellationToken cancellationToken) + { + Lookups.Add(thumbprint); + + if (Gate is { } gate) + { + await gate.WaitAsync(cancellationToken); + } + + return _certificates.GetValueOrDefault(thumbprint); + } +} + +/// +/// A nonce store over a set, which records every call and can be told to answer that every pair is already recorded. +/// +internal sealed class RecordingNonceStore : INonceStore +{ + private readonly HashSet<(string KeyId, string Nonce)> _recorded = []; + + public bool AnswerAlreadyRecorded { get; init; } + + public List<(string KeyId, string Nonce, DateTimeOffset RetainUntil)> Calls { get; } = []; + + public ValueTask TryRecordAsync( + string keyId, + string nonce, + DateTimeOffset retainUntil, + CancellationToken cancellationToken) + { + Calls.Add((keyId, nonce, retainUntil)); + return ValueTask.FromResult(!AnswerAlreadyRecorded && _recorded.Add((keyId, nonce))); + } +} + +/// +/// A generator whose bytes count up from zero, so that a test knows which bytes each call returns. +/// +internal sealed class CountingRandomNumberGenerator : RandomNumberGenerator +{ + private byte _next; + + public override void GetBytes(byte[] data) + { + GetBytes(data.AsSpan()); + } + + public override void GetBytes(Span data) + { + for (int i = 0; i < data.Length; i++) + { + data[i] = _next++; + } + } +} + +/// +/// A generator that returns the given bytes in order and fails when asked for more, +/// so that a test controls every random byte the library draws. +/// +internal sealed class ScriptedRandomNumberGenerator(byte[] bytes) : RandomNumberGenerator +{ + private int _next; + + public override void GetBytes(byte[] data) + { + GetBytes(data.AsSpan()); + } + + public override void GetBytes(Span data) + { + if (data.Length > bytes.Length - _next) + { + throw new InvalidOperationException("The test supplied fewer random bytes than the library drew."); + } + + bytes.AsSpan(_next, data.Length).CopyTo(data); + _next += data.Length; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs new file mode 100644 index 0000000000..1970e5d744 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestEnrollmentUrl.cs @@ -0,0 +1,162 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Builds the enrollment URL extension of CONTRACT.agent-identity.md §4 and the CSR attributes that request it. +/// +internal static class TestEnrollmentUrl +{ + // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension, under RFC 5612's documentation enterprise + // number until the contract moves it. + public const string Oid = "1.3.6.1.4.1.32473.1.1"; + + // RFC 2985 §5.4.2: the extensionRequest attribute. + public const string ExtensionRequestOid = "1.2.840.113549.1.9.14"; + + /// + /// The DER IA5String of a URL. + /// + public static byte[] IA5String(string url) + { + AsnWriter writer = new(AsnEncodingRules.DER); + writer.WriteCharacterString(UniversalTagNumber.IA5String, url); + return writer.Encode(); + } + + /// + /// An enrollment URL extension (RFC 5280 §4.1, Extension), non-critical, whose value is the given bytes. + /// + public static byte[] Extension(byte[] value, string oid = Oid) + { + AsnWriter writer = new(AsnEncodingRules.DER); + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(oid); + writer.WriteOctetString(value); + } + + return writer.Encode(); + } + + /// + /// The enrollment URL extension of a URL with its critical flag set. + /// + public static byte[] CriticalExtension(string url) + { + AsnWriter writer = new(AsnEncodingRules.DER); + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(Oid); + writer.WriteBoolean(true); + writer.WriteOctetString(IA5String(url)); + } + + return writer.Encode(); + } + + /// + /// The [0] attributes of a CSR holding another attribute, given whole, then one extensionRequest. + /// + public static byte[] AttributesAfter(byte[] otherAttribute, params byte[][] extensions) + { + return TestCsr.Tlv(0xA0, otherAttribute, ExtensionRequestAttribute(extensions)); + } + + /// + /// The same constructed element with an indefinite length (X.690 §8.1.3.6): its tag, 80, its contents, then 00 00. + /// + public static byte[] IndefiniteLength(byte[] element) + { + AsnDecoder.ReadEncodedValue(element, AsnEncodingRules.DER, out int contentOffset, out int contentLength, out _); + + return [element[0], 0x80, .. element.AsSpan(contentOffset, contentLength), 0x00, 0x00]; + } + + /// + /// The enrollment URL extension of a URL, as the agent's CSR requests it. + /// + public static byte[] Extension(string url) + { + return Extension(IA5String(url)); + } + + /// + /// The [0] attributes of a CSR (RFC 2986 §4) holding one extensionRequest of the given extensions. + /// + public static byte[] Attributes(params byte[][] extensions) + { + AsnWriter writer = new(AsnEncodingRules.DER); + using (writer.PushSetOf(new Asn1Tag(TagClass.ContextSpecific, 0))) + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(ExtensionRequestOid); + using (writer.PushSetOf()) + using (writer.PushSequence()) + { + foreach (byte[] extension in extensions) + { + writer.WriteEncodedValue(extension); + } + } + } + + return writer.Encode(); + } + + /// + /// The [0] attributes of a CSR holding one extensionRequest per given extension. + /// + public static byte[] AttributesWithAnExtensionRequestEach(params byte[][] extensions) + { + return TestCsr.Tlv(0xA0, [.. extensions.Select(extension => ExtensionRequestAttribute(extension))]); + } + + // The one extensionRequest Attribute (RFC 2986 §4) that Attributes writes for the given extensions. + private static byte[] ExtensionRequestAttribute(params byte[][] extensions) + { + return new AsnReader(Attributes(extensions), AsnEncodingRules.DER) + .ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0)) + .ReadEncodedValue() + .ToArray(); + } + + /// + /// A CSR for the key, signed by it, with the given attributes. + /// + public static byte[] Request(ECDsa key, byte[] attributes) + { + return TestCsr.Request(TestCsr.RequestInfo(key.ExportSubjectPublicKeyInfo(), attributes: attributes), key); + } + + /// + /// The CSR an agent sends: one extensionRequest holding the enrollment URL extension alone. + /// + public static byte[] Request(ECDsa key, string url) + { + return Request(key, Attributes(Extension(url))); + } + + /// + /// The validated CSR an agent sends for a new key, with the enrollment URL extension of a URL. + /// + public static CertificateSigningRequest Csr(string url) + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CsrValidationResult result = CertificateSigningRequest.Validate(Request(key, url)); + Assert.True(result.Succeeded, result.FailureDetail); + return result.Csr; + } + + /// + /// A validated CSR for a new key that names no enrollment URL, with an empty attribute set. + /// + public static CertificateSigningRequest CsrWithoutEnrollmentUrl() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CsrValidationResult result = CertificateSigningRequest.Validate(TestCsr.For(key)); + Assert.True(result.Succeeded, result.FailureDetail); + return result.Csr; + } +} \ No newline at end of file diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs new file mode 100644 index 0000000000..0140212c14 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestIdentity.cs @@ -0,0 +1,31 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Identities that the library authenticated, as a host receives them. +/// +internal static class TestIdentity +{ + /// + /// Authenticates a request of device-a for an operation through the public API: + /// connect through the channel handshake, the others as signed requests. + /// + public static async Task AuthenticateAsync(AgentOperation operation) + { + TestAuthority authority = new(TestAuthority.DefaultNow); + + if (operation == AgentOperation.Connect) + { + ChannelOpening opening = await authority.OpenChannelAsync(); + ChannelHandshakeResult handshake = await opening.VerifyHelloAsync(TestAuthority.ValidHello(opening)); + Assert.True(handshake.Succeeded, handshake.FailureDetail); + + return handshake.Identity; + } + + SignedHttpRequest request = authority.Valid(operation).ToHttpRequest(); + AuthenticationResult result = await authority.Authenticator.AuthenticateAsync(operation, request); + Assert.True(result.Succeeded, result.FailureDetail); + + return result.Identity; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestJson.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestJson.cs new file mode 100644 index 0000000000..3a5d6ee2a7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestJson.cs @@ -0,0 +1,72 @@ +using System.Text.Json; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Reads JSON that the library wrote with the BCL alone, so that a library bug cannot make a body agree with itself. +/// +internal static class TestJson +{ + public const string LowercaseUuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + + /// + /// The members of a JSON text that holds one object, by name; a repeated member name fails. + /// + public static Dictionary Members(byte[] utf8Json) + { + using JsonDocument document = JsonDocument.Parse(utf8Json); + + return Members(document.RootElement.Clone()); + } + + /// + /// The members of an object, by name; a repeated member name fails. + /// + public static Dictionary Members(JsonElement element) + { + Assert.Equal(JsonValueKind.Object, element.ValueKind); + + return element + .EnumerateObject() + .ToDictionary(member => member.Name, member => member.Value, StringComparer.Ordinal); + } + + /// + /// The names of the members of an object, in ordinal order, for comparison with the members a contract lists. + /// + public static IEnumerable Names(Dictionary members) + { + return members.Keys.Order(StringComparer.Ordinal); + } + + /// + /// The elements of an array. + /// + public static JsonElement[] Elements(JsonElement element) + { + Assert.Equal(JsonValueKind.Array, element.ValueKind); + + return [.. element.EnumerateArray()]; + } + + /// + /// Decodes a string in canonical base64 (RFC 4648 §4): + /// the encoding that the BCL writes for the decoded bytes, with padding and without white space. + /// + public static byte[] CanonicalBase64(JsonElement element) + { + Assert.Equal(JsonValueKind.String, element.ValueKind); + string text = element.GetString()!; + byte[] bytes = Convert.FromBase64String(text); + Assert.Equal(Convert.ToBase64String(bytes), text); + + return bytes; + } + + public static JsonElement Parse(string text) + { + using JsonDocument document = JsonDocument.Parse(text); + + return document.RootElement.Clone(); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs new file mode 100644 index 0000000000..5fbc60b3f7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestRequest.cs @@ -0,0 +1,248 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// A request to sign, described by the parts of RFC 9421 that the profile constrains. +/// +/// +/// The signature base is built here by string formatting from RFC 9421 §2.5, never through the library, +/// so that a library bug cannot make a request agree with it. +/// Component identifiers and parameter values are written as Signature-Input and the base hold them. +/// +internal sealed record TestRequest +{ + public const string DefaultNonce = "AAECAwQFBgcICQoLDA0ODw"; + + public required AgentOperation Operation { get; init; } + + /// + /// The key that signs, whose thumbprint also writes as keyid. + /// + public required KeyVector Key { get; init; } + + public string Method { get; init; } = "POST"; + + /// + /// The serialized component identifiers, such as "@method". + /// + public required IReadOnlyList Components { get; init; } + + /// + /// The signature parameters in order, each with its serialized value, such as "renew" with its quotes. + /// + public required IReadOnlyList<(string Key, string Value)> Parameters { get; init; } + + public byte[] Body { get; init; } = []; + + /// + /// The Content-Digest field value, which is also the value of a covered content-digest. + /// + public string? ContentDigest { get; init; } + + /// + /// The value of @signature-params that the signature covers, as RFC 9421 §2.3 serializes it. + /// + public string SignatureParams => $"({string.Join(' ', Components)}){ParametersText}"; + + /// + /// The parameters as they follow the inner list, such as ;created=1;expires=61. + /// + public string ParametersText => + string.Concat(Parameters.Select(parameter => $";{parameter.Key}={parameter.Value}")); + + // RFC 9421 §2.5. + public string SignatureBase + { + get + { + StringBuilder builder = new(); + + foreach (string component in Components) + { + builder.Append(component).Append(": ").Append(ComponentValue(component)).Append('\n'); + } + + return builder.Append("\"@signature-params\": ").Append(SignatureParams).ToString(); + } + } + + /// + /// A request that every rule of CONTRACT.agent-identity.md §6 accepts at , + /// when its key is registered. + /// + public static TestRequest Valid( + AgentOperation operation, + DateTimeOffset now, + KeyVector? key = null, + string nonce = DefaultNonce) + { + key ??= TestVectors.Instance.Key("device-a"); + long created = now.ToUnixTimeSeconds(); + bool coversBody = operation is AgentOperation.Renew or AgentOperation.CheckIn; + byte[] body = coversBody ? Encoding.UTF8.GetBytes("{\"metadata\":{\"hostname\":\"test-host\"}}") : []; + + return new TestRequest + { + Operation = operation, + Key = key, + Components = coversBody ? ["\"@method\"", "\"content-digest\""] : ["\"@method\""], + Parameters = + [ + ("created", created.ToString(CultureInfo.InvariantCulture)), + ("expires", (created + 60).ToString(CultureInfo.InvariantCulture)), + ("nonce", $"\"{nonce}\""), + ("keyid", $"\"{key.Thumbprint}\""), + ("alg", "\"ecdsa-p256-sha256\""), + ("tag", $"\"{Tag(operation)}\""), + ], + Body = body, + ContentDigest = coversBody ? Sha256Digest(body) : null, + }; + } + + // CONTRACT.agent-identity.md §6, column "tag". + public static string Tag(AgentOperation operation) + { + return operation switch + { + AgentOperation.Renew => "renew", + AgentOperation.Confirm => "confirm", + AgentOperation.CheckIn => "check-in", + AgentOperation.Connect => "connect", + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "Not an operation."), + }; + } + + // RFC 9530 §2. + public static string Sha256Digest(byte[] body) + { + return $"sha-256=:{Convert.ToBase64String(SHA256.HashData(body))}:"; + } + + /// + /// Replaces the serialized value of a parameter, keeping its position. + /// + public TestRequest WithParameter(string key, string value) + { + return this with + { + Parameters = [.. Parameters.Select(parameter => parameter.Key == key ? (key, value) : parameter)], + }; + } + + /// + /// Signs the base with and returns the signature in base64. + /// + /// + /// ECDSA signing is randomized, so each call returns another valid signature. + /// + public string Sign() + { + using ECDsa privateKey = ECDsa.Create(); + privateKey.ImportPkcs8PrivateKey(Key.PrivateKeyPkcs8, out _); + + byte[] signature = privateKey.SignData( + Encoding.UTF8.GetBytes(SignatureBase), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + return Convert.ToBase64String(signature); + } + + /// + /// Signs until the base64 signature satisfies . + /// + public string SignUntil(Func condition) + { + for (int attempt = 0; attempt < 1000; attempt++) + { + string signature = Sign(); + if (condition(signature)) + { + return signature; + } + } + + throw new InvalidOperationException("No signature satisfied the condition."); + } + + /// + /// Builds the HTTP request, with each field written as the profile expects unless a test passes its own lines. + /// + public SignedHttpRequest ToHttpRequest( + IReadOnlyList? signatureInput = null, + IReadOnlyList? signature = null, + IReadOnlyList? contentDigest = null) + { + return new SignedHttpRequest( + Method, + signatureInput ?? [$"sig={SignatureParams}"], + signature ?? [$"sig=:{Sign()}:"], + contentDigest ?? (ContentDigest is null ? [] : [ContentDigest]), + Body); + } + + private string ComponentValue(string identifier) + { + if (identifier.StartsWith("\"@method\"", StringComparison.Ordinal)) + { + return Method; + } + + if (identifier.StartsWith("\"content-digest\"", StringComparison.Ordinal)) + { + return ContentDigest + ?? throw new InvalidOperationException("The request covers content-digest without one."); + } + + throw new InvalidOperationException($"The request covers {identifier}, which it has no value for."); + } +} + +/// +/// Alterations of canonical base64 and base64url text that keep the value a lenient decoder reads. +/// +internal static class Base64Forms +{ + private const string Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + private const string Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + /// + /// Sets a pad bit of the final quantum, which RFC 4648 §3.5 leaves to the decoder. + /// + public static string WithNonZeroPadBits(string canonical, bool urlSafe) + { + string alphabet = urlSafe ? Base64UrlAlphabet : Base64Alphabet; + string data = canonical.TrimEnd('='); + + // Only a final quantum of one or two bytes has pad bits. + if (data.Length % 4 == 0) + { + throw new ArgumentException("The value has no pad bits.", nameof(canonical)); + } + + // The lowest bit of the last character is a pad bit. + char last = data[^1]; + char altered = alphabet[alphabet.IndexOf(last, StringComparison.Ordinal) ^ 1]; + + return string.Concat(data.AsSpan(0, data.Length - 1), [altered], canonical.AsSpan(data.Length)); + } + + public static string ToBase64UrlAlphabet(string base64) + { + return base64.Replace('+', '-').Replace('/', '_'); + } + + public static string ToBase64Alphabet(string base64Url) + { + return base64Url.Replace('-', '+').Replace('_', '/'); + } + + public static string Base64Url(byte[] bytes) + { + return ToBase64UrlAlphabet(Convert.ToBase64String(bytes)).TrimEnd('='); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs new file mode 100644 index 0000000000..b5ca79fedc --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/TestVectors.cs @@ -0,0 +1,203 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The shared vectors of docs/agent-identity/test-vectors.json, which the test project copies into its output. +/// +/// +/// The BCL decodes the byte strings, never the library under test, +/// so that a decoding bug in the library cannot make a vector agree with it. +/// Loading fails on a missing or unknown member, +/// so that a change to the file that the loader does not model is noticed rather than ignored. +/// Unix-second timestamps are read as . +/// +public sealed record TestVectors( + int Version, + string Description, + string Evaluation, + IReadOnlyList Keys, + RootVector Root, + IReadOnlyDictionary Csr, + HttpSignatureVectors HttpSignature, + ChannelProofVectors ChannelProof, + [property: JsonPropertyName("rfc9421_b_2_4")] Rfc9421Vector Rfc9421B24) +{ + private const int SupportedVersion = 1; + + private static readonly Lazy s_instance = new(Load); + + public static TestVectors Instance => s_instance.Value; + + public KeyVector Key(string name) + { + return Keys.Single(key => key.Name == name); + } + + private static TestVectors Load() + { + JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + RespectNullableAnnotations = true, + RespectRequiredConstructorParameters = true, + Converters = { new UnixSecondsConverter() }, + }; + + using FileStream file = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "test-vectors.json")); + TestVectors vectors = JsonSerializer.Deserialize(file, options) + ?? throw new InvalidDataException("The vector file holds null."); + + if (vectors.Version != SupportedVersion) + { + throw new InvalidDataException( + $"The loader reads version {SupportedVersion} of the vector file, not version {vectors.Version}."); + } + + return vectors; + } +} + +public sealed record KeyVector( + string Name, + Guid IdentityId, + byte[] PrivateKeyPkcs8, + byte[] PublicKeySec1, + byte[] Certificate, + string Thumbprint); + +public sealed record RootVector(byte[] Certificate, string Thumbprint, byte[] PublicKeySec1); + +public sealed record CsrVector( + byte[] Csr, + string Expected, + IReadOnlyDictionary ExpectedAt, + string? Description = null, + byte[]? PublicKeySec1 = null, + byte[]? PrivateKeyPkcs8 = null, + string? EnrollmentUrl = null); + +public sealed record HttpSignatureVectors( + IReadOnlyList Registered, + IReadOnlyList Cases, + IReadOnlyList Sequences) +{ + public RegisteredCertificateVector RegisteredCertificate(string name) + { + return Registered.Single(registered => registered.Name == name); + } + + public HttpSignatureCaseVector Case(string name) + { + return Cases.Single(c => c.Name == name); + } + + public HttpSignatureSequenceVector Sequence(string name) + { + return Sequences.Single(sequence => sequence.Name == name); + } +} + +public sealed record RegisteredCertificateVector( + string Name, + Guid IdentityId, + [property: JsonPropertyName("keyid")] string KeyId, + byte[] PublicKeySec1, + byte[] Certificate, + string CertificateStatus, + bool IdentityRevoked, + DateTimeOffset NotBefore, + DateTimeOffset NotAfter); + +/// ok or the wire value of an error code. +/// The request body as JSON text, absent for operations without one. +public sealed record HttpSignatureCaseVector( + string Name, + string Description, + string Endpoint, + string Method, + DateTimeOffset Now, + HttpSignatureHeaders Headers, + string SignatureBase, + string Expected, + string? Body = null) +{ + // The vector's content digest covers the UTF-8 encoding of the body text. + public byte[] BodyBytes => Body is null ? [] : Encoding.UTF8.GetBytes(Body); +} + +/// +/// The fields of a request, each a single field line; an absent field is null. +/// +/// A field that the signature profile does not cover. +public sealed record HttpSignatureHeaders( + [property: JsonPropertyName("signature-input")] string? SignatureInput = null, + [property: JsonPropertyName("signature")] string? Signature = null, + [property: JsonPropertyName("content-digest")] string? ContentDigest = null, + [property: JsonPropertyName("content-type")] string? ContentType = null); + +/// Names of cases to run in order against one nonce store. +/// The outcome of each step, as in . +public sealed record HttpSignatureSequenceVector( + string Name, + string Description, + IReadOnlyList Steps, + IReadOnlyList Expected); + +/// +/// The nonce parameter of the opening request, whose UTF-8 bytes end the proof input. +/// +/// The proof input that the valid proof signs. +public sealed record ChannelProofVectors( + string Description, + string Device, + byte[] PublicKeySec1, + byte[] Challenge, + string ConnectNonce, + string ConnectCase, + [property: JsonPropertyName("message_hex"), JsonConverter(typeof(HexConverter))] byte[] Message, + IReadOnlyList Cases) +{ + public ChannelProofCaseVector Case(string name) + { + return Cases.Single(c => c.Name == name); + } +} + +public sealed record ChannelProofCaseVector(string Name, string Description, byte[] Proof, bool ExpectedValid); + +public sealed record Rfc9421Vector( + string Description, + byte[] PublicKeySpki, + string SignatureBase, + byte[] Signature, + bool ExpectedValid); + +internal sealed class UnixSecondsConverter : JsonConverter +{ + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateTimeOffset.FromUnixTimeSeconds(reader.GetInt64()); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.ToUnixTimeSeconds()); + } +} + +internal sealed class HexConverter : JsonConverter +{ + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Convert.FromHexString(reader.GetString() ?? throw new JsonException("Expected a hexadecimal string.")); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + writer.WriteStringValue(Convert.ToHexStringLower(value)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs new file mode 100644 index 0000000000..b0104e3dea --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/UnacceptableUrls.cs @@ -0,0 +1,69 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// URLs that are not an absolute https URL without an @, a query or a fragment, +/// the form that a token's u and a config's agent_channel_url both take. +/// +public static class UnacceptableUrls +{ + // CONTRACT.agent-identity.md §2 makes `u`, and §5.2 makes `agent_channel_url`, an absolute `https` URL + // without a user name or password, a query or a fragment, read as text: + // it begins with `https://` and contains no '@', since any '@' counts as a user name. + public static string[] All { get; } = + [ + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "http://host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "wss://host/dvls", + // oracle: spec RFC 9110 §4.2.2 + "https:///dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://host/dvls?tenant=1", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: spec RFC 3986 §3.4 + "https://host/dvls?", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://host/dvls#top", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: spec RFC 3986 §3.5 + "https://host/dvls#", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://admin@host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://admin:secret@host/dvls", + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://:secret@host/dvls", + // Any '@' counts as a user name, even with nothing before it. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://@host/dvls", + // Read as text, a URL contains no '@' anywhere, even after the authority. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https://host/dvls/a@b", + // Read as text, a URL begins with `https://`. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + "https:@host/dvls", + ]; + + public static TheoryData Rows => new(All); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs new file mode 100644 index 0000000000..61aed832a9 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorAuthority.cs @@ -0,0 +1,116 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The authority that http_signature describes: its registered certificates, an empty nonce store, +/// and a clock that each case sets to its now. +/// +internal sealed class VectorAuthority +{ + public VectorAuthority() + { + foreach (RegisteredCertificateVector registered in TestVectors.Instance.HttpSignature.Registered) + { + Registry.Register( + registered.KeyId, + new RegisteredCertificate( + registered.IdentityId, + registered.Certificate, + Status(registered.CertificateStatus), + registered.IdentityRevoked)); + } + + Authenticator = new RequestAuthenticator(Registry, Nonces, Clock, Random); + } + + public FakeCertificateRegistry Registry { get; } = new(); + + public RecordingNonceStore Nonces { get; } = new(); + + public FixedTimeProvider Clock { get; } = new(DateTimeOffset.UnixEpoch); + + public CountingRandomNumberGenerator Random { get; } = new(); + + public RequestAuthenticator Authenticator { get; } + + /// + /// Runs a case through the public API: connect as a channel opening, the others as signed requests. + /// + public async Task RunAsync(HttpSignatureCaseVector vector) + { + Clock.Now = vector.Now; + + if (vector.Endpoint == "connect") + { + ChannelOpeningResult opening = await OpenChannelAsync(Authenticator, vector); + + return opening.Succeeded + ? VectorOutcome.Ok + : new VectorOutcome(opening.Error.Code.ToWireValue(), opening.FailureDetail); + } + + SignedHttpRequest request = new( + vector.Method, + Lines(vector.Headers.SignatureInput), + Lines(vector.Headers.Signature), + Lines(vector.Headers.ContentDigest), + vector.BodyBytes); + AuthenticationResult result = await Authenticator.AuthenticateAsync(Operation(vector.Endpoint), request); + + return result.Succeeded + ? VectorOutcome.Ok + : new VectorOutcome(result.Error.Code.ToWireValue(), result.FailureDetail); + } + + /// + /// Authenticates the opening request of a connect case through the public API. + /// + public static ValueTask OpenChannelAsync( + RequestAuthenticator authenticator, + HttpSignatureCaseVector vector) + { + return authenticator.AuthenticateChannelOpeningAsync( + vector.Method, + Lines(vector.Headers.SignatureInput), + Lines(vector.Headers.Signature)); + } + + /// + /// The field lines of a vector header, which holds at most one. + /// + public static string[] Lines(string? header) + { + return header is null ? [] : [header]; + } + + // CONTRACT.agent-identity.md §5: each endpoint is one operation. + public static AgentOperation Operation(string endpoint) + { + return endpoint switch + { + "renew" => AgentOperation.Renew, + "confirm" => AgentOperation.Confirm, + "check-in" => AgentOperation.CheckIn, + "connect" => AgentOperation.Connect, + _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, "Not an endpoint of the vectors."), + }; + } + + // CONTRACT.agent-identity.md "Definitions": the certificate statuses. + private static CertificateStatus Status(string status) + { + return status switch + { + "pending" => CertificateStatus.Pending, + "current" => CertificateStatus.Current, + "retired" => CertificateStatus.Retired, + _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Not a certificate status."), + }; + } +} + +/// ok or the wire value of the error code, as the vector file writes outcomes. +/// The failure detail, when the request failed. +internal sealed record VectorOutcome(string Outcome, string? FailureDetail) +{ + public static VectorOutcome Ok { get; } = new("ok", null); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs new file mode 100644 index 0000000000..ae7578c52e --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorChannel.cs @@ -0,0 +1,126 @@ +using Devolutions.Agent.Channel.V1; + +using Google.Protobuf; + +namespace Devolutions.AgentIdentity.Tests; + +/// +/// The agent channel stream that channel_proof describes: +/// its connect_case opened at that case's now, with the vector challenge. +/// +internal static class VectorChannel +{ + private static ChannelProofVectors Vectors => TestVectors.Instance.ChannelProof; + + private static HttpSignatureCaseVector ConnectCase => TestVectors.Instance.HttpSignature.Case(Vectors.ConnectCase); + + /// + /// The now of the opening request, at which the stream opens unless a test passes another clock. + /// + public static DateTimeOffset OpeningTime => ConnectCase.Now; + + /// + /// Metadata that CONTRACT.agent-identity.md §3 accepts, with the keys that §3.1 says the agent always sends. + /// + public static IReadOnlyList> ValidMetadata { get; } = + [ + new("hostname", "device-a"), + new("os_name", "Windows"), + new("os_version", "10.0.26100"), + new("arch", "x86_64"), + new("agent_version", "2026.3.0"), + ]; + + /// + /// Opens the stream through the public API. + /// + /// The authority's clock, which reads when null. + /// + /// The status to register the device's certificate with, instead of the status that the vector file gives it. + /// + public static Task OpenAsync(TimeProvider? clock = null, CertificateStatus? status = null) + { + return OpenAsync(new VectorAuthority(), clock, status); + } + + /// + /// Opens the stream through the public API, with the registry and the nonce store of , + /// which the test can then change before the handshake. + /// + public static Task OpenAsync( + VectorAuthority authority, + TimeProvider? clock = null, + CertificateStatus? status = null) + { + if (status is { } registeredStatus) + { + RegisterDevice(authority.Registry, registeredStatus, identityRevoked: false); + } + + return OpenAsync(authority.Registry, authority.Nonces, clock); + } + + /// + /// Opens the stream through the public API, over , + /// which must answer for the device's certificate as the vector file registers it. + /// + public static async Task OpenAsync( + ICertificateRegistry registry, + INonceStore? nonces = null, + TimeProvider? clock = null) + { + RequestAuthenticator authenticator = new( + registry, + nonces ?? new RecordingNonceStore(), + clock ?? new FixedTimeProvider(OpeningTime), + new ScriptedRandomNumberGenerator(Vectors.Challenge)); + + ChannelOpeningResult result = await VectorAuthority.OpenChannelAsync(authenticator, ConnectCase); + + // oracle: reference docs/agent-identity/test-vectors.json channel_proof.connect_case + Assert.True(result.Succeeded, result.FailureDetail); + Assert.Equal(Vectors.Challenge, result.Opening.Challenge.Challenge.Challenge_.ToByteArray()); + return result.Opening; + } + + /// + /// Registers the device's certificate again, as the vector file registers it but with this status and revocation. + /// + public static void RegisterDevice(FakeCertificateRegistry registry, CertificateStatus status, bool identityRevoked) + { + RegisteredCertificateVector device = TestVectors.Instance.HttpSignature.RegisteredCertificate(Vectors.Device); + registry.Register( + device.KeyId, + new RegisteredCertificate(device.IdentityId, device.Certificate, status, identityRevoked)); + } + + /// + /// A Hello that answers the Challenge of with + /// and . + /// + public static AgentMessage HelloMessage(ChannelOpening opening, byte[] proof) + { + AgentMessage message = new() + { + Id = Guid.NewGuid().ToString("D"), + CorrelationId = opening.Challenge.Id, + Hello = new Hello { Proof = ByteString.CopyFrom(proof) }, + }; + + foreach ((string key, string value) in ValidMetadata) + { + message.Hello.Metadata.Add(key, value); + } + + return message; + } + + /// + /// A Hello that the stream accepts: + /// with the proof of the vector case valid. + /// + public static AgentMessage ValidHelloMessage(ChannelOpening opening) + { + return HelloMessage(opening, Vectors.Case("valid").Proof); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs new file mode 100644 index 0000000000..978ad30736 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Support/VectorData.cs @@ -0,0 +1,19 @@ +namespace Devolutions.AgentIdentity.Tests; + +/// +/// Theory data that yields one row per vector name, so that each vector runs and reports as its own test. +/// +public static class VectorData +{ + public static TheoryData CsrNames => + new(TestVectors.Instance.Csr.Keys); + + public static TheoryData HttpSignatureCaseNames => + new(TestVectors.Instance.HttpSignature.Cases.Select(c => c.Name)); + + public static TheoryData HttpSignatureSequenceNames => + new(TestVectors.Instance.HttpSignature.Sequences.Select(sequence => sequence.Name)); + + public static TheoryData ChannelProofCaseNames => + new(TestVectors.Instance.ChannelProof.Cases.Select(c => c.Name)); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Usings.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Usings.cs new file mode 100644 index 0000000000..c802f4480b --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AgentOperation.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AgentOperation.cs new file mode 100644 index 0000000000..c47e17d1f7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AgentOperation.cs @@ -0,0 +1,36 @@ +namespace Devolutions.AgentIdentity; + +/// +/// An operation that an agent authenticates with a signed request. +/// +/// +/// Each operation has its own tag, covered components and rule for the certificate it accepts, +/// so a signature made for one operation never authenticates another. +/// +public enum AgentOperation +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering an operation would change what an existing host asks for. + // When adding an operation, give it the next unused value; + // values start at 1 so that an uninitialized operation is not a valid one. + + /// + /// POST renew, signed with tag renew. + /// + Renew = 1, + + /// + /// POST confirm, signed with tag confirm. + /// + Confirm = 2, + + /// + /// POST check-in, signed with tag check-in. + /// + CheckIn = 3, + + /// + /// The opening request of an agent channel stream, signed with tag connect. + /// + Connect = 4, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs new file mode 100644 index 0000000000..eeadd7dd89 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticatedIdentity.cs @@ -0,0 +1,64 @@ +namespace Devolutions.AgentIdentity; + +/// +/// An identity that a signed request, or the proof that authenticates an agent channel stream, proved. +/// +/// +/// Only request authentication and the channel handshake create one, so code that takes this type acts for an +/// identity that a signature check established, never for one taken from a value that the agent or a caller sent. +/// +public sealed class AuthenticatedIdentity +{ + internal AuthenticatedIdentity( + Guid identityId, + string certificateThumbprint, + CertificateStatus certificateStatus, + DateTimeOffset certificateNotBefore, + DateTimeOffset certificateNotAfter, + AgentOperation operation) + { + IdentityId = identityId; + CertificateThumbprint = certificateThumbprint; + CertificateStatus = certificateStatus; + CertificateNotBefore = certificateNotBefore; + CertificateNotAfter = certificateNotAfter; + Operation = operation; + } + + /// + /// The identity that the request proved. + /// + public Guid IdentityId { get; } + + /// + /// The thumbprint of the certificate whose key signed the request. + /// + public string CertificateThumbprint { get; } + + /// + /// The status of the certificate whose key signed the request, as the registry held it when the request + /// authenticated. + /// + /// + /// It is or , + /// or for an agent channel stream whose certificate was retired + /// during the handshake. + /// CONTRACT.agent-identity.md §7.4 says how the host treats a stream of a retired certificate. + /// + public CertificateStatus CertificateStatus { get; } + + /// + /// The notBefore of the certificate whose key signed the request. + /// + public DateTimeOffset CertificateNotBefore { get; } + + /// + /// The notAfter of the certificate whose key signed the request. + /// + public DateTimeOffset CertificateNotAfter { get; } + + /// + /// The operation that the request was signed for. + /// + public AgentOperation Operation { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs new file mode 100644 index 0000000000..5dae52dd53 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/AuthenticationResult.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of authenticating a signed request. +/// +/// +/// names the rule that the request broke and the step of +/// CONTRACT.agent-identity.md §6 that checks it. +/// +public sealed class AuthenticationResult : AgentIdentityResult +{ + private AuthenticationResult(AuthenticatedIdentity? identity, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) + { + Identity = identity; + } + + /// + /// Whether the request authenticated. + /// + [MemberNotNullWhen(true, nameof(Identity))] + public override bool Succeeded => Identity is not null; + + /// + /// The identity that the request proved, when it authenticated. + /// + public AuthenticatedIdentity? Identity { get; } + + internal static AuthenticationResult Success(AuthenticatedIdentity identity) + { + return new AuthenticationResult(identity, null, null); + } + + internal static AuthenticationResult Failure(AgentIdentityError error, string failureDetail) + { + return new AuthenticationResult(null, error, failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/EcdsaP256Signature.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/EcdsaP256Signature.cs new file mode 100644 index 0000000000..09bd78bc86 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/EcdsaP256Signature.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity; + +/// +/// The signature algorithm of request signatures and channel proofs: ECDSA P-256 over SHA-256, as raw r‖s. +/// +internal static class EcdsaP256Signature +{ + /// + /// The length of a signature: r and s, each a 32-byte big-endian unsigned integer. + /// + public const int Length = 64; + + /// + /// Verifies a signature over data, as RFC 9421 §3.3.4 defines for ecdsa-p256-sha256. + /// + /// A P-256 public key. + /// The signed bytes, which are hashed with SHA-256. + /// The signature, which fails unless it is exactly 64 bytes. + public static bool Verify(ECDsa publicKey, ReadOnlySpan data, ReadOnlySpan signature) + { + return signature.Length == Length + && publicKey.VerifyData( + data, + signature, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/FieldLines.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/FieldLines.cs new file mode 100644 index 0000000000..3200c33ff5 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/FieldLines.cs @@ -0,0 +1,36 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Helpers for the lines of an HTTP field, which a request can split a field value across. +/// +internal static class FieldLines +{ + /// + /// Copies the field lines that a caller passed, rejecting a null line. + /// + public static string[] Copy(IReadOnlyList fieldLines, string parameterName) + { + ArgumentNullException.ThrowIfNull(fieldLines, parameterName); + + string[] copy = [.. fieldLines]; + if (Array.IndexOf(copy, null) >= 0) + { + throw new ArgumentException("A field line is null.", parameterName); + } + + return copy; + } + + /// + /// Combines field lines into one field value, as RFC 9421 §2.1 does for a covered field. + /// + /// + /// Each line loses its leading and trailing white space, + /// and the lines are joined in order with a comma and a space. + /// RFC 9110 §5.3 combines lines the same way, so one combined value serves both parsing and the signature base. + /// + public static string Combine(IReadOnlyList fieldLines) + { + return string.Join(", ", fieldLines.Select(line => line.Trim(' ', '\t'))); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/ICertificateRegistry.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/ICertificateRegistry.cs new file mode 100644 index 0000000000..f39d280f42 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/ICertificateRegistry.cs @@ -0,0 +1,25 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Finds the certificates that the authority issued, by thumbprint. +/// +/// +/// The host implements it over its own storage. +/// A signed request names its certificate only by thumbprint, so this lookup is the only way it is resolved. +/// +public interface ICertificateRegistry +{ + /// + /// Finds the certificate with a thumbprint. + /// + /// + /// The thumbprint, base64url(SHA-256(certificate DER)), + /// as computes it. + /// + /// A token that cancels the lookup. + /// + /// The certificate in any status, including , + /// or null when the authority issued no certificate with this thumbprint or deleted its identity. + /// + ValueTask FindByThumbprintAsync(string thumbprint, CancellationToken cancellationToken); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/INonceStore.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/INonceStore.cs new file mode 100644 index 0000000000..efcbb89ec9 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/INonceStore.cs @@ -0,0 +1,29 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Records the nonces of accepted signatures, so that each signature is accepted at most once. +/// +/// +/// The host implements it over its own storage. +/// Request authentication records a nonce only after the signature and the body digest verify, +/// so requests that fail verification cannot fill the store. +/// +public interface INonceStore +{ + /// + /// Records a nonce for a key unless the pair is already recorded, as one atomic operation. + /// + /// The keyid of the signature, which is the thumbprint of its certificate. + /// The nonce of the signature, as its canonical base64url text. + /// + /// The last instant at which the signature could be accepted again, + /// until which the store keeps the pair; it may discard the pair afterwards. + /// + /// A token that cancels the operation. + /// True when this call recorded the pair; false when the pair was already recorded. + ValueTask TryRecordAsync( + string keyId, + string nonce, + DateTimeOffset retainUntil, + CancellationToken cancellationToken); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RegisteredCertificate.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RegisteredCertificate.cs new file mode 100644 index 0000000000..d954c9ac41 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RegisteredCertificate.cs @@ -0,0 +1,58 @@ +namespace Devolutions.AgentIdentity; + +/// +/// A certificate that the authority issued to an identity, with the state the authority keeps beside it. +/// +/// +/// Request authentication reads the validity and the public key from , +/// so the host stores no copy of them. +/// +public sealed class RegisteredCertificate +{ + /// + /// Creates a registered certificate. + /// + /// The identity that the certificate was issued to. + /// The DER encoding of the certificate, exactly as the authority issued it. + /// The status of the certificate. + /// Whether the identity is revoked. + /// is not a defined status. + public RegisteredCertificate( + Guid identityId, + ReadOnlyMemory certificateDer, + CertificateStatus status, + bool identityRevoked) + { + // An undefined status is a host bug, + // which must fail as a server error rather than draw an outcome that is terminal for the agent. + if (!Enum.IsDefined(status)) + { + throw new ArgumentOutOfRangeException(nameof(status), status, "Not a defined certificate status."); + } + + IdentityId = identityId; + CertificateDer = certificateDer; + Status = status; + IdentityRevoked = identityRevoked; + } + + /// + /// The identity that the certificate was issued to. + /// + public Guid IdentityId { get; } + + /// + /// The DER encoding of the certificate, whose thumbprint the registry looked up. + /// + public ReadOnlyMemory CertificateDer { get; } + + /// + /// The status of the certificate. + /// + public CertificateStatus Status { get; } + + /// + /// Whether the identity is revoked. + /// + public bool IdentityRevoked { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs new file mode 100644 index 0000000000..4a9b7581d1 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/RequestAuthenticator.cs @@ -0,0 +1,420 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity; + +/// +/// Authenticates the signed requests of agents, as CONTRACT.agent-identity.md §6 specifies. +/// +/// +/// Each request goes through the six steps of CONTRACT.agent-identity.md §6 in order, +/// and the first step that fails decides the error. +/// Exceptions from the certificate registry and the nonce store propagate to the caller. +/// +public sealed class RequestAuthenticator +{ + // CONTRACT.agent-identity.md §6 step 3. + private const long MaxSignatureLifetimeSeconds = 300; + private const long ClockToleranceSeconds = 60; + + // CONTRACT.agent-identity.md §7.3 step 2. + private const int ChallengeLength = 32; + + // RFC 5480 §2.1.1: id-ecPublicKey. + private const string EcPublicKeyOid = "1.2.840.10045.2.1"; + + // CONTRACT.agent-identity.md §6 step 6, and the shared intent: + // a nonce is kept until 60 s after its signature expires. + private static readonly TimeSpan NonceRetentionAfterExpiry = TimeSpan.FromSeconds(60); + + // RFC 5480 §2.1.1.1: the DER encoding of the namedCurve secp256r1, which is P-256. + private static readonly byte[] P256CurveParameters = [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]; + + private readonly ICertificateRegistry _certificateRegistry; + private readonly INonceStore _nonceStore; + private readonly TimeProvider _timeProvider; + private readonly RandomNumberGenerator _randomNumberGenerator; + + /// + /// Creates an authenticator over the host's certificate registry and nonce store. + /// + /// The certificates that the authority issued. + /// The nonces of accepted signatures. + /// The authority's clock. + /// The source of the random bytes of each channel challenge. + /// An argument is null. + public RequestAuthenticator( + ICertificateRegistry certificateRegistry, + INonceStore nonceStore, + TimeProvider timeProvider, + RandomNumberGenerator randomNumberGenerator) + { + ArgumentNullException.ThrowIfNull(certificateRegistry); + ArgumentNullException.ThrowIfNull(nonceStore); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(randomNumberGenerator); + + _certificateRegistry = certificateRegistry; + _nonceStore = nonceStore; + _timeProvider = timeProvider; + _randomNumberGenerator = randomNumberGenerator; + } + + /// + /// Authenticates a signed renew, confirm or check-in request. + /// + /// + /// A body larger than fails first, + /// with . + /// The request then goes through the steps of CONTRACT.agent-identity.md §6, + /// and its nonce is recorded only once every other step has passed. + /// + /// The operation of the endpoint that received the request. + /// The request, as received. + /// A token that cancels the registry lookup and the nonce recording. + /// + /// is , + /// which authenticates, or is not a defined operation. + /// + /// is null. + /// + /// The registry returned a certificate with another thumbprint, or one whose key is not a P-256 key. + /// + public async ValueTask AuthenticateAsync( + AgentOperation operation, + SignedHttpRequest request, + CancellationToken cancellationToken = default) + { + // A signed opening request never authenticates a stream on its own, + // so only the channel handshake turns a verified opening into an identity. + if (operation == AgentOperation.Connect) + { + throw new ArgumentOutOfRangeException( + nameof(operation), + operation, + "A channel opening is authenticated by AuthenticateChannelOpeningAsync."); + } + + SignedOperation signedOperation = SignedOperation.For(operation); + ArgumentNullException.ThrowIfNull(request); + + if (request.Body.Length > AgentIdentityProtocol.MaxRequestBodyBytes) + { + return AuthenticationResult.Failure( + AgentIdentityError.BodyTooLarge, + "The request body is larger than 64 KiB."); + } + + Verification verification = await VerifyAsync( + signedOperation, + request.Method, + request.SignatureInput, + request.Signature, + request.ContentDigest, + request.Body, + cancellationToken) + .ConfigureAwait(false); + + return verification.Accepted + ? AuthenticationResult.Success(verification.Certificate.ToIdentity(operation)) + : AuthenticationResult.Failure(verification.Error, verification.FailureDetail); + } + + /// + /// Authenticates the signed opening request of an agent channel stream, and prepares the stream's challenge. + /// + /// + /// The opening request goes through the steps of CONTRACT.agent-identity.md §6 with tag connect, + /// and its nonce is recorded only once every other step has passed. + /// A verified opening does not authenticate the stream: + /// the agent must still sign , + /// which checks. + /// The agent has to do so, counted from the end of this verification. + /// + /// The request method, as received. + /// The signature-input metadata entries in received order. + /// The signature metadata entries in received order. + /// A token that cancels the registry lookup and the nonce recording. + /// An argument is null. + /// A metadata entry is null. + /// + /// The registry returned a certificate with another thumbprint, or one whose key is not a P-256 key. + /// + public async ValueTask AuthenticateChannelOpeningAsync( + string method, + IReadOnlyList signatureInput, + IReadOnlyList signature, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(method); + string[] signatureInputLines = FieldLines.Copy(signatureInput, nameof(signatureInput)); + string[] signatureLines = FieldLines.Copy(signature, nameof(signature)); + + Verification verification = await VerifyAsync( + SignedOperation.Connect, + method, + signatureInputLines, + signatureLines, + [], + ReadOnlyMemory.Empty, + cancellationToken) + .ConfigureAwait(false); + + if (!verification.Accepted) + { + return ChannelOpeningResult.Failure(verification.Error, verification.FailureDetail); + } + + byte[] challenge = new byte[ChallengeLength]; + _randomNumberGenerator.GetBytes(challenge); + + // The Hello timeout runs from the end of verification rather than from the reading that judged the request, + // so that time spent in the registry and the nonce store does not shorten the agent's time to answer. + DateTimeOffset openedAt = _timeProvider.GetUtcNow(); + + return ChannelOpeningResult.Success( + new ChannelOpening( + verification.Certificate, + verification.Nonce, + Guid.NewGuid(), + challenge, + openedAt, + _timeProvider, + _certificateRegistry)); + } + + private async ValueTask VerifyAsync( + SignedOperation operation, + string method, + IReadOnlyList signatureInput, + IReadOnlyList signature, + IReadOnlyList contentDigest, + ReadOnlyMemory body, + CancellationToken cancellationToken) + { + // Step 1. + if (!SignatureFields.TryParse( + operation, + signatureInput, + signature, + contentDigest, + out SignatureFields? fields, + out string? failureDetail)) + { + return Verification.Reject(AgentIdentityErrorCode.SignatureInvalid, failureDetail); + } + + // Step 2. + if (fields.Tag != operation.Tag) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 2: the tag is not the operation's tag."); + } + + // Step 3. + // Steps 3 and 4 compare against one reading of the clock, exactly, so that they agree on what now is. + DateTimeOffset now = _timeProvider.GetUtcNow(); + if (!IsWithinWindow(fields.Created, fields.Expires, now)) + { + return Verification.Reject( + AgentIdentityErrorCode.ClockSkew, + "Step 3: created and expires are outside the accepted window."); + } + + // Step 4. + RegisteredCertificate? registered = await _certificateRegistry + .FindByThumbprintAsync(fields.KeyId, cancellationToken) + .ConfigureAwait(false); + + if (registered is null) + { + return Verification.Reject( + AgentIdentityErrorCode.IdentityUnknown, + "Step 4: no certificate is registered with the keyid, or its identity was deleted."); + } + + using X509Certificate2 certificate = LoadRegisteredCertificate(fields.KeyId, registered); + + if (registered.Status is not (CertificateStatus.Pending or CertificateStatus.Current)) + { + return Verification.Reject(AgentIdentityErrorCode.IdentityUnknown, "Step 4: the certificate is retired."); + } + + if (registered.IdentityRevoked) + { + return Verification.Reject(AgentIdentityErrorCode.IdentityRevoked, "Step 4: the identity is revoked."); + } + + // UNTESTED: the conversion is exact in the hour that a daylight-saving fall-back repeats, + // because the local DateTime that X509Certificate2 returns keeps the ambiguous-DST bit, + // which ToUniversalTime uses. + // A wrong instant there shifts expiry and validity checks by an hour on hosts outside UTC. + // Keep the conversion straight from the property: arithmetic, formatting or a DateTimeOffset built from the + // local value before ToUniversalTime loses the bit. + DateTimeOffset notBefore = new(certificate.NotBefore.ToUniversalTime()); + DateTimeOffset notAfter = new(certificate.NotAfter.ToUniversalTime()); + + if (!operation.AcceptsValidity(notBefore, notAfter, now)) + { + return Verification.Reject( + AgentIdentityErrorCode.CertificateExpired, + operation.HasRenewalGrace + ? "Step 4: the certificate is expired beyond its renewal grace." + : "Step 4: the certificate is expired."); + } + + // Step 5. + // UNTESTED: the digest comparison takes the same time whichever bytes differ. + // The digest is not secret, but a constant-time comparison needs no argument that its timing reveals nothing. + // When changing this check, keep CryptographicOperations.FixedTimeEquals. + if (fields.ContentDigest is { } digest + && !CryptographicOperations.FixedTimeEquals(SHA256.HashData(body.Span), digest)) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 5: Content-Digest does not match the body."); + } + + if (!fields.TryBuildSignatureBase(method, out byte[]? signatureBase)) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 5: the method is not an HTTP token, so the signature base has no @method."); + } + + using ECDsa publicKey = LoadPublicKey(certificate); + + if (!EcdsaP256Signature.Verify(publicKey, signatureBase, fields.Signature)) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 5: the signature does not verify."); + } + + // Step 6. + DateTimeOffset retainUntil = DateTimeOffset.FromUnixTimeSeconds(fields.Expires) + NonceRetentionAfterExpiry; + bool recorded = await _nonceStore + .TryRecordAsync(fields.KeyId, fields.Nonce, retainUntil, cancellationToken) + .ConfigureAwait(false); + + if (!recorded) + { + return Verification.Reject( + AgentIdentityErrorCode.SignatureInvalid, + "Step 6: the nonce was already accepted with the keyid."); + } + + VerifiedCertificate verified = new( + registered.IdentityId, + fields.KeyId, + registered.Status, + notBefore, + notAfter, + publicKey.ExportSubjectPublicKeyInfo()); + + return Verification.Accept(verified, fields.Nonce); + } + + // CONTRACT.agent-identity.md §6 step 3: 0 < expires − created ≤ 300, created ≤ now + 60 and expires ≥ now − 60. + private static bool IsWithinWindow(long created, long expires, DateTimeOffset now) + { + // Both are integers of at most 15 digits, so their difference fits in a long. + long lifetime = expires - created; + if (lifetime is <= 0 or > MaxSignatureLifetimeSeconds) + { + return false; + } + + // Ticks in 128 bits keep the comparison exact, for times outside the range of DateTimeOffset too. + Int128 nowTicks = (now - DateTimeOffset.UnixEpoch).Ticks; + Int128 toleranceTicks = (Int128)ClockToleranceSeconds * TimeSpan.TicksPerSecond; + + return (Int128)created * TimeSpan.TicksPerSecond <= nowTicks + toleranceTicks + && (Int128)expires * TimeSpan.TicksPerSecond >= nowTicks - toleranceTicks; + } + + // The registry answers a thumbprint with a certificate that the authority issued, so anything else is a host bug, + // which fails as a server error rather than as a verdict on the agent. + private static X509Certificate2 LoadRegisteredCertificate(string keyId, RegisteredCertificate registered) + { + ReadOnlySpan der = registered.CertificateDer.Span; + + if (AgentIdentityProtocol.ComputeThumbprint(der) != keyId) + { + throw new InvalidOperationException( + "The certificate registry returned a certificate whose thumbprint is not the keyid it was asked for."); + } + + X509Certificate2 certificate; + try + { + certificate = X509CertificateLoader.LoadCertificate(der); + } + catch (CryptographicException exception) + { + throw new InvalidOperationException( + "The certificate registry returned bytes that are not an X.509 certificate.", + exception); + } + + PublicKey publicKey = certificate.PublicKey; + byte[]? curve = publicKey.EncodedParameters?.RawData; + if (publicKey.Oid.Value != EcPublicKeyOid + || curve is null + || !curve.AsSpan().SequenceEqual(P256CurveParameters)) + { + certificate.Dispose(); + throw new InvalidOperationException( + "The certificate registry returned a certificate whose key is not a P-256 key."); + } + + return certificate; + } + + private static ECDsa LoadPublicKey(X509Certificate2 certificate) + { + try + { + return certificate.GetECDsaPublicKey() + ?? throw new InvalidOperationException( + "The certificate registry returned a certificate whose key is not an EC key."); + } + catch (CryptographicException exception) + { + throw new InvalidOperationException( + "The certificate registry returned a certificate whose P-256 key cannot be loaded.", + exception); + } + } + + /// + /// The outcome of the steps of CONTRACT.agent-identity.md §6: the verified certificate and nonce, + /// or the error of the failed step. + /// + private sealed class Verification + { + [MemberNotNullWhen(true, nameof(Certificate), nameof(Nonce))] + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public bool Accepted { get; private init; } + + public VerifiedCertificate? Certificate { get; private init; } + + public string? Nonce { get; private init; } + + public AgentIdentityError? Error { get; private init; } + + public string? FailureDetail { get; private init; } + + public static Verification Accept(VerifiedCertificate certificate, string nonce) + { + return new Verification { Accepted = true, Certificate = certificate, Nonce = nonce }; + } + + public static Verification Reject(AgentIdentityErrorCode code, string failureDetail) + { + return new Verification { Error = AgentIdentityError.From(code), FailureDetail = failureDetail }; + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs new file mode 100644 index 0000000000..0c1af3f4c9 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignatureFields.cs @@ -0,0 +1,315 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using static Devolutions.AgentIdentity.StructuredField; + +namespace Devolutions.AgentIdentity; + +/// +/// The Signature-Input, Signature and Content-Digest fields of a signed request, +/// parsed and checked against the profile of CONTRACT.agent-identity.md §6 step 1. +/// +/// +/// One parse drives both the profile checks and the signature base, +/// so the parameters that the checks accept are exactly those the signature covers. +/// +internal sealed class SignatureFields +{ + private const string Label = "sig"; + private const string Algorithm = "ecdsa-p256-sha256"; + private const string DigestAlgorithm = "sha-256"; + private const int ThumbprintLength = 32; + private const int NonceLength = 16; + + private static readonly string[] ParameterNames = ["created", "expires", "nonce", "keyid", "alg", "tag"]; + + private readonly InnerList _signatureInput; + private readonly string? _contentDigestValue; + + private SignatureFields( + InnerList signatureInput, + long created, + long expires, + string nonce, + string keyId, + string tag, + byte[] signature, + byte[]? contentDigest, + string? contentDigestValue) + { + _signatureInput = signatureInput; + Created = created; + Expires = expires; + Nonce = nonce; + KeyId = keyId; + Tag = tag; + Signature = signature; + ContentDigest = contentDigest; + _contentDigestValue = contentDigestValue; + } + + public long Created { get; } + + public long Expires { get; } + + /// + /// The nonce, as its canonical base64url text. + /// + public string Nonce { get; } + + /// + /// The thumbprint of the signing certificate, as its canonical base64url text. + /// + public string KeyId { get; } + + public string Tag { get; } + + public byte[] Signature { get; } + + /// + /// The SHA-256 digest that Content-Digest declares, for an operation that covers it; otherwise null. + /// + public byte[]? ContentDigest { get; } + + /// + /// Parses and checks the signature fields of a request for an operation. + /// + /// + /// Content-Digest is read only when the operation covers it; otherwise it is ignored. + /// The signature and digest lengths are not checked here, since verification checks them. + /// On failure, names the rule that the fields break. + /// + public static bool TryParse( + SignedOperation operation, + IReadOnlyList signatureInput, + IReadOnlyList signature, + IReadOnlyList contentDigest, + [NotNullWhen(true)] out SignatureFields? fields, + [NotNullWhen(false)] out string? failureDetail) + { + fields = null; + + if (!TryParseSingleMember( + signatureInput, + "Signature-Input", + Label, + out MemberValue? inputMember, + out failureDetail)) + { + return false; + } + + if (inputMember is not InnerList innerList) + { + failureDetail = "Step 1: the sig member of Signature-Input is not an inner list."; + return false; + } + + if (!innerList.Items.All(item => item is { Value: StringItem, Parameters.Count: 0 })) + { + failureDetail = "Step 1: a covered component is not a string without parameters."; + return false; + } + + IEnumerable components = innerList.Items.Select(item => ((StringItem)item.Value).Value); + if (!components.SequenceEqual(operation.CoveredComponents)) + { + failureDetail = "Step 1: the covered components are not exactly those of the operation, in order."; + return false; + } + + // The parser rejects a repeated key, so six known keys are the six parameters, each once. + IReadOnlyList parameters = innerList.Parameters; + if (parameters.Count != ParameterNames.Length + || !parameters.All(parameter => ParameterNames.Contains(parameter.Key))) + { + failureDetail = "Step 1: the parameters are not exactly created, expires, nonce, keyid, alg and tag."; + return false; + } + + if (Find(parameters, "created") is not IntegerItem created + || Find(parameters, "expires") is not IntegerItem expires + || Find(parameters, "nonce") is not StringItem nonce + || Find(parameters, "keyid") is not StringItem keyId + || Find(parameters, "alg") is not StringItem alg + || Find(parameters, "tag") is not StringItem tag) + { + failureDetail = "Step 1: created or expires is not an integer, or another parameter is not a string."; + return false; + } + + // A malformed keyid fails here rather than at the certificate lookup, + // so that an agent bug never draws the terminal identity_unknown. + if (!StrictBase64.TryDecodeBase64Url(keyId.Value, out _, exactLength: ThumbprintLength)) + { + failureDetail = "Step 1: keyid is not a certificate thumbprint in canonical base64url."; + return false; + } + + if (!StrictBase64.TryDecodeBase64Url(nonce.Value, out _, exactLength: NonceLength)) + { + failureDetail = "Step 1: nonce is not 16 bytes in canonical base64url."; + return false; + } + + if (alg.Value != Algorithm) + { + failureDetail = "Step 1: alg is not ecdsa-p256-sha256."; + return false; + } + + if (!TryParseSingleMember(signature, "Signature", Label, out MemberValue? signatureMember, out failureDetail)) + { + return false; + } + + if (!TryDecodeByteSequence(signatureMember, out byte[]? signatureBytes)) + { + failureDetail = "Step 1: the signature is not canonical base64 without parameters."; + return false; + } + + byte[]? digest = null; + string? contentDigestValue = null; + + if (operation.CoversContentDigest) + { + if (!TryParseSingleMember( + contentDigest, + "Content-Digest", + DigestAlgorithm, + out MemberValue? digestMember, + out failureDetail)) + { + return false; + } + + if (!TryDecodeByteSequence(digestMember, out digest)) + { + failureDetail = "Step 1: the sha-256 digest is not canonical base64 without parameters."; + return false; + } + + // RFC 9421 §2.1: a covered field contributes its combined value, as received. + contentDigestValue = FieldLines.Combine(contentDigest); + } + + fields = new SignatureFields( + innerList, + created.Value, + expires.Value, + nonce.Value, + keyId.Value, + tag.Value, + signatureBytes, + digest, + contentDigestValue); + failureDetail = null; + return true; + } + + /// + /// Builds the signature base of RFC 9421 §2.5 for the request. + /// + /// The request method, as received, which is the value of @method. + /// The ASCII bytes of the signature base. + /// False when the method is not an RFC 9110 token, so @method has no value. + public bool TryBuildSignatureBase(string method, [NotNullWhen(true)] out byte[]? signatureBase) + { + signatureBase = null; + + // RFC 9110 §9.1 defines a method as a token, + // so the base stays ASCII and no component value can add a line of its own. + if (!IsToken(method)) + { + return false; + } + + StringBuilder builder = new(); + + foreach (Item component in _signatureInput.Items) + { + string name = ((StringItem)component.Value).Value; + string value = name switch + { + SignedOperation.MethodComponent => method, + SignedOperation.ContentDigestComponent => _contentDigestValue + ?? throw new UnreachableException("Content-Digest is covered but was not read."), + _ => throw new UnreachableException("A covered component is outside the operation's profile."), + }; + + builder.Append(SerializeString(name)).Append(": ").Append(value).Append('\n'); + } + + builder.Append("\"@signature-params\": "); + AppendSignatureParameters(builder); + + signatureBase = Encoding.ASCII.GetBytes(builder.ToString()); + return true; + } + + // RFC 9421 §2.3: the value of @signature-params is the serialization of the received Signature-Input member, + // with its parameters in received order, so the base never holds the received Signature-Input text. + private void AppendSignatureParameters(StringBuilder builder) + { + builder.Append('('); + builder.AppendJoin(' ', _signatureInput.Items.Select(item => SerializeString(((StringItem)item.Value).Value))); + builder.Append(')'); + + foreach (Parameter parameter in _signatureInput.Parameters) + { + builder.Append(';').Append(parameter.Key).Append('=').Append(parameter.Value switch + { + IntegerItem integer => SerializeInteger(integer.Value), + StringItem text => SerializeString(text.Value), + _ => throw new UnreachableException("A signature parameter is outside the profile."), + }); + } + } + + private static bool TryParseSingleMember( + IReadOnlyList fieldLines, + string fieldName, + string key, + [NotNullWhen(true)] out MemberValue? value, + [NotNullWhen(false)] out string? failureDetail) + { + value = null; + + if (fieldLines.Count == 0) + { + failureDetail = $"Step 1: {fieldName} is absent."; + return false; + } + + if (!TryParseDictionary(FieldLines.Combine(fieldLines), out IReadOnlyList? dictionary)) + { + failureDetail = $"Step 1: {fieldName} is not an RFC 8941 dictionary without repeated keys."; + return false; + } + + if (dictionary is not [{ Key: var memberKey, Value: var memberValue }] || memberKey != key) + { + failureDetail = $"Step 1: {fieldName} does not hold exactly one member, {key}."; + return false; + } + + value = memberValue; + failureDetail = null; + return true; + } + + private static bool TryDecodeByteSequence(MemberValue value, [NotNullWhen(true)] out byte[]? bytes) + { + bytes = null; + + return value is Item { Value: ByteSequenceItem sequence, Parameters.Count: 0 } + && StrictBase64.TryDecodeBase64(sequence.Base64, out bytes); + } + + private static BareItem? Find(IReadOnlyList parameters, string key) + { + return parameters.FirstOrDefault(parameter => parameter.Key == key)?.Value; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedHttpRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedHttpRequest.cs new file mode 100644 index 0000000000..8483134735 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedHttpRequest.cs @@ -0,0 +1,64 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The parts of an HTTP request that request authentication reads, as the host received them. +/// +public sealed class SignedHttpRequest +{ + /// + /// Creates the request from what the host received. + /// + /// The request method, as received. + /// + /// The Signature-Input field lines in received order; empty when the field is absent. + /// + /// + /// The Signature field lines in received order; empty when the field is absent. + /// + /// + /// The Content-Digest field lines in received order; empty when the field is absent. + /// + /// The request body, as received. + /// An argument is null. + /// A field line is null. + public SignedHttpRequest( + string method, + IReadOnlyList signatureInput, + IReadOnlyList signature, + IReadOnlyList contentDigest, + ReadOnlyMemory body) + { + ArgumentNullException.ThrowIfNull(method); + + Method = method; + SignatureInput = FieldLines.Copy(signatureInput, nameof(signatureInput)); + Signature = FieldLines.Copy(signature, nameof(signature)); + ContentDigest = FieldLines.Copy(contentDigest, nameof(contentDigest)); + Body = body; + } + + /// + /// The request method, as received. + /// + public string Method { get; } + + /// + /// The Signature-Input field lines in received order. + /// + public IReadOnlyList SignatureInput { get; } + + /// + /// The Signature field lines in received order. + /// + public IReadOnlyList Signature { get; } + + /// + /// The Content-Digest field lines in received order. + /// + public IReadOnlyList ContentDigest { get; } + + /// + /// The request body, as received. + /// + public ReadOnlyMemory Body { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs new file mode 100644 index 0000000000..d5e5929c66 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/SignedOperation.cs @@ -0,0 +1,87 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The signature profile of one operation: its tag, its covered components and the certificate validity it accepts. +/// +/// +/// These are the rows of the table in CONTRACT.agent-identity.md §6. +/// +internal sealed class SignedOperation +{ + public const string MethodComponent = "@method"; + public const string ContentDigestComponent = "content-digest"; + + private SignedOperation(string tag, string[] coveredComponents, bool hasRenewalGrace) + { + Tag = tag; + CoveredComponents = coveredComponents; + CoversContentDigest = coveredComponents.Contains(ContentDigestComponent); + HasRenewalGrace = hasRenewalGrace; + } + + public static SignedOperation Renew { get; } = + new("renew", [MethodComponent, ContentDigestComponent], hasRenewalGrace: true); + + public static SignedOperation Confirm { get; } = + new("confirm", [MethodComponent], hasRenewalGrace: false); + + public static SignedOperation CheckIn { get; } = + new("check-in", [MethodComponent, ContentDigestComponent], hasRenewalGrace: false); + + public static SignedOperation Connect { get; } = + new("connect", [MethodComponent], hasRenewalGrace: false); + + public string Tag { get; } + + /// + /// The components that a signature for this operation covers, in the order it must list them. + /// + public IReadOnlyList CoveredComponents { get; } + + public bool CoversContentDigest { get; } + + /// + /// Whether the operation also accepts a certificate expired by less than its own lifetime. + /// + public bool HasRenewalGrace { get; } + + /// + /// Returns the profile of an operation. + /// + /// + /// is not a defined operation. + /// + public static SignedOperation For(AgentOperation operation) + { + return operation switch + { + AgentOperation.Renew => Renew, + AgentOperation.Confirm => Confirm, + AgentOperation.CheckIn => CheckIn, + AgentOperation.Connect => Connect, + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "Not a defined agent operation."), + }; + } + + /// + /// Tells whether this operation accepts a current or pending certificate with this validity + /// at . + /// + /// + /// A certificate is unexpired while now ≤ notAfter, as in RFC 5280 §4.1.2.5. + /// notBefore is not checked: the authority issued the certificate itself, + /// so a future notBefore only reflects the authority's clock. + /// Renewal also accepts a certificate expired by less than its own lifetime. + /// + public bool AcceptsValidity(DateTimeOffset notBefore, DateTimeOffset notAfter, DateTimeOffset now) + { + if (now <= notAfter) + { + return true; + } + + // now < notAfter + (notAfter − notBefore), compared as differences, + // which cannot overflow for a notAfter close to the largest DateTimeOffset. + return HasRenewalGrace && now - notAfter < notAfter - notBefore; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/StructuredField.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/StructuredField.cs new file mode 100644 index 0000000000..16e5cd4b2d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/StructuredField.cs @@ -0,0 +1,621 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// Parses RFC 8941 structured field dictionaries, and serializes the values that a signature base holds. +/// +/// +/// Parsing follows RFC 8941 §4.2, except that a repeated dictionary key or parameter key fails it. +/// RFC 8941 keeps the last of repeated keys, +/// so a field that repeats one could be read differently by a proxy, a log or another parser. +/// A byte sequence keeps its base64 text, so that the caller decodes it as strictly as its protocol requires. +/// +internal static class StructuredField +{ + /// + /// Parses a field value as a dictionary. + /// + /// The field value, with all its field lines combined. + /// + /// The members in received order, when the value is a dictionary without repeated keys. + /// + public static bool TryParseDictionary( + string input, + [NotNullWhen(true)] out IReadOnlyList? dictionary) + { + dictionary = null; + + // RFC 8941 §4.2 step 1: a value that is not ASCII fails parsing. + if (!Ascii.IsValid(input)) + { + return false; + } + + Reader reader = new(input); + reader.SkipSpaces(); + + // The dictionary rule consumes trailing white space and fails on anything else, + // so the input is empty when it succeeds. + if (!reader.TryParseDictionary(out List? members)) + { + return false; + } + + dictionary = members; + return true; + } + + /// + /// Serializes a string, as RFC 8941 §4.1.6 does. + /// + /// + /// A string of printable ASCII characters, such as one that produced. + /// + public static string SerializeString(string value) + { + StringBuilder builder = new(value.Length + 2); + builder.Append('"'); + + foreach (char c in value) + { + if (c is '"' or '\\') + { + builder.Append('\\'); + } + + builder.Append(c); + } + + return builder.Append('"').ToString(); + } + + /// + /// Serializes an integer, as RFC 8941 §4.1.4 does. + /// + /// + /// An integer of at most 15 digits, such as one that produced. + /// + public static string SerializeInteger(long value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + /// + /// Tells whether a value is an RFC 9110 token, such as a request method. + /// + public static bool IsToken(string value) + { + return value.Length > 0 && value.All(IsTokenCharacter); + } + + // RFC 9110 §5.6.2: tchar. + private static bool IsTokenCharacter(char c) + { + return char.IsAsciiLetterOrDigit(c) + || c is '!' or '#' or '$' or '%' or '&' or '\'' or '*' or '+' or '-' or '.' or '^' or '_' or '`' or '|' + or '~'; + } + + /// + /// A value without parameters: an integer, a decimal, a string, a token, a byte sequence or a Boolean. + /// + public abstract record BareItem; + + public sealed record IntegerItem(long Value) : BareItem; + + public sealed record DecimalItem(decimal Value) : BareItem; + + public sealed record StringItem(string Value) : BareItem; + + public sealed record TokenItem(string Value) : BareItem; + + /// + /// The text between the colons, which holds only base64 characters but may not be canonical. + /// + public sealed record ByteSequenceItem(string Base64) : BareItem; + + public sealed record BooleanItem(bool Value) : BareItem; + + public sealed record Parameter(string Key, BareItem Value); + + /// + /// The value of a dictionary member: an item or an inner list. + /// + public abstract record MemberValue; + + public sealed record Item(BareItem Value, IReadOnlyList Parameters) : MemberValue; + + public sealed record InnerList(IReadOnlyList Items, IReadOnlyList Parameters) : MemberValue; + + public sealed record DictionaryMember(string Key, MemberValue Value); + + /// + /// The parsing algorithms of RFC 8941 §4.2, each consuming the input from the current position. + /// + /// + /// UNTESTED: parsing takes time linear in the length of the input. + /// The input is an unauthenticated header, + /// so a quadratic step would let one request cost a quadratic amount of work. + /// When changing a rule, keep each key lookup a set lookup and read each character a bounded number of times. + /// + private sealed class Reader(string input) + { + private int _position; + + private bool AtEnd => _position == input.Length; + + public void SkipSpaces() + { + while (Next(' ')) + { + _position++; + } + } + + // RFC 8941 §4.2.2. + public bool TryParseDictionary([NotNullWhen(true)] out List? dictionary) + { + dictionary = null; + List members = []; + HashSet keys = new(StringComparer.Ordinal); + + while (!AtEnd) + { + if (!TryParseKey(out string? key)) + { + return false; + } + + MemberValue? value; + if (TryConsume('=')) + { + if (!TryParseItemOrInnerList(out value)) + { + return false; + } + } + else + { + if (!TryParseParameters(out List? parameters)) + { + return false; + } + + value = new Item(new BooleanItem(true), parameters); + } + + if (!keys.Add(key)) + { + return false; + } + + members.Add(new DictionaryMember(key, value)); + + SkipOptionalWhitespace(); + if (AtEnd) + { + break; + } + + if (!TryConsume(',')) + { + return false; + } + + SkipOptionalWhitespace(); + + // A trailing comma. + if (AtEnd) + { + return false; + } + } + + dictionary = members; + return true; + } + + // RFC 8941 §4.2.1.1. + private bool TryParseItemOrInnerList([NotNullWhen(true)] out MemberValue? value) + { + value = null; + + if (Next('(')) + { + if (!TryParseInnerList(out InnerList? innerList)) + { + return false; + } + + value = innerList; + return true; + } + + if (!TryParseItem(out Item? item)) + { + return false; + } + + value = item; + return true; + } + + // RFC 8941 §4.2.1.2. + private bool TryParseInnerList([NotNullWhen(true)] out InnerList? innerList) + { + innerList = null; + + if (!TryConsume('(')) + { + return false; + } + + List items = []; + + while (!AtEnd) + { + SkipSpaces(); + + if (TryConsume(')')) + { + if (!TryParseParameters(out List? parameters)) + { + return false; + } + + innerList = new InnerList(items, parameters); + return true; + } + + if (!TryParseItem(out Item? item)) + { + return false; + } + + items.Add(item); + + if (!Next(' ') && !Next(')')) + { + return false; + } + } + + // The end of the inner list was not found. + return false; + } + + // RFC 8941 §4.2.3. + private bool TryParseItem([NotNullWhen(true)] out Item? item) + { + item = null; + + if (!TryParseBareItem(out BareItem? value) || !TryParseParameters(out List? parameters)) + { + return false; + } + + item = new Item(value, parameters); + return true; + } + + // RFC 8941 §4.2.3.1. + private bool TryParseBareItem([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (AtEnd) + { + return false; + } + + char first = input[_position]; + + if (first == '-' || char.IsAsciiDigit(first)) + { + return TryParseIntegerOrDecimal(out value); + } + + if (first == '"') + { + return TryParseString(out value); + } + + if (char.IsAsciiLetter(first) || first == '*') + { + return TryParseToken(out value); + } + + if (first == ':') + { + return TryParseByteSequence(out value); + } + + if (first == '?') + { + return TryParseBoolean(out value); + } + + return false; + } + + // RFC 8941 §4.2.3.2. + private bool TryParseParameters([NotNullWhen(true)] out List? parameters) + { + parameters = null; + List result = []; + HashSet keys = new(StringComparer.Ordinal); + + while (TryConsume(';')) + { + SkipSpaces(); + + if (!TryParseKey(out string? key)) + { + return false; + } + + BareItem value = new BooleanItem(true); + if (TryConsume('=')) + { + if (!TryParseBareItem(out BareItem? parsed)) + { + return false; + } + + value = parsed; + } + + if (!keys.Add(key)) + { + return false; + } + + result.Add(new Parameter(key, value)); + } + + parameters = result; + return true; + } + + // RFC 8941 §4.2.3.3. + private bool TryParseKey([NotNullWhen(true)] out string? key) + { + key = null; + + if (AtEnd || !(char.IsAsciiLetterLower(input[_position]) || input[_position] == '*')) + { + return false; + } + + int start = _position; + while (!AtEnd && IsKeyCharacter(input[_position])) + { + _position++; + } + + key = input[start.._position]; + return true; + } + + // RFC 8941 §4.2.4. + private bool TryParseIntegerOrDecimal([NotNullWhen(true)] out BareItem? value) + { + value = null; + + bool negative = TryConsume('-'); + if (AtEnd || !char.IsAsciiDigit(input[_position])) + { + return false; + } + + int start = _position; + bool isDecimal = false; + + while (!AtEnd) + { + char c = input[_position]; + + if (char.IsAsciiDigit(c)) + { + _position++; + } + else if (!isDecimal && c == '.') + { + // At most 12 digits before the decimal point. + if (_position - start > 12) + { + return false; + } + + isDecimal = true; + _position++; + } + else + { + break; + } + + // At most 15 digits in an integer, and 16 characters in a decimal including its point. + if (_position - start > (isDecimal ? 16 : 15)) + { + return false; + } + } + + ReadOnlySpan number = input.AsSpan(start, _position - start); + + if (!isDecimal) + { + long integer = long.Parse(number, NumberStyles.None, CultureInfo.InvariantCulture); + value = new IntegerItem(negative ? -integer : integer); + return true; + } + + // One to three digits after the decimal point. + int fractionLength = number.Length - number.IndexOf('.') - 1; + if (fractionLength is 0 or > 3) + { + return false; + } + + decimal parsed = decimal.Parse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + value = new DecimalItem(negative ? -parsed : parsed); + return true; + } + + // RFC 8941 §4.2.5. + private bool TryParseString([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (!TryConsume('"')) + { + return false; + } + + StringBuilder builder = new(); + + while (!AtEnd) + { + char c = input[_position++]; + + if (c == '\\') + { + if (AtEnd) + { + return false; + } + + char escaped = input[_position++]; + if (escaped is not ('"' or '\\')) + { + return false; + } + + builder.Append(escaped); + } + else if (c == '"') + { + value = new StringItem(builder.ToString()); + return true; + } + else if (c is < ' ' or > '~') + { + return false; + } + else + { + builder.Append(c); + } + } + + // The closing quote was not found. + return false; + } + + // RFC 8941 §4.2.6. + private bool TryParseToken([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (AtEnd || !(char.IsAsciiLetter(input[_position]) || input[_position] == '*')) + { + return false; + } + + int start = _position; + while (!AtEnd && (IsTokenCharacter(input[_position]) || input[_position] is ':' or '/')) + { + _position++; + } + + value = new TokenItem(input[start.._position]); + return true; + } + + // RFC 8941 §4.2.7. + private bool TryParseByteSequence([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (!TryConsume(':')) + { + return false; + } + + int end = input.IndexOf(':', _position); + if (end < 0) + { + return false; + } + + string content = input[_position..end]; + _position = end + 1; + + if (!content.All(c => char.IsAsciiLetterOrDigit(c) || c is '+' or '/' or '=')) + { + return false; + } + + value = new ByteSequenceItem(content); + return true; + } + + // RFC 8941 §4.2.8. + private bool TryParseBoolean([NotNullWhen(true)] out BareItem? value) + { + value = null; + + if (!TryConsume('?')) + { + return false; + } + + if (TryConsume('1')) + { + value = new BooleanItem(true); + return true; + } + + if (TryConsume('0')) + { + value = new BooleanItem(false); + return true; + } + + return false; + } + + private void SkipOptionalWhitespace() + { + while (Next(' ') || Next('\t')) + { + _position++; + } + } + + private bool Next(char expected) + { + return !AtEnd && input[_position] == expected; + } + + private bool TryConsume(char expected) + { + if (!Next(expected)) + { + return false; + } + + _position++; + return true; + } + + private static bool IsKeyCharacter(char c) + { + return char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c is '_' or '-' or '.' or '*'; + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/VerifiedCertificate.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/VerifiedCertificate.cs new file mode 100644 index 0000000000..fbc743b19f --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Authentication/VerifiedCertificate.cs @@ -0,0 +1,19 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The facts about the certificate that authenticated a request: +/// the registry's state for it, and the validity and public key read from the certificate itself. +/// +internal sealed record VerifiedCertificate( + Guid IdentityId, + string Thumbprint, + CertificateStatus Status, + DateTimeOffset NotBefore, + DateTimeOffset NotAfter, + byte[] SubjectPublicKeyInfo) +{ + public AuthenticatedIdentity ToIdentity(AgentOperation operation) + { + return new AuthenticatedIdentity(IdentityId, Thumbprint, Status, NotBefore, NotAfter, operation); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs new file mode 100644 index 0000000000..950884bcea --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/AgentMetadata.cs @@ -0,0 +1,178 @@ +using System.Buffers; +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// Metadata that an agent reported, such as its host name and OS version, checked against the limits of the protocol. +/// +/// +/// Metadata is informational, for display and friendly-name evaluation, and never authorizes anything. +/// Only and the request body parsers create instances, +/// so an instance always holds valid metadata. +/// Keys compare ordinally, and enumeration follows the order in which the agent sent the entries. +/// +public sealed class AgentMetadata : IReadOnlyDictionary +{ + private const int MaxKeys = 32; + private const int MaxKeyLength = 64; + private const int MaxValueBytes = 1024; + private const int MaxTotalBytes = 16 * 1024; + + private readonly OrderedDictionary _entries; + + private AgentMetadata(OrderedDictionary entries) + { + _entries = entries; + } + + /// + public int Count => _entries.Count; + + /// + public IEnumerable Keys => _entries.Keys; + + /// + public IEnumerable Values => _entries.Values; + + /// + public string this[string key] => _entries[key]; + + /// + /// Validates metadata received from an agent, such as the metadata of a Hello message. + /// + /// + /// Valid metadata has at most 32 keys, each matching ^[a-z][a-z0-9_]{0,63}$ and appearing once. + /// Each value is Unicode text of at most 1024 UTF-8 bytes without C0, DEL or C1 control characters, + /// and the UTF-8 lengths of all keys and values add up to at most 16 KiB. + /// + /// The metadata entries, in the order received. + /// An entry has a null key or value. + public static MetadataValidationResult Validate(IEnumerable> entries) + { + ArgumentNullException.ThrowIfNull(entries); + + OrderedDictionary validated = new(StringComparer.Ordinal); + int totalBytes = 0; + + foreach ((string key, string value) in entries) + { + if (key is null || value is null) + { + throw new ArgumentException("A metadata entry has a null key or value.", nameof(entries)); + } + + if (validated.Count == MaxKeys) + { + return MetadataValidationResult.Failure($"The metadata has more than {MaxKeys} keys."); + } + + // A detail names an entry by its position, because the key comes from the request. + int position = validated.Count + 1; + + if (!IsValidKey(key)) + { + return MetadataValidationResult.Failure( + $"Metadata key {position} does not match ^[a-z][a-z0-9_]{{0,63}}$."); + } + + if (validated.ContainsKey(key)) + { + return MetadataValidationResult.Failure($"Metadata key {position} repeats an earlier key."); + } + + if (CheckValue(value, out int valueBytes) is { } problem) + { + return MetadataValidationResult.Failure($"The value of metadata key {position} {problem}."); + } + + // Keys are ASCII, so each character is one UTF-8 byte. + totalBytes += key.Length + valueBytes; + if (totalBytes > MaxTotalBytes) + { + return MetadataValidationResult.Failure( + $"The metadata keys and values have more than {MaxTotalBytes} UTF-8 bytes."); + } + + validated.Add(key, value); + } + + return MetadataValidationResult.Success(new AgentMetadata(validated)); + } + + /// + public bool ContainsKey(string key) + { + return _entries.ContainsKey(key); + } + + /// + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) + { + return _entries.TryGetValue(key, out value); + } + + /// + public IEnumerator> GetEnumerator() + { + return _entries.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + // Checks the key against ^[a-z][a-z0-9_]{0,63}$ character by character: + // a .NET regular expression ending in $ would also accept a final line feed. + private static bool IsValidKey(string key) + { + if (key.Length is 0 or > MaxKeyLength || key[0] is not (>= 'a' and <= 'z')) + { + return false; + } + + foreach (char c in key) + { + if (c is not ((>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + return false; + } + } + + return true; + } + + // Returns why the value is invalid, or null and its UTF-8 length. + private static string? CheckValue(string value, out int utf8Length) + { + utf8Length = 0; + ReadOnlySpan remaining = value; + + while (!remaining.IsEmpty) + { + // An unpaired surrogate encodes no Unicode scalar value, so the string has no UTF-8 form. + if (Rune.DecodeFromUtf16(remaining, out Rune rune, out int consumed) != OperationStatus.Done) + { + return "is not valid Unicode"; + } + + if (rune.Value is <= 0x1F or (>= 0x7F and <= 0x9F)) + { + return "holds a control character"; + } + + utf8Length += rune.Utf8SequenceLength; + if (utf8Length > MaxValueBytes) + { + return $"is longer than {MaxValueBytes} UTF-8 bytes"; + } + + remaining = remaining[consumed..]; + } + + return null; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/CheckInRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/CheckInRequestBody.cs new file mode 100644 index 0000000000..d770b27093 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/CheckInRequestBody.cs @@ -0,0 +1,33 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of a check-in request: the agent's metadata. +/// +public sealed class CheckInRequestBody +{ + private CheckInRequestBody(AgentMetadata metadata) + { + Metadata = metadata; + } + + /// + /// The validated metadata. + /// + public AgentMetadata Metadata { get; } + + /// + /// Parses and validates the body of a check-in request, { "metadata": { ... } }. + /// + /// + /// The body is one JSON object without comments or trailing commas, nested at most 64 levels deep, + /// that repeats no member name at any depth and whose strings are all Unicode text. + /// metadata is an object that accepts, + /// a null member counts as absent, and other members are ignored. + /// Authenticate the request first, since an authentication error wins over an invalid body. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan utf8Json) + { + return RequestBodyReader.ReadMetadata(utf8Json, metadata => new CheckInRequestBody(metadata)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ConfirmRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ConfirmRequestBody.cs new file mode 100644 index 0000000000..6b4bac0d08 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ConfirmRequestBody.cs @@ -0,0 +1,30 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of a confirm request, which is empty. +/// +public sealed class ConfirmRequestBody +{ + private ConfirmRequestBody() + { + } + + /// + /// Checks that the body of a confirm request is empty. + /// + /// + /// Any byte, white space included, makes the body invalid. + /// Authenticate the request first and act on it only once the body is valid, + /// so that an authentication error wins over an invalid body and an invalid body changes no state. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan body) + { + if (!body.IsEmpty) + { + return RequestBodyParseResult.Failure("The body is not empty."); + } + + return RequestBodyParseResult.Success(new ConfirmRequestBody()); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/EnrollRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/EnrollRequestBody.cs new file mode 100644 index 0000000000..f6bdbad22b --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/EnrollRequestBody.cs @@ -0,0 +1,40 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of an enroll request: the CSR of the key to enroll and the agent's metadata. +/// +public sealed class EnrollRequestBody +{ + private EnrollRequestBody(CertificateSigningRequest csr, AgentMetadata metadata) + { + Csr = csr; + Metadata = metadata; + } + + /// + /// The validated CSR of the key to enroll. + /// + public CertificateSigningRequest Csr { get; } + + /// + /// The validated metadata. + /// + public AgentMetadata Metadata { get; } + + /// + /// Parses and validates the body of an enroll request, { "csr": "...", "metadata": { ... } }. + /// + /// + /// The body is one JSON object without comments or trailing commas, nested at most 64 levels deep, + /// that repeats no member name at any depth and whose strings are all Unicode text. + /// csr is a canonical base64 request that accepts, + /// metadata is an object that accepts, + /// a null member counts as absent, and other members are ignored. + /// Check the enrollment token first, since an authentication error wins over an invalid body. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan utf8Json) + { + return RequestBodyReader.ReadCsrAndMetadata(utf8Json, (csr, metadata) => new EnrollRequestBody(csr, metadata)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs new file mode 100644 index 0000000000..d6b030b862 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/IdentityConfig.cs @@ -0,0 +1,304 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; + +using Devolutions.Agent.Channel.V1; + +namespace Devolutions.AgentIdentity; + +/// +/// The config that the authority holds for an identity, which the agent receives in the enroll and +/// check-in responses and in ConfigUpdate messages. +/// +/// +/// Only creates instances, +/// so an instance always serializes to a config that CONTRACT.agent-identity.md §5.2 accepts. +/// The agent replaces its stored config only with a higher , +/// so the host creates the config with a higher revision whenever the identity's config changes. +/// +public sealed class IdentityConfig +{ + private const string VersionMember = "version"; + private const string RevisionMember = "revision"; + private const string AgentChannelUrlMember = "agent_channel_url"; + + // The schema of the config; the agent treats a config of another version as a transient failure. + private const int SchemaVersion = 1; + + // CONTRACT.agent-identity.md §7.1: neither side of the agent channel sends an envelope larger than 64 KiB. + private const int MaxEnvelopeBytes = 64 * 1024; + + private readonly byte[] _utf8Json; + + private IdentityConfig(ulong revision, string? agentChannelUrl, byte[] utf8Json) + { + Revision = revision; + AgentChannelUrl = agentChannelUrl; + _utf8Json = utf8Json; + } + + /// + /// The revision of the config, which strictly increases for an identity whenever its config changes. + /// + public ulong Revision { get; } + + /// + /// The base URL of the agent channel, without trailing slashes, or null when the authority does not offer the + /// channel to the identity. + /// + public string? AgentChannelUrl { get; } + + /// + /// The UTF-8 JSON object that copies. + /// + internal ReadOnlySpan Utf8Json => _utf8Json; + + /// + /// Creates a config with schema version 1. + /// + /// + /// The agent channel URL is written in normalized absolute form without trailing slashes, + /// since the agent appends the path of the channel service to it. + /// Each product-specific field has a snake_case name: + /// lowercase ASCII words of letters and digits joined by single underscores, the first starting with a letter. + /// No two fields have the same name, and none is named version, revision or + /// agent_channel_url. + /// Each value is any JSON value but null, + /// since an absent field is omitted rather than written as null. + /// The members of every object inside a value follow the same rules: + /// each has a snake_case name, which no other member of its object has, and a value other than null. + /// An array element is no member, so it may be null. + /// CONTRACT.agent-identity.md §7.1 limits each agent channel message to 65,536 bytes of protobuf, + /// so the config must fit one ConfigUpdate message. + /// That message is measured as builds it, with a 36-character id, + /// so the outcome never depends on the UUID generated for it. + /// + /// The revision of the config. + /// + /// The absolute https base URL of the agent channel, such as https://host/dvls, + /// without user information or any other @, query or fragment; + /// null when the authority does not offer the channel to the identity, so that the agent opens none. + /// + /// The product-specific fields, if any. + /// + /// is not an absolute https URL, + /// or has user information or any other @, a query or a fragment; + /// or a product-specific field has no name, a name that is not snake_case or that the contract gives a member, + /// no value, the value null, or a value that UTF-8 JSON cannot carry, such as a lone surrogate; + /// or a member inside a value has a name that is not snake_case or that its object repeats, or the value + /// null; + /// or the ConfigUpdate message that carries the config would exceed 65,536 bytes + /// (CONTRACT.agent-identity.md §7.1), an error reported for . + /// + public static IdentityConfig Create( + ulong revision, + string? agentChannelUrl = null, + IEnumerable>? productFields = null) + { + string? normalizedUrl = agentChannelUrl is null + ? null + : BaseUrl.Normalize(agentChannelUrl, "The agent channel URL", nameof(agentChannelUrl)); + + byte[] utf8Json = Write(revision, normalizedUrl, productFields ?? []); + int envelopeBytes = ConfigUpdateBytes(utf8Json); + + if (envelopeBytes > MaxEnvelopeBytes) + { + throw new ArgumentException( + $"The ConfigUpdate message of the config would take {envelopeBytes} bytes, " + + $"more than the {MaxEnvelopeBytes} bytes that the agent channel carries.", + nameof(productFields)); + } + + return new IdentityConfig(revision, normalizedUrl, utf8Json); + } + + /// + /// Writes the config as a UTF-8 JSON object with the members version, revision, + /// agent_channel_url when the authority offers the agent channel, and the product-specific fields. + /// + public byte[] ToUtf8Json() + { + return [.. _utf8Json]; + } + + // Writes the config once, when it is created, + // so that a value that UTF-8 JSON cannot carry fails then rather than in a response. + private static byte[] Write( + ulong revision, + string? agentChannelUrl, + IEnumerable> productFields) + { + ArrayBufferWriter buffer = new(); + + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WriteNumber(VersionMember, SchemaVersion); + writer.WriteNumber(RevisionMember, revision); + + if (agentChannelUrl is not null) + { + writer.WriteString(AgentChannelUrlMember, agentChannelUrl); + } + + WriteProductFields(writer, productFields); + writer.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + // Measures the ConfigUpdate that ChannelMessages builds for the config. + // Its id is a fresh lowercase hyphenated UUID, so a placeholder of the same 36 characters stands in for it, + // and the outcome never depends on the UUID generated. + private static int ConfigUpdateBytes(byte[] utf8Json) + { + ServerMessage envelope = new() + { + Id = Guid.Empty.ToString("D"), + ConfigUpdate = new ConfigUpdate { ConfigJson = Encoding.UTF8.GetString(utf8Json) }, + }; + + return envelope.CalculateSize(); + } + + private static void WriteProductFields( + Utf8JsonWriter writer, + IEnumerable> productFields) + { + HashSet names = new(StringComparer.Ordinal); + int position = 0; + + foreach ((string name, JsonElement value) in productFields) + { + position++; + + if (name is null) + { + throw new ArgumentException($"Product field {position} has no name.", nameof(productFields)); + } + + // The name is quoted only once it is valid, so that a message never repeats arbitrary text. + if (!IsSnakeCase(name)) + { + throw new ArgumentException( + $"The name of product field {position} is not snake_case.", + nameof(productFields)); + } + + if (name is VersionMember or RevisionMember or AgentChannelUrlMember) + { + throw new ArgumentException( + $"The product field '{name}' would repeat a member of the config.", + nameof(productFields)); + } + + if (!names.Add(name)) + { + throw new ArgumentException($"The product fields repeat the name '{name}'.", nameof(productFields)); + } + + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + throw new ArgumentException( + $"The product field '{name}' has no value; an absent field is omitted, never null.", + nameof(productFields)); + } + + try + { + // CONTRACT.agent-identity.md §1 governs every member of an agent-facing body, + // so it governs those inside the value. + if (CheckMembers(value) is { } problem) + { + throw new ArgumentException( + $"The value of the product field '{name}' {problem}.", + nameof(productFields)); + } + + writer.WritePropertyName(name); + value.WriteTo(writer); + } + catch (InvalidOperationException exception) + { + throw new ArgumentException( + $"The value of the product field '{name}' cannot be written as UTF-8 JSON.", + nameof(productFields), + exception); + } + } + } + + // Returns how a member inside the value breaks the conventions of CONTRACT.agent-identity.md §1, or null. + // The walk keeps its own stack, so that a deeply nested value cannot exhaust the thread's stack. + // Reading a name decodes its escapes, and throws InvalidOperationException for a lone surrogate. + private static string? CheckMembers(JsonElement value) + { + Stack pending = new(); + pending.Push(value); + + while (pending.TryPop(out JsonElement element)) + { + if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + pending.Push(item); + } + } + else if (element.ValueKind == JsonValueKind.Object) + { + HashSet names = new(StringComparer.Ordinal); + + foreach (JsonProperty member in element.EnumerateObject()) + { + string memberName = member.Name; + + // The name is quoted only once it is valid, so that a message never repeats arbitrary text. + if (!IsSnakeCase(memberName)) + { + return "has a member whose name is not snake_case"; + } + + // RFC 8259 §4: receivers disagree on the value of a repeated name. + if (!names.Add(memberName)) + { + return $"repeats the member name '{memberName}' in one object"; + } + + if (member.Value.ValueKind == JsonValueKind.Null) + { + return $"gives the member '{memberName}' the value null; an absent field is omitted"; + } + + pending.Push(member.Value); + } + } + } + + return null; + } + + // Checks the name character by character: + // a .NET regular expression ending in $ would also accept a final line feed. + private static bool IsSnakeCase(string name) + { + if (name.Length == 0 || name[0] is not (>= 'a' and <= 'z') || name[^1] == '_') + { + return false; + } + + for (int i = 1; i < name.Length; i++) + { + char c = name[i]; + + if (!(c is (>= 'a' and <= 'z') or (>= '0' and <= '9') || (c == '_' && name[i - 1] != '_'))) + { + return false; + } + } + + return true; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs new file mode 100644 index 0000000000..fce26c32f7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/MetadataValidationResult.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of : valid metadata, or the error that rejects it. +/// +/// +/// The error is 400 invalid_request. +/// never repeats a key or value of the metadata: +/// it names an entry by its position. +/// +public sealed class MetadataValidationResult : AgentIdentityResult +{ + private static readonly AgentIdentityError InvalidRequest = + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); + + private MetadataValidationResult(AgentMetadata? metadata, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) + { + Metadata = metadata; + } + + /// + /// Whether the metadata is valid. + /// + [MemberNotNullWhen(true, nameof(Metadata))] + public override bool Succeeded => Metadata is not null; + + /// + /// The validated metadata, when is true. + /// + public AgentMetadata? Metadata { get; } + + internal static MetadataValidationResult Success(AgentMetadata metadata) + { + return new MetadataValidationResult(metadata, null, null); + } + + internal static MetadataValidationResult Failure(string detail) + { + return new MetadataValidationResult(null, InvalidRequest, detail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RenewRequestBody.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RenewRequestBody.cs new file mode 100644 index 0000000000..77ff656f57 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RenewRequestBody.cs @@ -0,0 +1,40 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The body of a renew request: the CSR of the new key and the agent's metadata. +/// +public sealed class RenewRequestBody +{ + private RenewRequestBody(CertificateSigningRequest csr, AgentMetadata metadata) + { + Csr = csr; + Metadata = metadata; + } + + /// + /// The validated CSR of the new key. + /// + public CertificateSigningRequest Csr { get; } + + /// + /// The validated metadata. + /// + public AgentMetadata Metadata { get; } + + /// + /// Parses and validates the body of a renew request, { "csr": "...", "metadata": { ... } }. + /// + /// + /// The body is one JSON object without comments or trailing commas, nested at most 64 levels deep, + /// that repeats no member name at any depth and whose strings are all Unicode text. + /// csr is a canonical base64 request that accepts, + /// metadata is an object that accepts, + /// a null member counts as absent, and other members are ignored. + /// Authenticate the request first, since an authentication error wins over an invalid body. + /// + /// The request body. + public static RequestBodyParseResult Parse(ReadOnlySpan utf8Json) + { + return RequestBodyReader.ReadCsrAndMetadata(utf8Json, (csr, metadata) => new RenewRequestBody(csr, metadata)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs new file mode 100644 index 0000000000..4e40c64c86 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyParseResult.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of parsing a request body: the validated body, or the error that rejects it. +/// +/// +/// The body type: , , +/// or . +/// +/// +/// The error is 400 invalid_request. +/// never repeats a value from the body, not even a metadata key: +/// it names a metadata entry by its position. +/// +public sealed class RequestBodyParseResult : AgentIdentityResult + where TBody : class +{ + private static readonly AgentIdentityError InvalidRequest = + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); + + private RequestBodyParseResult(TBody? body, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) + { + Body = body; + } + + /// + /// Whether the body is valid. + /// + [MemberNotNullWhen(true, nameof(Body))] + public override bool Succeeded => Body is not null; + + /// + /// The validated body, when is true. + /// + public TBody? Body { get; } + + internal static RequestBodyParseResult Success(TBody body) + { + return new RequestBodyParseResult(body, null, null); + } + + internal static RequestBodyParseResult Failure(string detail) + { + return new RequestBodyParseResult(null, InvalidRequest, detail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs new file mode 100644 index 0000000000..fac097d17b --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/RequestBodyReader.cs @@ -0,0 +1,210 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Devolutions.AgentIdentity; + +/// +/// Reads the JSON request bodies with one set of rules, +/// so that every endpoint classifies a body, its CSR and its metadata the same way. +/// +internal static class RequestBodyReader +{ + public static RequestBodyParseResult ReadCsrAndMetadata( + ReadOnlySpan utf8Json, + Func create) + where TBody : class + { + if (!TryRead( + utf8Json, + readsCsr: true, + out CertificateSigningRequest? csr, + out AgentMetadata? metadata, + out string? failure)) + { + return RequestBodyParseResult.Failure(failure); + } + + // A successful read with readsCsr set always yields a CSR. + return RequestBodyParseResult.Success(create(csr!, metadata)); + } + + public static RequestBodyParseResult ReadMetadata( + ReadOnlySpan utf8Json, + Func create) + where TBody : class + { + if (!TryRead(utf8Json, readsCsr: false, out _, out AgentMetadata? metadata, out string? failure)) + { + return RequestBodyParseResult.Failure(failure); + } + + return RequestBodyParseResult.Success(create(metadata)); + } + + private static bool TryRead( + ReadOnlySpan utf8Json, + bool readsCsr, + out CertificateSigningRequest? csr, + [NotNullWhen(true)] out AgentMetadata? metadata, + [NotNullWhen(false)] out string? failure) + { + csr = null; + metadata = null; + + failure = CheckJson(utf8Json); + if (failure is not null) + { + return false; + } + + // The check read the same bytes under the same default options, so parsing them succeeds. + using JsonDocument document = JsonDocument.Parse(utf8Json.ToArray()); + JsonElement body = document.RootElement; + + if (body.ValueKind != JsonValueKind.Object) + { + failure = "The body is not a JSON object."; + return false; + } + + if (readsCsr && !TryReadCsr(body, out csr, out failure)) + { + return false; + } + + return TryReadMetadata(body, out metadata, out failure); + } + + // Returns why the bytes are not one JSON text that every parser reads the same way, or null. + // A repeated member name lets two parsers read two values (CONTRACT.agent-identity.md §1), + // and so does a string that is not Unicode text (RFC 8259 §8.2). + private static string? CheckJson(ReadOnlySpan utf8Json) + { + // The default options reject comments, trailing commas, content after the value and nesting beyond 64 levels. + Utf8JsonReader reader = new(utf8Json); + Stack> objects = new(); + + try + { + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + objects.Push(new HashSet(StringComparer.Ordinal)); + break; + + case JsonTokenType.EndObject: + objects.Pop(); + break; + + case JsonTokenType.PropertyName: + // GetString unescapes the name, so names that differ only in their escapes count as repeated. + if (!objects.Peek().Add(reader.GetString()!)) + { + return $"The body repeats a member name at byte {reader.TokenStartIndex}."; + } + + break; + + case JsonTokenType.String: + // The reader accepts an unpaired surrogate escape or invalid UTF-8 until a string is decoded. + _ = reader.GetString(); + break; + } + } + } + catch (JsonException exception) + { + return $"The body is not valid JSON, at line {exception.LineNumber + 1}, " + + $"byte {exception.BytePositionInLine + 1} of that line."; + } + catch (InvalidOperationException) + { + return $"The body holds a string that is not Unicode text at byte {reader.TokenStartIndex}."; + } + + return null; + } + + private static bool TryReadCsr( + JsonElement body, + [NotNullWhen(true)] out CertificateSigningRequest? csr, + [NotNullWhen(false)] out string? failure) + { + csr = null; + + if (!body.TryGetProperty("csr", out JsonElement value) || value.ValueKind == JsonValueKind.Null) + { + failure = "The body has no csr."; + return false; + } + + if (value.ValueKind != JsonValueKind.String) + { + failure = "The csr is not a JSON string."; + return false; + } + + // CONTRACT.agent-identity.md §1: a base64 value in any form but its canonical one is invalid. + if (!StrictBase64.TryDecodeBase64(value.GetString(), out byte[]? der)) + { + failure = "The csr is not canonical base64."; + return false; + } + + CsrValidationResult result = CertificateSigningRequest.Validate(der); + if (!result.Succeeded) + { + failure = result.FailureDetail; + return false; + } + + csr = result.Csr; + failure = null; + return true; + } + + private static bool TryReadMetadata( + JsonElement body, + [NotNullWhen(true)] out AgentMetadata? metadata, + [NotNullWhen(false)] out string? failure) + { + metadata = null; + + if (!body.TryGetProperty("metadata", out JsonElement value) || value.ValueKind == JsonValueKind.Null) + { + failure = "The body has no metadata."; + return false; + } + + if (value.ValueKind != JsonValueKind.Object) + { + failure = "The metadata is not a JSON object."; + return false; + } + + List> entries = []; + foreach (JsonProperty entry in value.EnumerateObject()) + { + if (entry.Value.ValueKind != JsonValueKind.String) + { + failure = $"The value of metadata member {entries.Count + 1} is not a JSON string."; + return false; + } + + entries.Add(new KeyValuePair(entry.Name, entry.Value.GetString()!)); + } + + MetadataValidationResult result = AgentMetadata.Validate(entries); + if (!result.Succeeded) + { + failure = result.FailureDetail; + return false; + } + + metadata = result.Metadata; + failure = null; + return true; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs new file mode 100644 index 0000000000..82a07d7807 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Bodies/ResponseBodies.cs @@ -0,0 +1,224 @@ +using System.Buffers; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; + +namespace Devolutions.AgentIdentity; + +/// +/// Writes the bodies of the successful responses of the agent-facing endpoints, as UTF-8 JSON objects. +/// +/// +/// Each body has exactly the members that CONTRACT.agent-identity.md §5 gives the response, +/// and no member is ever null. +/// A certificate is written as its DER in canonical base64, and a time as an RFC 3339 UTC timestamp. +/// +public static class ResponseBodies +{ + /// + /// Writes the body of a trust-anchor response: roots, the published root certificates. + /// + /// + /// The authority publishes one root normally, and the old and new roots during a rotation. + /// The roots are written in the order given, each with its certificate, its thumbprint, + /// and its not_before and not_after. + /// A chain that validates against these roots says nothing about whether its identity is revoked. + /// + /// The published root certificates. + /// is null. + /// is empty or holds null. + public static byte[] TrustAnchor(IEnumerable roots) + { + X509Certificate2[] published = Certificates(roots, nameof(roots)); + + return Write(writer => + { + writer.WriteStartArray("roots"); + + foreach (X509Certificate2 root in published) + { + ReadOnlySpan der = root.RawDataMemory.Span; + + writer.WriteStartObject(); + writer.WriteBase64String("certificate", der); + writer.WriteString("thumbprint", AgentIdentityProtocol.ComputeThumbprint(der)); + writer.WriteString("not_before", Timestamp(root.NotBefore)); + writer.WriteString("not_after", Timestamp(root.NotAfter)); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + }); + } + + /// + /// Writes the body of an enroll response, for a new identity or a replay: + /// authority_id, identity_id, certificate_chain and config. + /// + /// + /// The IDs are written as lowercase hyphenated UUIDs. + /// + /// The authority's ID, which never changes for the authority. + /// The identity's ID. + /// + /// The chain of the identity's current certificate, leaf first: each certificate after the leaf issued the one + /// before it. + /// + /// The identity's config. + /// + /// or is null. + /// + /// + /// An ID is the nil UUID, which stands for no ID; + /// or is empty, holds null, or is not leaf first. + /// + public static byte[] Enroll( + Guid authorityId, + Guid identityId, + IEnumerable certificateChain, + IdentityConfig config) + { + CheckId(authorityId, "authority", nameof(authorityId)); + CheckId(identityId, "identity", nameof(identityId)); + X509Certificate2[] chain = LeafFirstChain(certificateChain); + ArgumentNullException.ThrowIfNull(config); + + return Write(writer => + { + writer.WriteString("authority_id", authorityId.ToString("D")); + writer.WriteString("identity_id", identityId.ToString("D")); + WriteChain(writer, chain); + WriteConfig(writer, config); + }); + } + + /// + /// Writes the body of a renew response: certificate_chain. + /// + /// + /// The chain of the identity's pending certificate, leaf first: each certificate after the leaf issued the one + /// before it. + /// + /// is null. + /// + /// is empty, holds null, or is not leaf first. + /// + public static byte[] Renew(IEnumerable certificateChain) + { + X509Certificate2[] chain = LeafFirstChain(certificateChain); + + return Write(writer => WriteChain(writer, chain)); + } + + /// + /// Writes the body of a check-in response: config and renewal_requested. + /// + /// The identity's config. + /// + /// The identity's request-renewal flag, which the authority sets when it wants the identity to renew. + /// + /// is null. + public static byte[] CheckIn(IdentityConfig config, bool renewalRequested) + { + ArgumentNullException.ThrowIfNull(config); + + return Write(writer => + { + WriteConfig(writer, config); + writer.WriteBoolean("renewal_requested", renewalRequested); + }); + } + + private static byte[] Write(Action writeMembers) + { + ArrayBufferWriter buffer = new(); + + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writeMembers(writer); + writer.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + // Copies the certificates, so that the checks and the body see the same ones. + private static X509Certificate2[] Certificates(IEnumerable certificates, string paramName) + { + ArgumentNullException.ThrowIfNull(certificates, paramName); + X509Certificate2[] copy = [.. certificates]; + + if (copy.Length == 0) + { + throw new ArgumentException("There is no certificate.", paramName); + } + + if (copy.Any(certificate => certificate is null)) + { + throw new ArgumentException("A certificate is null.", paramName); + } + + return copy; + } + + private static X509Certificate2[] LeafFirstChain(IEnumerable certificateChain) + { + X509Certificate2[] chain = Certificates(certificateChain, nameof(certificateChain)); + + // RFC 5280 §4.1.2.6: a CA encodes its subject exactly as the issuer of each certificate it issues, + // so a chain in another order fails this comparison. + for (int i = 1; i < chain.Length; i++) + { + if (!chain[i - 1].IssuerName.RawData.AsSpan().SequenceEqual(chain[i].SubjectName.RawData)) + { + throw new ArgumentException( + $"The subject of chain certificate {i + 1} is not the issuer of certificate {i}, " + + "so the chain is not leaf first.", + nameof(certificateChain)); + } + } + + return chain; + } + + // RFC 9562 §5.9: the nil UUID stands for the absence of a UUID, and both IDs are required. + private static void CheckId(Guid id, string description, string paramName) + { + if (id == Guid.Empty) + { + throw new ArgumentException($"The {description} ID is the nil UUID.", paramName); + } + } + + private static void WriteChain(Utf8JsonWriter writer, X509Certificate2[] chain) + { + writer.WriteStartArray("certificate_chain"); + + foreach (X509Certificate2 certificate in chain) + { + writer.WriteBase64StringValue(certificate.RawDataMemory.Span); + } + + writer.WriteEndArray(); + } + + private static void WriteConfig(Utf8JsonWriter writer, IdentityConfig config) + { + writer.WritePropertyName("config"); + + // The config wrote these bytes itself when it was created. + writer.WriteRawValue(config.Utf8Json, skipInputValidation: true); + } + + // X509Certificate2 gives its validity in local time. + // UNTESTED: the conversion is exact in the hour that a daylight-saving fall-back repeats, + // because the local DateTime that X509Certificate2 returns keeps the ambiguous-DST bit, + // which ToUniversalTime uses. + // A wrong instant there publishes a root's validity an hour off on hosts outside UTC. + // Keep the conversion straight from the property: arithmetic, formatting or a DateTimeOffset built from the + // local value before ToUniversalTime loses the bit. + private static string Timestamp(DateTime localTime) + { + return AgentIdentityProtocol.FormatTimestamp(new DateTimeOffset(localTime.ToUniversalTime())); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs new file mode 100644 index 0000000000..2711a0f464 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateSigningRequest.cs @@ -0,0 +1,302 @@ +using System.Formats.Asn1; +using System.Numerics; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity; + +/// +/// A PKCS #10 certificate signing request from an agent, reduced to the public key whose possession it proves. +/// +/// +/// Only and the request body parsers create instances, +/// so an instance always holds an uncompressed P-256 key that signed its request. +/// The request's subject, attributes and requested extensions are ignored and not kept, +/// since an identity is never derived from claims in a CSR, except the enrollment URL that its extensionRequest names, +/// which enrollment compares with the presented token (CONTRACT.agent-identity.md §4); +/// so the subject and the attributes need only be BER elements of their type, +/// and the rest of the request is DER, as CONTRACT.agent-identity.md §1 requires of a CSR. +/// +public sealed class CertificateSigningRequest +{ + private const string IdEcPublicKey = "1.2.840.10045.2.1"; + private const string Secp256r1 = "1.2.840.10045.3.1.7"; + private const string EcdsaWithSha256 = "1.2.840.10045.4.3.2"; + + // RFC 2985 §5.4.2: the extensionRequest attribute. + private const string ExtensionRequest = "1.2.840.113549.1.9.14"; + + private static readonly Asn1Tag AttributesTag = new(TagClass.ContextSpecific, 0, isConstructed: true); + + // SEC 2 §2.4.2: secp256r1 is y² = x³ - 3x + b over the prime field of order p. + private static readonly BigInteger P256Prime = Unsigned( + "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF"); + + private static readonly BigInteger P256B = Unsigned( + "5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B"); + + private CertificateSigningRequest(byte[] subjectPublicKeyInfo, string? enrollmentUrl) + { + SubjectPublicKeyInfo = subjectPublicKeyInfo; + SubjectPublicKeyInfoSha256 = SHA256.HashData(subjectPublicKeyInfo); + EnrollmentUrl = enrollmentUrl; + } + + /// + /// The DER encoding of the request's SubjectPublicKeyInfo, exactly as the request holds it. + /// + /// + /// Validation accepts only the canonical encoding of an uncompressed P-256 key, + /// so each key has exactly one SubjectPublicKeyInfo. + /// + public ReadOnlyMemory SubjectPublicKeyInfo { get; } + + /// + /// The SHA-256 digest of , which identifies the key among every issued key. + /// + public ReadOnlyMemory SubjectPublicKeyInfoSha256 { get; } + + /// + /// The value of the enrollment URL extension that the request's extensionRequest attribute holds, + /// or null when the request names no single enrollment URL. + /// + /// + /// The value is read as text, as the request holds it. + /// It is null when no extensionRequest holds the extension, when the extension appears more than once, + /// or when its value is not one IA5String; a request whose attributes cannot be read names none either. + /// compares it with the presented token's u, + /// and a renewal ignores it (CONTRACT.agent-identity.md §4). + /// + public string? EnrollmentUrl { get; } + + /// + /// Validates a PKCS #10 certificate signing request (RFC 2986). + /// + /// + /// A valid request has version 0 and no trailing bytes, and is DER except for the subject and the attributes, + /// which the server ignores but for the enrollment URL that holds: + /// each need only be a BER element of its type, a SEQUENCE and a constructed [0], + /// and the attributes never make the request invalid; the values of attributes other than extensionRequest stay + /// unread. + /// Its key is id-ecPublicKey on the named curve P-256, as an uncompressed point on the curve (RFC 5480). + /// Its signature algorithm is ecdsa-with-SHA256 without parameters (RFC 5758), + /// and its signature verifies under its key over the exact bytes of its certificationRequestInfo. + /// + /// The encoding of the request, DER except for its subject and attributes. + public static CsrValidationResult Validate(ReadOnlySpan der) + { + try + { + return Parse(der.ToArray()); + } + catch (AsnContentException) + { + return CsrValidationResult.Failure("The CSR is not a DER-encoded PKCS #10 request."); + } + } + + // RFC 2986 §4: + // CertificationRequest ::= SEQUENCE { certificationRequestInfo, signatureAlgorithm, signature BIT STRING } + // CertificationRequestInfo ::= SEQUENCE { version INTEGER, subject Name, subjectPKInfo, attributes [0] } + private static CsrValidationResult Parse(byte[] der) + { + AsnReader outer = new(der, AsnEncodingRules.DER); + AsnReader request = outer.ReadSequence(); + if (outer.HasData) + { + return CsrValidationResult.Failure("The CSR is followed by other bytes."); + } + + ReadOnlyMemory requestInfo = request.PeekEncodedValue(); + ReadOnlyMemory fields = request.PeekContentBytes(); + _ = request.ReadSequence(); + AsnReader signatureAlgorithm = request.ReadSequence(); + byte[] signature = request.ReadBitString(out int signatureUnusedBits); + request.ThrowIfNotEmpty(); + + if (!AsnDecoder.TryReadInt32(fields.Span, AsnEncodingRules.DER, out int version, out int length) || version != 0) + { + return CsrValidationResult.Failure("The CSR version is not 0."); + } + + // The subject and the attributes are read only as BER elements of their type, since nothing the server decides + // depends on them but the enrollment URL, whose reading never makes the request invalid; + // the self-signature still covers their bytes. + fields = fields[length..]; + AsnDecoder.ReadSequence(fields.Span, AsnEncodingRules.BER, out _, out _, out length); + + fields = fields[length..]; + AsnDecoder.ReadSequence(fields.Span, AsnEncodingRules.DER, out _, out _, out length); + ReadOnlyMemory subjectPublicKeyInfo = fields[..length]; + + AsnReader attributes = new(fields[length..], AsnEncodingRules.BER); + AsnReader attributeSet = attributes.ReadSetOf(skipSortOrderValidation: true, expectedTag: AttributesTag); + attributes.ThrowIfNotEmpty(); + + AsnReader subjectPublicKeyInfoReader = new AsnReader(subjectPublicKeyInfo, AsnEncodingRules.DER).ReadSequence(); + if (ReadP256Point(subjectPublicKeyInfoReader, out ECPoint point) is { } keyFailure) + { + return CsrValidationResult.Failure(keyFailure); + } + + if (signatureAlgorithm.ReadObjectIdentifier() != EcdsaWithSha256 || signatureAlgorithm.HasData) + { + return CsrValidationResult.Failure( + "The CSR signature algorithm is not ecdsa-with-SHA256 without parameters."); + } + + if (signatureUnusedBits != 0 || !IsPositiveIntegerPair(signature)) + { + return CsrValidationResult.Failure( + "The CSR signature is not a DER ECDSA-Sig-Value of positive integers."); + } + + using ECDsa key = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = point }); + + if (!key.VerifyData( + requestInfo.Span, + signature, + HashAlgorithmName.SHA256, + DSASignatureFormat.Rfc3279DerSequence)) + { + return CsrValidationResult.Failure("The CSR self-signature does not verify."); + } + + return CsrValidationResult.Success( + new CertificateSigningRequest(subjectPublicKeyInfo.ToArray(), ReadEnrollmentUrl(attributeSet))); + } + + // RFC 2986 §4: Attribute ::= SEQUENCE { type OBJECT IDENTIFIER, values SET OF ANY }; + // RFC 2985 §5.4.2: an extensionRequest value is Extensions ::= SEQUENCE OF Extension; + // RFC 5280 §4.1: Extension ::= SEQUENCE { extnID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }. + // Returns the value of the one enrollment URL extension among the requested extensions, + // or null when there is none, there are several, or the attributes cannot be read, + // since the server otherwise ignores the attributes (CONTRACT.agent-identity.md §4). + private static string? ReadEnrollmentUrl(AsnReader attributeSet) + { + try + { + List values = []; + + while (attributeSet.HasData) + { + AsnReader attribute = attributeSet.ReadSequence(); + + // The values of every other attribute stay unread, since the server ignores them. + if (attribute.ReadObjectIdentifier() != ExtensionRequest) + { + continue; + } + + AsnReader attributeValues = attribute.ReadSetOf(skipSortOrderValidation: true); + + while (attributeValues.HasData) + { + AsnReader extensions = attributeValues.ReadSequence(); + + while (extensions.HasData) + { + AsnReader extension = extensions.ReadSequence(); + string id = extension.ReadObjectIdentifier(); + if (extension.PeekTag().HasSameClassAndValue(Asn1Tag.Boolean)) + { + _ = extension.ReadBoolean(); + } + + byte[] value = extension.ReadOctetString(); + if (id == EnrollmentUrlExtension.Oid) + { + values.Add(value); + } + } + } + } + + return values is [byte[] single] ? EnrollmentUrlExtension.ReadValue(single) : null; + } + catch (AsnContentException) + { + return null; + } + } + + // RFC 5480 §2: SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }, + // with id-ecPublicKey, the namedCurve secp256r1 and an ECPoint whose octets are the bits of the BIT STRING. + // Returns why the key is not an uncompressed P-256 point, or null. + private static string? ReadP256Point(AsnReader subjectPublicKeyInfo, out ECPoint point) + { + point = default; + + AsnReader algorithm = subjectPublicKeyInfo.ReadSequence(); + byte[] encodedPoint = subjectPublicKeyInfo.ReadBitString(out int unusedBits); + subjectPublicKeyInfo.ThrowIfNotEmpty(); + + if (algorithm.ReadObjectIdentifier() != IdEcPublicKey) + { + return "The CSR key is not an id-ecPublicKey key."; + } + + // RFC 5480 §2.1.1: the parameters are present and use the namedCurve choice. + if (!algorithm.HasData + || !algorithm.PeekTag().HasSameClassAndValue(Asn1Tag.ObjectIdentifier) + || algorithm.ReadObjectIdentifier() != Secp256r1 + || algorithm.HasData) + { + return "The CSR key is not on the named curve P-256."; + } + + // SEC 1 §2.3.3: the uncompressed form is 04, then x and y in 32 octets each. + // Requiring it gives each key exactly one encoding, and so one SubjectPublicKeyInfo SHA-256. + if (unusedBits != 0 || encodedPoint.Length != 65 || encodedPoint[0] != 0x04) + { + return "The CSR key is not an uncompressed P-256 point."; + } + + byte[] x = encodedPoint[1..33]; + byte[] y = encodedPoint[33..]; + + if (!IsOnP256(x, y)) + { + return "The CSR key is not a point on P-256."; + } + + point = new ECPoint { X = x, Y = y }; + return null; + } + + // Checks the curve equation itself rather than relying on the platform's key import, + // which throws an exception of a platform-specific type for a point off the curve. + private static bool IsOnP256(byte[] x, byte[] y) + { + BigInteger bx = new(x, isUnsigned: true, isBigEndian: true); + BigInteger by = new(y, isUnsigned: true, isBigEndian: true); + + if (bx >= P256Prime || by >= P256Prime) + { + return false; + } + + BigInteger left = by * by % P256Prime; + BigInteger right = BigInteger.Remainder((bx * bx * bx) - (3 * bx) + P256B, P256Prime); + + return left == right; + } + + // RFC 3279 §2.2.3: Ecdsa-Sig-Value ::= SEQUENCE { r INTEGER, s INTEGER }, where r and s are positive. + // The platform verifier reads each INTEGER as unsigned, so it would also accept an r or s that DER makes negative. + private static bool IsPositiveIntegerPair(byte[] signature) + { + AsnReader outer = new(signature, AsnEncodingRules.DER); + AsnReader sequence = outer.ReadSequence(); + BigInteger r = sequence.ReadInteger(); + BigInteger s = sequence.ReadInteger(); + sequence.ThrowIfNotEmpty(); + outer.ThrowIfNotEmpty(); + + return r.Sign > 0 && s.Sign > 0; + } + + private static BigInteger Unsigned(string hex) + { + return new BigInteger(Convert.FromHexString(hex), isUnsigned: true, isBigEndian: true); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateStatus.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateStatus.cs new file mode 100644 index 0000000000..8d5863a4b9 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CertificateStatus.cs @@ -0,0 +1,27 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The status of a certificate the authority issued to an identity. +/// +public enum CertificateStatus +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a status would change what an existing host reports. + // When adding a status, give it the next unused value; + // values start at 1 so that an uninitialized status is not a valid one. + + /// + /// Issued by renew and not yet confirmed. + /// + Pending = 1, + + /// + /// The certificate the identity authenticates with. + /// + Current = 2, + + /// + /// Replaced by a confirmed certificate. + /// + Retired = 3, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs new file mode 100644 index 0000000000..5f59609f49 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/CsrValidationResult.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of : a validated request, or the error that rejects it. +/// +/// +/// The error is 400 invalid_request. +/// +public sealed class CsrValidationResult : AgentIdentityResult +{ + private static readonly AgentIdentityError InvalidRequest = + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest); + + private CsrValidationResult(CertificateSigningRequest? csr, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) + { + Csr = csr; + } + + /// + /// Whether the request is valid. + /// + [MemberNotNullWhen(true, nameof(Csr))] + public override bool Succeeded => Csr is not null; + + /// + /// The validated request, when is true. + /// + public CertificateSigningRequest? Csr { get; } + + internal static CsrValidationResult Success(CertificateSigningRequest csr) + { + return new CsrValidationResult(csr, null, null); + } + + internal static CsrValidationResult Failure(string detail) + { + return new CsrValidationResult(null, InvalidRequest, detail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs new file mode 100644 index 0000000000..fe1b5c4992 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/EnrollmentUrlExtension.cs @@ -0,0 +1,50 @@ +using System.Formats.Asn1; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity; + +/// +/// The enrollment URL extension of CONTRACT.agent-identity.md §4: a non-critical extension whose value is an IA5String +/// holding the u of the token that enrolled an identity, without trailing slashes. +/// +/// +/// Every certificate issued for an identity carries it once with the same value, +/// and the agent's CSR requests it in an extensionRequest with the base URL it sends the request to. +/// +internal static class EnrollmentUrlExtension +{ + // CONTRACT.agent-identity.md §4: the OID of the enrollment URL extension, under the enterprise number that RFC 5612 + // reserves for documentation; the contract moves it under Devolutions' own enterprise number before release. + public const string Oid = "1.3.6.1.4.1.32473.1.1"; + + /// + /// Creates the extension, non-critical, for an enrollment URL. + /// + /// The enrollment URL, which is ASCII and has no trailing slash. + public static X509Extension Create(string enrollmentUrl) + { + AsnWriter writer = new(AsnEncodingRules.DER); + writer.WriteCharacterString(UniversalTagNumber.IA5String, enrollmentUrl); + + return new X509Extension(Oid, writer.Encode(), critical: false); + } + + /// + /// Reads the enrollment URL from the value of the extension, or returns null when the value is not one IA5String. + /// + /// The contents of the extension's extnValue OCTET STRING. + public static string? ReadValue(ReadOnlyMemory value) + { + try + { + AsnReader reader = new(value, AsnEncodingRules.DER); + string enrollmentUrl = reader.ReadCharacterString(UniversalTagNumber.IA5String); + reader.ThrowIfNotEmpty(); + return enrollmentUrl; + } + catch (AsnContentException) + { + return null; + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs new file mode 100644 index 0000000000..50a1896784 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IRootSigner.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.AgentIdentity; + +/// +/// The root that issues identity certificates, implemented by the host, which keeps the root's private key. +/// +public interface IRootSigner +{ + /// + /// The root certificate, which must have a subject key identifier, + /// and fit the chain profile of CONTRACT.agent-identity.md §4: a P-256 or P-384 key, basicConstraints with cA set, + /// and key usage with keyCertSign. + /// + /// + /// Its subject becomes the issuer of each identity certificate, + /// and its subject key identifier becomes their authority key identifier. + /// + X509Certificate2 Certificate { get; } + + /// + /// Signs identity certificates with the private key of , + /// with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a P-384 key. + /// + X509SignatureGenerator SignatureGenerator { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs new file mode 100644 index 0000000000..6114d303ef --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/IdentityCertificateIssuer.cs @@ -0,0 +1,342 @@ +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// Issues identity certificates, the leaf certificates through which agents authenticate. +/// +public static class IdentityCertificateIssuer +{ + // RFC 5280 §4.2.1.12: id-kp-clientAuth. + private const string ClientAuthentication = "1.3.6.1.5.5.7.3.2"; + + private const string SubjectKeyIdentifierOid = "2.5.29.14"; + + private const string KeyUsageOid = "2.5.29.15"; + + private const string BasicConstraintsOid = "2.5.29.19"; + + // RFC 5480 §2.1.1: id-ecPublicKey. + private const string EcPublicKeyOid = "1.2.840.10045.2.1"; + + // RFC 5480 §2.1.1.1: the DER encodings of the namedCurve secp256r1 (P-256) and secp384r1 (P-384). + private static readonly byte[] P256CurveParameters = [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]; + private static readonly byte[] P384CurveParameters = [0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22]; + + // RFC 5758 §3.2: the DER AlgorithmIdentifiers of ecdsa-with-SHA256 and ecdsa-with-SHA384, + // whose parameters are absent. + private static readonly byte[] EcdsaWithSha256Identifier = + [0x30, 0x0A, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02]; + private static readonly byte[] EcdsaWithSha384Identifier = + [0x30, 0x0A, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x03]; + + /// + /// The lifetime of an identity certificate when the host sets no other: 90 days. + /// + public static TimeSpan DefaultLifetime { get; } = TimeSpan.FromDays(90); + + /// + /// Issues the certificate of the new identity that an enrollment decision creates, for the key of a validated CSR. + /// + /// + /// + /// The certificate has the CSR's key, the subject CN=<identity ID>, + /// the subject alternative name URI urn:uuid:<identity ID>, the extended key usage clientAuth, + /// the critical key usage digitalSignature, a subject key identifier, + /// an authority key identifier equal to the issuer's subject key identifier, and no basic constraints. + /// The identity ID is , written lowercase and hyphenated, + /// and nothing else from the CSR reaches the certificate. + /// + /// + /// It is valid from the current time, truncated to the second, for , + /// or until the issuer's notAfter if that comes first; a fraction of a second in the lifetime is dropped. + /// Its serial number is 16 octets from , positive and never zero, + /// and signs it with the ECDSA algorithm of its key's curve. + /// It names in the enrollment URL extension. + /// + /// + /// Taking the decision rather than an ID keeps the authority from certifying a key for an identity + /// that no enrollment created. + /// A renewal certifies a key for an existing identity, + /// so it uses the overload that takes the identity that the renew request proved. + /// + /// + /// + /// The decision of to create the identity, + /// whose is . + /// + /// The validated CSR whose key the certificate is for. + /// The root that signs the certificate. + /// The certificate lifetime, usually . + /// The clock, read once. + /// The source of the serial number. + /// An argument is null. + /// + /// does not create an identity; + /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second; + /// or the issuer is outside the chain profile of CONTRACT.agent-identity.md §4: + /// its certificate has neither a P-256 nor a P-384 key, no basicConstraints with cA set or no key usage with + /// keyCertSign, or its generator does not sign with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a + /// P-384 key, or holds another key than its certificate's. + /// + /// + /// is shorter than one second. + /// + public static X509Certificate2 Issue( + EnrollmentDecision creating, + CertificateSigningRequest csr, + IRootSigner issuer, + TimeSpan lifetime, + TimeProvider timeProvider, + RandomNumberGenerator random) + { + ArgumentNullException.ThrowIfNull(creating); + + if (creating.Outcome != EnrollmentOutcome.CreateIdentity) + { + throw new ArgumentException("The enrollment decision does not create an identity.", nameof(creating)); + } + + // A decision to create an identity always carries the ID minted for it and the token's URL. + return Issue( + creating.NewIdentityId!.Value, + creating.EnrollmentUrl!, + csr, + issuer, + lifetime, + timeProvider, + random); + } + + // Private so that a certificate is issued only for an identity ID taken from a proof of that identity. + private static X509Certificate2 Issue( + Guid identityId, + string enrollmentUrl, + CertificateSigningRequest csr, + IRootSigner issuer, + TimeSpan lifetime, + TimeProvider timeProvider, + RandomNumberGenerator random) + { + ArgumentNullException.ThrowIfNull(csr); + ArgumentNullException.ThrowIfNull(issuer); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(random); + + // A certificate holds whole seconds, so a shorter lifetime would end in the second it begins. + ArgumentOutOfRangeException.ThrowIfLessThan(lifetime, TimeSpan.FromSeconds(1)); + + X509Certificate2 issuerCertificate = issuer.Certificate; + + // RFC 5280 §4.2.1.1: the authority key identifier repeats the issuer's subject key identifier. + if (issuerCertificate.Extensions[SubjectKeyIdentifierOid] is not X509SubjectKeyIdentifierExtension issuerKeyId) + { + throw new ArgumentException("The issuer certificate has no subject key identifier.", nameof(issuer)); + } + + // CONTRACT.agent-identity.md §4: every certificate in a chain is signed with ecdsa-with-SHA256 by a P-256 key + // or with ecdsa-with-SHA384 by a P-384 key, and every CA certificate carries basicConstraints with cA set + // and key usage with keyCertSign. + (HashAlgorithmName hash, byte[] expectedAlgorithm) = SignatureProfile(issuerCertificate.PublicKey) + ?? throw new ArgumentException( + "The issuer certificate's key is neither a P-256 nor a P-384 key.", + nameof(issuer)); + + // Read once, so that every check acts on the generator that signs. + X509SignatureGenerator generator = issuer.SignatureGenerator; + byte[] signatureAlgorithm = generator.GetSignatureAlgorithmIdentifier(hash); + if (!signatureAlgorithm.AsSpan().SequenceEqual(expectedAlgorithm)) + { + throw new ArgumentException( + "The issuer does not sign with the ECDSA algorithm of its key's curve.", + nameof(issuer)); + } + + // A generator over another key would sign certificates that do not chain to the issuer certificate. + PublicKey signerKey = generator.PublicKey; + byte[] issuerKeyValue = issuerCertificate.PublicKey.EncodedKeyValue.RawData; + if (!signerKey.EncodedKeyValue.RawData.AsSpan().SequenceEqual(issuerKeyValue)) + { + throw new ArgumentException( + "The issuer's generator signs with another key than its certificate's.", + nameof(issuer)); + } + + if (issuerCertificate.Extensions[BasicConstraintsOid] is not X509BasicConstraintsExtension + { + CertificateAuthority: true, + }) + { + throw new ArgumentException("The issuer certificate is not a CA certificate.", nameof(issuer)); + } + + if (issuerCertificate.Extensions[KeyUsageOid] is not X509KeyUsageExtension keyUsage + || !keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.KeyCertSign)) + { + throw new ArgumentException("The issuer certificate's key usage has no keyCertSign.", nameof(issuer)); + } + + DateTimeOffset notBefore = TruncateToSecond(timeProvider.GetUtcNow()); + // UNTESTED: the conversion is exact in the hour that a daylight-saving fall-back repeats, + // because the local DateTime that X509Certificate2 returns keeps the ambiguous-DST bit, + // which ToUniversalTime uses. + // A wrong instant there shifts expiry and validity checks by an hour on hosts outside UTC. + // Keep the conversion straight from the property: arithmetic, formatting or a DateTimeOffset built from the + // local value before ToUniversalTime loses the bit. + DateTimeOffset issuerNotAfter = new(issuerCertificate.NotAfter.ToUniversalTime(), TimeSpan.Zero); + + if (issuerNotAfter <= notBefore) + { + throw new ArgumentException( + "The issuer certificate expires before the new certificate begins.", + nameof(issuer)); + } + + // Comparing the lifetime with the time left avoids adding a lifetime too long for DateTimeOffset. + DateTimeOffset notAfter = lifetime < issuerNotAfter - notBefore + ? TruncateToSecond(notBefore + lifetime) + : issuerNotAfter; + + string id = identityId.ToString("D"); + PublicKey publicKey = PublicKey.CreateFromSubjectPublicKeyInfo(csr.SubjectPublicKeyInfo.Span, out _); + + X500DistinguishedNameBuilder subject = new(); + subject.AddCommonName(id); + + SubjectAlternativeNameBuilder alternativeName = new(); + alternativeName.AddUri(new Uri($"urn:uuid:{id}")); + + CertificateRequest request = new(subject.Build(), publicKey, hash); + OidCollection usages = [new Oid(ClientAuthentication)]; + + // RFC 5280 §4.2.1.2, method (1): the SHA-1 hash of the subjectPublicKey bits. + X509SubjectKeyIdentifierHashAlgorithm keyIdMethod = X509SubjectKeyIdentifierHashAlgorithm.Sha1; + Collection extensions = request.CertificateExtensions; + extensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + extensions.Add(new X509EnhancedKeyUsageExtension(usages, critical: false)); + extensions.Add(alternativeName.Build(critical: false)); + extensions.Add(new X509SubjectKeyIdentifierExtension(publicKey, keyIdMethod, critical: false)); + extensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(issuerKeyId)); + extensions.Add(EnrollmentUrlExtension.Create(enrollmentUrl)); + + return request.Create( + issuerCertificate.SubjectName, + generator, + notBefore, + notAfter, + NewSerialNumber(random)); + } + + // The hash and the signature AlgorithmIdentifier that CONTRACT.agent-identity.md §4 pairs with the key's curve, + // or null for a key of another type or curve. + private static (HashAlgorithmName Hash, byte[] SignatureAlgorithm)? SignatureProfile(PublicKey key) + { + byte[]? curve = key.EncodedParameters?.RawData; + + if (key.Oid.Value != EcPublicKeyOid || curve is null) + { + return null; + } + + if (curve.AsSpan().SequenceEqual(P256CurveParameters)) + { + return (HashAlgorithmName.SHA256, EcdsaWithSha256Identifier); + } + + if (curve.AsSpan().SequenceEqual(P384CurveParameters)) + { + return (HashAlgorithmName.SHA384, EcdsaWithSha384Identifier); + } + + return null; + } + + /// + /// Issues the pending certificate of a renewal, for the identity that signed the renew request. + /// + /// + /// The certificate is the one that the other overload issues, with + /// as the identity ID and in the enrollment URL extension. + /// Taking the identity that the signature check produced, rather than an ID, + /// keeps a renewal from certifying a key for an identity that a request only named. + /// + /// + /// The identity that a renew request proved, as + /// returns it for . + /// + /// + /// The identity's enrollment URL, the of the decision that created + /// it, which every certificate of the identity names. + /// + /// The validated CSR whose key the certificate is for. + /// The root that signs the certificate. + /// The certificate lifetime, usually . + /// The clock, read once. + /// The source of the serial number. + /// An argument is null. + /// + /// was authenticated for another operation than renew; + /// or is empty, ends in a slash or is not ASCII; + /// or the issuer certificate has no subject key identifier, or its notAfter is not after the current second; + /// or the issuer is outside the chain profile of CONTRACT.agent-identity.md §4: + /// its certificate has neither a P-256 nor a P-384 key, no basicConstraints with cA set or no key usage with + /// keyCertSign, or its generator does not sign with ecdsa-with-SHA256 for a P-256 key or ecdsa-with-SHA384 for a + /// P-384 key, or holds another key than its certificate's. + /// + /// + /// is shorter than one second. + /// + public static X509Certificate2 Issue( + AuthenticatedIdentity renewing, + string enrollmentUrl, + CertificateSigningRequest csr, + IRootSigner issuer, + TimeSpan lifetime, + TimeProvider timeProvider, + RandomNumberGenerator random) + { + ArgumentNullException.ThrowIfNull(renewing); + + if (renewing.Operation != AgentOperation.Renew) + { + throw new ArgumentException("The identity was not authenticated by a renew request.", nameof(renewing)); + } + + ArgumentNullException.ThrowIfNull(enrollmentUrl); + + // CONTRACT.agent-identity.md §4: the extension holds the token's `u`, which is ASCII, without trailing slashes. + if (enrollmentUrl.Length == 0 || enrollmentUrl.EndsWith('/') || !Ascii.IsValid(enrollmentUrl)) + { + throw new ArgumentException( + "The enrollment URL is empty, ends in a slash or is not ASCII.", + nameof(enrollmentUrl)); + } + + return Issue(renewing.IdentityId, enrollmentUrl, csr, issuer, lifetime, timeProvider, random); + } + + private static DateTimeOffset TruncateToSecond(DateTimeOffset value) + { + return new DateTimeOffset(value.UtcTicks - (value.UtcTicks % TimeSpan.TicksPerSecond), TimeSpan.Zero); + } + + // RFC 5280 §4.1.2.2: a positive INTEGER of at most 20 octets. + private static byte[] NewSerialNumber(RandomNumberGenerator random) + { + byte[] serialNumber = new byte[16]; + + do + { + random.GetBytes(serialNumber); + + // A clear top bit keeps the INTEGER positive without a leading zero octet. + serialNumber[0] &= 0x7F; + } + while (!serialNumber.AsSpan().ContainsAnyExcept((byte)0)); + + return serialNumber; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalDecision.cs new file mode 100644 index 0000000000..5c2cea6753 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalDecision.cs @@ -0,0 +1,45 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with a renew request, as decides it. +/// +public sealed class RenewalDecision +{ + private RenewalDecision(RenewalOutcome outcome, AgentIdentityError? error, string? failureDetail) + { + Outcome = outcome; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// What the authority does with the request. + /// + public RenewalOutcome Outcome { get; } + + /// + /// The error that refuses the request, 400 invalid_request, + /// when is ; otherwise null. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that refuses the request, for the host's logs, + /// when is ; otherwise null. + /// + public string? FailureDetail { get; } + + internal static RenewalDecision IssuePendingCertificate { get; } = + new(RenewalOutcome.IssuePendingCertificate, null, null); + + internal static RenewalDecision ReturnPendingCertificate { get; } = + new(RenewalOutcome.ReturnPendingCertificate, null, null); + + internal static RenewalDecision Reject(string failureDetail) + { + return new RenewalDecision( + RenewalOutcome.Reject, + AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest), + failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalOutcome.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalOutcome.cs new file mode 100644 index 0000000000..246b9f8e9d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalOutcome.cs @@ -0,0 +1,29 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with a renew request. +/// +public enum RenewalOutcome +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering an outcome would change what an existing host does. + // When adding an outcome, give it the next unused value; + // values start at 1 so that an uninitialized outcome is not a valid one. + + /// + /// Issue a pending certificate for the CSR key, with the overload that + /// takes the renewing identity, and retire the identity's previous pending certificate, if any. + /// + IssuePendingCertificate = 1, + + /// + /// Return the chain of the identity's pending certificate, which holds the CSR key, + /// so that a device recovers a lost renew response. + /// + ReturnPendingCertificate = 2, + + /// + /// Refuse the request with . + /// + Reject = 3, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalRules.cs new file mode 100644 index 0000000000..be35911baa --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Certificates/RenewalRules.cs @@ -0,0 +1,72 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Applies the renewal rules that every authority shares to a renew request. +/// +public static class RenewalRules +{ + /// + /// Decides whether a renew request gets a new pending certificate, gets the identity's pending certificate again, + /// or is refused, from the issued certificate that holds the key of its CSR. + /// + /// + /// + /// The host calls this once the request authenticated and its body, CSR and metadata are valid. + /// The key of the identity's pending certificate gets that certificate's chain again, + /// so that a device recovers a lost renew response. + /// Any other issued key gets invalid_request: + /// the identity's current or retired key, any key of another identity, revoked or not, + /// and any key of a deleted identity. + /// A key never issued gets a new pending certificate, which retires the identity's previous pending certificate, + /// if any. + /// Renewal never promotes a certificate; only confirm does. + /// + /// + /// Every accepted request, including one that gets the pending certificate again, replaces the identity's metadata. + /// The decision holds only for the state it was made from. + /// The host issues the certificate atomically, so that of two concurrent renewals with one CSR key, + /// exactly one issues the certificate and the other gets that pending certificate. + /// + /// + /// + /// The identity that the renew request proved, as returns it + /// for . + /// + /// The issued certificate whose public key is the CSR key, or null when none has it. + /// is null. + /// + /// was authenticated for another operation than renew. + /// + public static RenewalDecision Decide(AuthenticatedIdentity renewing, IssuedKeyMatch? keyMatch) + { + ArgumentNullException.ThrowIfNull(renewing); + + if (renewing.Operation != AgentOperation.Renew) + { + throw new ArgumentException("The identity was not authenticated by a renew request.", nameof(renewing)); + } + + if (keyMatch is null) + { + return RenewalDecision.IssuePendingCertificate; + } + + // A public key belongs to one certificate lineage, so only the pending key of the identity itself is a retry. + if (keyMatch.IdentityDeleted) + { + return RenewalDecision.Reject("The CSR key belongs to a deleted identity."); + } + + if (keyMatch.IdentityId != renewing.IdentityId) + { + return RenewalDecision.Reject("The CSR key belongs to another identity."); + } + + if (keyMatch.Status != CertificateStatus.Pending) + { + return RenewalDecision.Reject("The CSR key belongs to a current or retired certificate of the identity."); + } + + return RenewalDecision.ReturnPendingCertificate; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshake.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshake.cs new file mode 100644 index 0000000000..9c21e59f45 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshake.cs @@ -0,0 +1,18 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The timing of the handshake that authenticates an agent channel stream. +/// +public static class ChannelHandshake +{ + /// + /// How long the agent has to answer the challenge after the opening request is verified: 10 seconds. + /// + /// + /// When no message arrives in that time, the host calls + /// and closes the stream with its error. + /// also fails a message that it reads later than that, + /// so a late timer cannot let one through. + /// + public static TimeSpan HelloTimeout { get; } = TimeSpan.FromSeconds(10); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs new file mode 100644 index 0000000000..bf7c810df2 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelHandshakeResult.cs @@ -0,0 +1,88 @@ +using System.Diagnostics.CodeAnalysis; + +using Devolutions.Agent.Channel.V1; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of the handshake that authenticates an agent channel stream: +/// the identity that the agent proved, with its metadata and the Welcome to send, +/// or the error that closes the stream. +/// +/// +/// +/// On success, the host replaces the identity's metadata with , sends , +/// and only then treats the stream as authenticated. +/// On failure, the host closes the stream with of +/// , and changes no state of the identity. +/// +/// +/// The error is when the certificate that opened the stream is +/// no longer registered or its identity was deleted, when the +/// identity was revoked, when the certificate expired +/// during the handshake, and when the agent proved the key in +/// time but sent invalid metadata. +/// Every other failure is . +/// never repeats a value from the message, not even a metadata key: +/// it names a metadata entry by its position. +/// +/// +public sealed class ChannelHandshakeResult : AgentIdentityResult +{ + private ChannelHandshakeResult( + AuthenticatedIdentity? identity, + AgentMetadata? metadata, + ServerMessage? welcome, + AgentIdentityError? error, + string? failureDetail) + : base(error, failureDetail) + { + Identity = identity; + Metadata = metadata; + Welcome = welcome; + } + + /// + /// Whether the stream authenticated: the certificate that opened it is still registered, unexpired and of an + /// unrevoked identity, and the agent proved in time that it holds its key and sent valid metadata. + /// + [MemberNotNullWhen(true, nameof(Identity), nameof(Metadata), nameof(Welcome))] + public override bool Succeeded => Identity is not null && Metadata is not null && Welcome is not null; + + /// + /// The identity that the stream is authenticated for, with , + /// when the handshake succeeded. + /// + /// + /// The stream stays bound to the certificate that opened it, + /// so the host closes the stream no later than . + /// When that certificate was retired during the handshake, + /// is , + /// and CONTRACT.agent-identity.md §7.4 says how the host treats a stream of a retired certificate. + /// + public AuthenticatedIdentity? Identity { get; } + + /// + /// The metadata of the Hello, which replaces the identity's metadata, when the handshake succeeded. + /// + public AgentMetadata? Metadata { get; } + + /// + /// The Welcome that answers the Hello, when the handshake succeeded: + /// a fresh id, the Hello id as correlation_id, and the server time. + /// + public ServerMessage? Welcome { get; } + + internal static ChannelHandshakeResult Success( + AuthenticatedIdentity identity, + AgentMetadata metadata, + ServerMessage welcome) + { + return new ChannelHandshakeResult(identity, metadata, welcome, null, null); + } + + internal static ChannelHandshakeResult Failure(AgentIdentityError error, string failureDetail) + { + return new ChannelHandshakeResult(null, null, null, error, failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelMessages.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelMessages.cs new file mode 100644 index 0000000000..e69b7ab908 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelMessages.cs @@ -0,0 +1,90 @@ +using System.Text; + +using Devolutions.Agent.Channel.V1; + +using ConfigUpdatePayload = Devolutions.Agent.Channel.V1.ConfigUpdate; +using ReconnectPayload = Devolutions.Agent.Channel.V1.Reconnect; +using RenewRequestedPayload = Devolutions.Agent.Channel.V1.RenewRequested; + +namespace Devolutions.AgentIdentity; + +/// +/// Builds the messages that the authority pushes to the agent on an agent channel stream. +/// +/// +/// The host sends them only on an authenticated stream, after its Welcome, +/// since nothing is pushed before the agent signs the stream's challenge. +/// Each message has a fresh id, a lowercase hyphenated UUID, +/// which the agent's Ack carries as its correlation_id. +/// A push is an optimization, since Hello and Welcome reconcile the state at every connection. +/// +public static class ChannelMessages +{ + /// + /// Builds a ConfigUpdate, whose config_json is the identity's config as a JSON object. + /// + /// + /// The host sends it after the Welcome when the identity's config has a higher revision than the one in + /// applied_state_versions["config"] of the Hello, + /// and on each live stream of the identity when its config changes. + /// + /// The identity's config. + /// is null. + public static ServerMessage ConfigUpdate(IdentityConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + return new ServerMessage + { + Id = NewId(), + ConfigUpdate = new ConfigUpdatePayload { ConfigJson = Encoding.UTF8.GetString(config.Utf8Json) }, + }; + } + + /// + /// Builds a RenewRequested, which asks the agent to renew its certificate. + /// + /// + /// While the identity's request-renewal flag is set, the host sends it after the Welcome of every stream + /// that the identity opens, until the identity confirms a certificate issued after the flag was set. + /// + /// Why the authority asks for the renewal. + /// is not a defined reason. + public static ServerMessage RenewRequested(RenewalRequestReason reason) + { + string wireReason = reason switch + { + RenewalRequestReason.Admin => "admin", + RenewalRequestReason.Rotation => "rotation", + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "Not a defined renewal request reason."), + }; + + return new ServerMessage { Id = NewId(), RenewRequested = new RenewRequestedPayload { Reason = wireReason } }; + } + + /// + /// Builds a Reconnect, which asks the agent to open a new stream and then close this one. + /// + /// + /// When a confirmation retires the certificate that opened a stream, + /// the host sends it on that stream with , + /// and closes the stream with status OK 60 s later if it is still open. + /// + /// Why the authority asks for a new stream. + /// is not a defined reason. + public static ServerMessage Reconnect(ReconnectReason reason) + { + string wireReason = reason switch + { + ReconnectReason.CertificateRotated => "certificate_rotated", + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "Not a defined reconnect reason."), + }; + + return new ServerMessage { Id = NewId(), Reconnect = new ReconnectPayload { Reason = wireReason } }; + } + + private static string NewId() + { + return Guid.NewGuid().ToString("D"); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs new file mode 100644 index 0000000000..b9af275468 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpening.cs @@ -0,0 +1,390 @@ +using System.Diagnostics.CodeAnalysis; + +using Devolutions.Agent.Channel.V1; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +using ChallengePayload = Devolutions.Agent.Channel.V1.Challenge; +using WelcomePayload = Devolutions.Agent.Channel.V1.Welcome; + +namespace Devolutions.AgentIdentity; + +/// +/// An agent channel stream whose signed opening request verified, before the agent proves it holds the key. +/// +/// +/// Signed opening headers are not tied to one stream and can be captured on the way, +/// so they never authenticate a stream on their own. +/// The host sends as the first message on the stream, +/// and the stream is authenticated only once the agent signs that challenge. +/// The identity stays hidden until then, so that nothing acts for it on the strength of the headers alone. +/// The host passes the first agent message to , +/// calls when it cannot decode that message, +/// or calls when none arrives within . +/// +public sealed class ChannelOpening +{ + private const string ExpiredDetail = "The certificate that opened the stream expired during the handshake."; + + private static readonly AgentIdentityError SignatureInvalid = + AgentIdentityError.From(AgentIdentityErrorCode.SignatureInvalid); + + private static readonly AgentIdentityError CertificateExpired = + AgentIdentityError.From(AgentIdentityErrorCode.CertificateExpired); + + private static readonly AgentIdentityError IdentityUnknown = + AgentIdentityError.From(AgentIdentityErrorCode.IdentityUnknown); + + private static readonly AgentIdentityError IdentityRevoked = + AgentIdentityError.From(AgentIdentityErrorCode.IdentityRevoked); + + private readonly TimeProvider _timeProvider; + private readonly ICertificateRegistry _certificateRegistry; + + // Set by the first call to VerifyHelloAsync, HelloTimedOutAsync or FirstMessageUnreadableAsync. + private int _handshakeEnded; + + internal ChannelOpening( + VerifiedCertificate certificate, + string connectNonce, + Guid challengeId, + byte[] challenge, + DateTimeOffset openedAt, + TimeProvider timeProvider, + ICertificateRegistry certificateRegistry) + { + Certificate = certificate; + ConnectNonce = connectNonce; + ChallengeId = challengeId.ToString("D"); + ChallengeBytes = challenge; + Challenge = new ServerMessage + { + Id = ChallengeId, + Challenge = new ChallengePayload { Challenge_ = ByteString.CopyFrom(challenge) }, + }; + OpenedAt = openedAt; + _timeProvider = timeProvider; + _certificateRegistry = certificateRegistry; + } + + /// + /// The first message to send on the stream: a Challenge with 32 random bytes fresh for this stream. + /// + public ServerMessage Challenge { get; } + + /// + /// The thumbprint of the certificate that signed the opening request, + /// for logs and to key the stream among the host's live streams until the handshake reveals the identity. + /// + /// + /// The host adds the stream under this thumbprint to the live streams that its revocation and deletion paths + /// close before it ends the handshake, so that a revocation that commits after the handshake's registry read + /// still finds and closes the stream. + /// + public string CertificateThumbprint => Certificate.Thumbprint; + + /// + /// The notAfter of the certificate that signed the opening request. + /// + /// + /// Once this time has passed, the host closes a stream still waiting for its Hello with the error of + /// : , which that method + /// returns only strictly after this time, unless the identity is also revoked or deleted. + /// It is a time, not an identity, which stays hidden until the agent signs the challenge. + /// + public DateTimeOffset CertificateNotAfter => Certificate.NotAfter; + + /// + /// The certificate that signed the opening request, whose key must also sign the challenge. + /// + internal VerifiedCertificate Certificate { get; } + + /// + /// The nonce parameter of the opening request, whose UTF-8 bytes end the signed challenge. + /// + internal string ConnectNonce { get; } + + /// + /// The id of , as sent. + /// + internal string ChallengeId { get; } + + /// + /// The random bytes of , as sent. + /// + /// + /// They are kept apart from the message, + /// so that a change to the message object cannot change what the agent must sign. + /// + internal ReadOnlyMemory ChallengeBytes { get; } + + /// + /// The instant the opening request was verified, + /// from which the agent has to answer the challenge. + /// + internal DateTimeOffset OpenedAt { get; } + + /// + /// Verifies the first message that the agent sent on the stream, + /// as CONTRACT.agent-identity.md §7.3 step 4 specifies. + /// + /// + /// The method reads the clock once, then the certificate registry once, + /// because the host cannot close the stream for a change to its identity while that identity is still hidden. + /// In the order of CONTRACT.agent-identity.md §6 step 4, the handshake fails with + /// when the certificate that opened the stream is no longer + /// registered or its identity was deleted, with when the + /// identity is revoked, and with after + /// . + /// A certificate retired since the opening still passes, + /// and then reports ; + /// CONTRACT.agent-identity.md §7.4 says how the host treats a stream of a retired certificate. + /// More than after the opening request was verified, + /// the handshake fails with , + /// even when the host's own timer let the message through. + /// The message must then be a Hello whose correlation_id is the id of , + /// and whose proof verifies with the key of the certificate that signed the opening request; + /// otherwise the handshake fails with . + /// Only then is the metadata checked, and metadata that CONTRACT.agent-identity.md §3 rejects fails the handshake + /// with . + /// The Welcome of a successful handshake carries the time of the clock reading. + /// The first call to this method, to + /// or to ends the handshake, whatever its outcome, + /// including an exception from the registry, which propagates. + /// + /// The first message that the agent sent on the stream. + /// A token that cancels the registry lookup. + /// is null. + /// + /// The handshake has already ended, + /// or the registry returned another certificate, or the certificate for another identity. + /// + public async ValueTask VerifyHelloAsync( + AgentMessage firstMessage, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(firstMessage); + EndHandshake(); + + (DateTimeOffset now, RegisteredCertificate? registered) = + await ReadClockAndRegistryAsync(cancellationToken).ConfigureAwait(false); + + if (IsClosed(registered, now, out ChannelHandshakeResult? closure)) + { + return closure; + } + + if (now - OpenedAt > ChannelHandshake.HelloTimeout) + { + return ChannelHandshakeResult.Failure(SignatureInvalid, "The Hello arrived after the timeout."); + } + + if (firstMessage.PayloadCase != AgentMessage.PayloadOneofCase.Hello) + { + return ChannelHandshakeResult.Failure(SignatureInvalid, "The first message on the stream is not a Hello."); + } + + if (!firstMessage.HasCorrelationId) + { + return ChannelHandshakeResult.Failure(SignatureInvalid, "The Hello has no correlation_id."); + } + + if (!string.Equals(firstMessage.CorrelationId, ChallengeId, StringComparison.Ordinal)) + { + return ChannelHandshakeResult.Failure( + SignatureInvalid, + "The correlation_id of the Hello is not the id of the Challenge."); + } + + Hello hello = firstMessage.Hello; + + if (!ChannelProof.Verify(Certificate.SubjectPublicKeyInfo, ChallengeBytes.Span, ConnectNonce, hello.Proof.Span)) + { + return ChannelHandshakeResult.Failure( + SignatureInvalid, + "The proof of the Hello does not verify with the key of the certificate that opened the stream."); + } + + MetadataValidationResult metadata = AgentMetadata.Validate(hello.Metadata); + if (!metadata.Succeeded) + { + return ChannelHandshakeResult.Failure(metadata.Error, metadata.FailureDetail); + } + + ServerMessage welcome = new() + { + Id = Guid.NewGuid().ToString("D"), + CorrelationId = firstMessage.Id, + Welcome = new WelcomePayload { ServerTime = Timestamp.FromDateTimeOffset(now) }, + }; + + return ChannelHandshakeResult.Success( + (Certificate with { Status = registered.Status }).ToIdentity(AgentOperation.Connect), + metadata.Metadata, + welcome); + } + + /// + /// Ends the handshake of a stream on which no agent message arrived within + /// . + /// + /// + /// A stream still awaiting its Hello closes for its identity and its certificate before it closes for the + /// timeout, so the method reads the clock once, then the certificate registry once, as + /// does. + /// In the order of CONTRACT.agent-identity.md §6 step 4, the handshake fails with + /// when the certificate that opened the stream is no longer + /// registered or its identity was deleted, with when the + /// identity is revoked, and with after + /// . + /// Otherwise it fails with . + /// The host also calls it when the agent ends the stream without sending a message. + /// The first call to this method, to + /// or to ends the handshake, whatever its outcome, + /// including an exception from the registry, which propagates. + /// + /// A token that cancels the registry lookup. + /// + /// The handshake has already ended, + /// or the registry returned another certificate, or the certificate for another identity. + /// + public async ValueTask HelloTimedOutAsync(CancellationToken cancellationToken = default) + { + EndHandshake(); + + return await FailWithoutHelloAsync("No Hello arrived before the timeout.", cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Ends the handshake of a stream whose first agent message could not be decoded. + /// + /// + /// The host calls it when the transport cannot decode the first AgentMessage, + /// for example because a protobuf string holds invalid UTF-8, so that no message exists to pass to + /// . + /// No proof can be read from such a message, so CONTRACT.agent-identity.md §7.3 step 4 counts it as not a + /// Hello. + /// Like , the method reads the clock once, then the certificate registry once, + /// and fails the handshake for the certificate and its identity as that method does. + /// Otherwise it fails with . + /// The first call to this method, to + /// or to ends the handshake, whatever its outcome, + /// including an exception from the registry, which propagates. + /// + /// A token that cancels the registry lookup. + /// + /// The handshake has already ended, + /// or the registry returned another certificate, or the certificate for another identity. + /// + public async ValueTask FirstMessageUnreadableAsync( + CancellationToken cancellationToken = default) + { + EndHandshake(); + + return await FailWithoutHelloAsync("The first message on the stream could not be decoded.", cancellationToken) + .ConfigureAwait(false); + } + + private void EndHandshake() + { + // UNTESTED: of two calls racing on two threads, exactly one ends the handshake and the other throws. + // A host can receive the first message just as its Hello timer fires, + // and both calls deciding the stream could authenticate a stream that is being closed. + // When changing this guard, keep the read and the write of the flag in one atomic operation. + if (Interlocked.Exchange(ref _handshakeEnded, 1) != 0) + { + throw new InvalidOperationException("The handshake of this channel opening has already ended."); + } + } + + // CONTRACT.agent-identity.md §6: a certificate is unexpired while now ≤ notAfter. + private bool HasExpired(DateTimeOffset now) + { + return now > Certificate.NotAfter; + } + + // Reads the clock, then the registry, once each. + // The clock comes first, as in CONTRACT.agent-identity.md §6, so that a slow registry cannot make a Hello late. + // The host cannot tie the stream to its identity before the proof verifies, + // so only this lookup sees a revocation, a deletion or a rotation since the opening. + private async ValueTask<(DateTimeOffset Now, RegisteredCertificate? Registered)> ReadClockAndRegistryAsync( + CancellationToken cancellationToken) + { + DateTimeOffset now = _timeProvider.GetUtcNow(); + RegisteredCertificate? registered = await _certificateRegistry + .FindByThumbprintAsync(Certificate.Thumbprint, cancellationToken) + .ConfigureAwait(false); + + return (now, registered); + } + + // The outcome of a handshake with no Hello to verify: the closure that applies, if any, + // and otherwise signature_invalid with the detail, as CONTRACT.agent-identity.md §7.3 step 4 specifies. + private async ValueTask FailWithoutHelloAsync( + string detail, + CancellationToken cancellationToken) + { + (DateTimeOffset now, RegisteredCertificate? registered) = + await ReadClockAndRegistryAsync(cancellationToken).ConfigureAwait(false); + + return IsClosed(registered, now, out ChannelHandshakeResult? closure) + ? closure + : ChannelHandshakeResult.Failure(SignatureInvalid, detail); + } + + // CONTRACT.agent-identity.md §7.4: a deletion, a revocation and the certificate's notAfter close a stream even + // while it awaits its Hello, ahead of every failure of §7.3 step 4, in the order of §6 step 4. + // A certificate retired since the opening does not close the stream: + // §7.4 lets that stream complete the handshake, whereas identity_unknown would end the identity for an agent + // that did nothing wrong. + private bool IsClosed( + [NotNullWhen(false)] RegisteredCertificate? registered, + DateTimeOffset now, + [NotNullWhen(true)] out ChannelHandshakeResult? closure) + { + if (registered is null) + { + closure = ChannelHandshakeResult.Failure( + IdentityUnknown, + "The certificate that opened the stream is no longer registered, or its identity was deleted."); + return true; + } + + CheckIsTheOpeningCertificate(registered); + + if (registered.IdentityRevoked) + { + closure = ChannelHandshakeResult.Failure( + IdentityRevoked, + "The identity was revoked after the stream opened."); + return true; + } + + if (HasExpired(now)) + { + closure = ChannelHandshakeResult.Failure(CertificateExpired, ExpiredDetail); + return true; + } + + closure = null; + return false; + } + + // The registry answers a thumbprint with the certificate that the authority issued under it, for its one identity, + // so another answer is a host bug, which fails as a server error rather than as a verdict on the agent. + private void CheckIsTheOpeningCertificate(RegisteredCertificate registered) + { + if (AgentIdentityProtocol.ComputeThumbprint(registered.CertificateDer.Span) != Certificate.Thumbprint) + { + throw new InvalidOperationException( + "The certificate registry returned a certificate whose thumbprint is not the one it was asked for."); + } + + if (registered.IdentityId != Certificate.IdentityId) + { + throw new InvalidOperationException( + "The certificate registry returned the certificate that opened the stream for another identity."); + } + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs new file mode 100644 index 0000000000..017d22dfe4 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelOpeningResult.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of authenticating the signed opening request of an agent channel stream. +/// +/// +/// When the opening fails, the host closes the stream with of +/// . +/// names the rule that the opening request broke and the step of +/// CONTRACT.agent-identity.md §6 that checks it. +/// +public sealed class ChannelOpeningResult : AgentIdentityResult +{ + private ChannelOpeningResult(ChannelOpening? opening, AgentIdentityError? error, string? failureDetail) + : base(error, failureDetail) + { + Opening = opening; + } + + /// + /// Whether the opening request authenticated. + /// + [MemberNotNullWhen(true, nameof(Opening))] + public override bool Succeeded => Opening is not null; + + /// + /// The opened stream, waiting for the agent to sign its challenge, when the opening request authenticated. + /// + public ChannelOpening? Opening { get; } + + internal static ChannelOpeningResult Success(ChannelOpening opening) + { + return new ChannelOpeningResult(opening, null, null); + } + + internal static ChannelOpeningResult Failure(AgentIdentityError error, string failureDetail) + { + return new ChannelOpeningResult(null, error, failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs new file mode 100644 index 0000000000..6be2835d6a --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ChannelProof.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// The proof in Hello that the agent holds the key of the certificate that opened the stream, +/// as CONTRACT.agent-identity.md §7.3 step 3 defines it. +/// +internal static class ChannelProof +{ + // The device key also signs request signature bases, which start with a quoted component name, + // so no proof is ever a valid request signature, nor the reverse. + private static ReadOnlySpan Domain => "devolutions-agent-identity/v1/channel-proof"u8; + + /// + /// Builds the bytes that the proof signs: the domain, a zero byte, the challenge, + /// then the UTF-8 bytes of the nonce parameter of the opening request. + /// + public static byte[] BuildInput(ReadOnlySpan challenge, string connectNonce) + { + return [.. Domain, 0x00, .. challenge, .. Encoding.UTF8.GetBytes(connectNonce)]; + } + + /// + /// Verifies a proof, which fails unless it is a raw 64-byte r‖s signature by the key. + /// + /// + /// The P-256 key of the certificate that authenticated the opening request. + /// + /// The random bytes of the Challenge that the stream sent. + /// The nonce parameter of the opening request. + /// The proof of the Hello. + public static bool Verify( + byte[] subjectPublicKeyInfo, + ReadOnlySpan challenge, + string connectNonce, + ReadOnlySpan proof) + { + using ECDsa publicKey = ECDsa.Create(); + publicKey.ImportSubjectPublicKeyInfo(subjectPublicKeyInfo, out _); + + return EcdsaP256Signature.Verify(publicKey, BuildInput(challenge, connectNonce), proof); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ReconnectReason.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ReconnectReason.cs new file mode 100644 index 0000000000..6a038f25ee --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/ReconnectReason.cs @@ -0,0 +1,17 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Why the authority asks the agent to replace an agent channel stream, as a Reconnect message says. +/// +public enum ReconnectReason +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a reason would change what an existing host sends. + // When adding a reason, give it the next unused value; + // values start at 1 so that an uninitialized reason is not a valid one. + + /// + /// A confirmation retired the certificate that opened the stream: certificate_rotated. + /// + CertificateRotated = 1, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/RenewalRequestReason.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/RenewalRequestReason.cs new file mode 100644 index 0000000000..169e5cca90 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Channel/RenewalRequestReason.cs @@ -0,0 +1,22 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Why the authority asks an identity to renew its certificate, as a RenewRequested message says. +/// +public enum RenewalRequestReason +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a reason would change what an existing host sends. + // When adding a reason, give it the next unused value; + // values start at 1 so that an uninitialized reason is not a valid one. + + /// + /// An administrator asked for the renewal: admin. + /// + Admin = 1, + + /// + /// A root rotation started while the identity holds a certificate from the old root: rotation. + /// + Rotation = 2, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj new file mode 100644 index 0000000000..9b498dd5fb --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj @@ -0,0 +1,37 @@ + + + net10.0 + enable + enable + true + true + NU1901;NU1902;NU1903;NU1904 + Devolutions.AgentIdentity + Library + Devolutions Inc. + Devolutions Inc. + Devolutions Agent Identity + Agent-facing protocol rules shared by every Devolutions Agent Identity authority + © Devolutions Inc. All rights reserved. + Devolutions.AgentIdentity + 2026.10.20 + MIT OR Apache-2.0 + README.md + https://github.com/Devolutions/devolutions-gateway.git + git + true + snupkg + + + + + + + + + + + + + + diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs new file mode 100644 index 0000000000..fe50a1517b --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorization.cs @@ -0,0 +1,120 @@ +using System.Buffers; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// Reads the credential of an enroll request, Authorization: Bearer <token>. +/// +public static class EnrollmentAuthorization +{ + private const string TokenStart = EnrollmentToken.Prefix + "."; + + private static readonly SearchValues Base64UrlAlphabet = + SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"); + + /// + /// Reads the enrollment token of an enroll request and returns the hash under which the authority finds it. + /// + /// + /// + /// The request gets token_invalid when the Authorization field is missing or repeated, + /// does not use the Bearer scheme, or holds no dvaet1 token of at most 4096 bytes + /// whose secret is in canonical base64url form, with its pad bits zero. + /// The scheme is case-insensitive, one or more spaces separate it from the token, + /// and white space around the field value is ignored. + /// + /// + /// The authority finds a token by its secret alone, so the bag is not decoded. + /// The lookup includes revoked tokens, which keep their hash, + /// and refuses them except for a replay. + /// When no stored token has , + /// the request gets token_invalid too. + /// Both checks come before any check of the request body. + /// + /// + /// + /// The values of the request's Authorization field lines, one per line as received, without combining them. + /// + /// is null. + public static EnrollmentAuthorizationResult ReadBearerToken(IReadOnlyList authorizationFieldLines) + { + ArgumentNullException.ThrowIfNull(authorizationFieldLines); + + if (authorizationFieldLines.Count == 0) + { + return EnrollmentAuthorizationResult.Failure("The request has no Authorization field."); + } + + if (authorizationFieldLines.Count != 1) + { + return EnrollmentAuthorizationResult.Failure("The request has more than one Authorization field line."); + } + + // RFC 9110 §5.5: the optional white space around a field value is not part of it. + ReadOnlySpan value = authorizationFieldLines[0].AsSpan().Trim(" \t"); + int separator = value.IndexOf(' '); + ReadOnlySpan scheme = separator < 0 ? value : value[..separator]; + + if (!Ascii.EqualsIgnoreCase(scheme, "Bearer")) + { + return EnrollmentAuthorizationResult.Failure("The Authorization field does not use the Bearer scheme."); + } + + if (separator < 0) + { + return EnrollmentAuthorizationResult.Failure( + "The Authorization field has no token after the Bearer scheme."); + } + + ReadOnlySpan token = value[separator..].TrimStart(' '); + + if (!(token.Length <= EnrollmentToken.MaxLength)) + { + return EnrollmentAuthorizationResult.Failure("The bearer token is longer than 4096 bytes."); + } + + if (!TryReadSecret(token, out ReadOnlySpan secret)) + { + return EnrollmentAuthorizationResult.Failure("The bearer token is not a dvaet1 enrollment token."); + } + + // CONTRACT.agent-identity.md §2 accepts only the canonical form of the secret, + // since a decoder that ignored the pad bits would read the same 32 bytes from four secret texts. + if (!StrictBase64.TryDecodeBase64Url(secret, out byte[]? secretBytes)) + { + return EnrollmentAuthorizationResult.Failure( + "The secret of the bearer token is not in canonical base64url form."); + } + + return EnrollmentAuthorizationResult.Success(EnrollmentToken.HashSecret(secretBytes)); + } + + // Matches the entire token, with nothing after the secret, + // against the pattern of CONTRACT.agent-identity.md §2: ^dvaet1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$. + private static bool TryReadSecret(ReadOnlySpan token, out ReadOnlySpan secret) + { + secret = default; + + if (!token.StartsWith(TokenStart, StringComparison.Ordinal)) + { + return false; + } + + ReadOnlySpan segments = token[TokenStart.Length..]; + int separator = segments.LastIndexOf('.'); + + if (separator < 0) + { + return false; + } + + ReadOnlySpan bag = segments[..separator]; + secret = segments[(separator + 1)..]; + + return 0 < bag.Length + && secret.Length == EnrollmentToken.SecretLength + && !bag.ContainsAnyExcept(Base64UrlAlphabet) + && !secret.ContainsAnyExcept(Base64UrlAlphabet); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs new file mode 100644 index 0000000000..95295ae5bc --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentAuthorizationResult.cs @@ -0,0 +1,43 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The result of : +/// the hash under which the authority finds the enrollment token, or token_invalid. +/// +/// +/// tells whether the request holds a well-formed token, +/// which still authenticates nothing until the authority finds a stored token by its hash. +/// The error is token_invalid, with HTTP status 401, +/// and is a fixed sentence that never holds any part of the token. +/// +public sealed class EnrollmentAuthorizationResult : AgentIdentityResult +{ + private EnrollmentAuthorizationResult( + ReadOnlyMemory secretHash, + AgentIdentityError? error, + string? failureDetail) + : base(error, failureDetail) + { + SecretHash = secretHash; + } + + /// + /// The SHA-256 of the 32 bytes that the token's secret encodes, + /// which is the of the stored token; + /// empty when is false. + /// + public ReadOnlyMemory SecretHash { get; } + + internal static EnrollmentAuthorizationResult Success(byte[] secretHash) + { + return new EnrollmentAuthorizationResult(secretHash, null, null); + } + + internal static EnrollmentAuthorizationResult Failure(string failureDetail) + { + return new EnrollmentAuthorizationResult( + ReadOnlyMemory.Empty, + AgentIdentityError.From(AgentIdentityErrorCode.TokenInvalid), + failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs new file mode 100644 index 0000000000..1436952549 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentDecision.cs @@ -0,0 +1,88 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with an enroll request, as decides it. +/// +public sealed class EnrollmentDecision +{ + private EnrollmentDecision( + EnrollmentOutcome outcome, + Guid? newIdentityId, + string? enrollmentUrl, + Guid? existingIdentityId, + AgentIdentityError? error, + string? failureDetail) + { + Outcome = outcome; + NewIdentityId = newIdentityId; + EnrollmentUrl = enrollmentUrl; + ExistingIdentityId = existingIdentityId; + Error = error; + FailureDetail = failureDetail; + } + + /// + /// What the authority does with the request. + /// + public EnrollmentOutcome Outcome { get; } + + /// + /// The ID that the library minted for the new identity, a random UUID unique to this decision, + /// when is ; otherwise null. + /// + /// + /// The authority creates the identity under this ID and answers the request with it. + /// It issues the identity's certificate by passing this decision to the + /// overload that takes it, so that no certificate is issued for an identity that no enrollment created. + /// + public Guid? NewIdentityId { get; } + + /// + /// The enrollment URL that every certificate of the new identity names, the token's u without trailing + /// slashes, when is ; otherwise null. + /// + /// + /// The overload that takes this decision writes it into the certificate, + /// and the authority keeps it with the identity so that each renewal names the same URL. + /// + public string? EnrollmentUrl { get; } + + /// + /// The identity whose enrollment the request replays, + /// when is ; otherwise null. + /// + public Guid? ExistingIdentityId { get; } + + /// + /// The error that refuses the request, when is ; + /// otherwise null. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that refuses the request, for the host's logs, + /// when is ; otherwise null. + /// + public string? FailureDetail { get; } + + internal static EnrollmentDecision CreateIdentity(Guid newIdentityId, string enrollmentUrl) + { + return new EnrollmentDecision(EnrollmentOutcome.CreateIdentity, newIdentityId, enrollmentUrl, null, null, null); + } + + internal static EnrollmentDecision ReturnExistingIdentity(Guid identityId) + { + return new EnrollmentDecision(EnrollmentOutcome.ReturnExistingIdentity, null, null, identityId, null, null); + } + + internal static EnrollmentDecision Reject(AgentIdentityErrorCode code, string failureDetail) + { + return new EnrollmentDecision( + EnrollmentOutcome.Reject, + null, + null, + null, + AgentIdentityError.From(code), + failureDetail); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs new file mode 100644 index 0000000000..c360ba9af2 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentOutcome.cs @@ -0,0 +1,32 @@ +namespace Devolutions.AgentIdentity; + +/// +/// What the authority does with an enroll request. +/// +public enum EnrollmentOutcome +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering an outcome would change what an existing host does. + // When adding an outcome, give it the next unused value; + // values start at 1 so that an uninitialized outcome is not a valid one. + + /// + /// Create a new identity under , which counts one use of the token. + /// + CreateIdentity = 1, + + /// + /// Return with its current certificate chain, + /// so that a device recovers a lost enrollment response. + /// + /// + /// The replay counts no use of the token and changes no state of the identity, such as its metadata, + /// because the CSR proves past possession of the key, not current possession. + /// + ReturnExistingIdentity = 2, + + /// + /// Refuse the request with . + /// + Reject = 3, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs new file mode 100644 index 0000000000..c4db3bc046 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentRules.cs @@ -0,0 +1,131 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Applies the enrollment rules that every authority shares to an enroll request. +/// +public static class EnrollmentRules +{ + /// + /// Decides whether an enroll request creates an identity, replays the enrollment of an existing one, + /// or is refused. + /// + /// + /// + /// The host calls this once and its token lookup succeed + /// and the request body, CSR and metadata are valid. + /// When several outcomes apply, the earliest of these wins: + /// token_url_mismatch when the CSR names no enrollment URL, or not the token's u + /// without trailing slashes, compared as text; + /// the result of the CSR key lookup, + /// except that a revoked token gets token_invalid for anything but a replay; + /// token_invalid for a revoked token, + /// then token_expired once reaches its expiry; + /// token_exhausted once it has created identities; + /// a new identity, whose ID, , is minted at random for this decision, + /// and whose certificates name . + /// + /// + /// The CSR key lookup takes the first matching rule: + /// the current key of a non-revoked identity enrolled with the presented token replays that identity's enrollment; + /// the key of an identity enrolled with another token gets invalid_request; + /// any key of a revoked identity gets identity_revoked; + /// a pending or retired key of a non-revoked identity gets invalid_request; + /// the key of a deleted identity gets identity_revoked. + /// + /// + /// The decision holds only for the state it was made from. + /// The host creates the identity and counts the use atomically, + /// so that concurrent enrollments never create more than identities + /// with one token, and of two concurrent enrollments with one CSR key, exactly one creates the identity + /// and the other gets the replay. + /// + /// + /// The state of the token that the request presents. + /// The request's validated CSR. + /// The issued certificate whose public key is the CSR key, or null when none has it. + /// The authority's current time, read once for the request. + /// or is null. + public static EnrollmentDecision Decide( + EnrollmentTokenState token, + CertificateSigningRequest csr, + IssuedKeyMatch? keyMatch, + DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentNullException.ThrowIfNull(csr); + + // CONTRACT.agent-identity.md §2.1 (2) and §4: a token works only at the base URL it names. + string enrollmentUrl = token.ProductBaseUrl.TrimEnd('/'); + if (!string.Equals(csr.EnrollmentUrl, enrollmentUrl, StringComparison.Ordinal)) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.TokenUrlMismatch, + "The CSR names no enrollment URL, or not the token's."); + } + + if (keyMatch is not null) + { + if (keyMatch.IsReplay) + { + return EnrollmentDecision.ReturnExistingIdentity(keyMatch.IdentityId); + } + + if (token.Revoked) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.TokenInvalid, + "The token is revoked, and the CSR key is not the current key of an identity that it created."); + } + + return RejectIssuedKey(keyMatch); + } + + if (token.Revoked) + { + return EnrollmentDecision.Reject(AgentIdentityErrorCode.TokenInvalid, "The token is revoked."); + } + + if (!(now < token.ExpiresAt)) + { + return EnrollmentDecision.Reject(AgentIdentityErrorCode.TokenExpired, "The token has expired."); + } + + if (!(token.IdentitiesCreated < token.MaxUses)) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.TokenExhausted, + "The token has created its maximum number of identities."); + } + + return EnrollmentDecision.CreateIdentity(Guid.NewGuid(), enrollmentUrl); + } + + // The key rules after the replay, in their order; a deleted identity matches only its own rule. + private static EnrollmentDecision RejectIssuedKey(IssuedKeyMatch keyMatch) + { + if (keyMatch.IdentityDeleted) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.IdentityRevoked, + "The CSR key belongs to a deleted identity."); + } + + if (!keyMatch.EnrolledWithPresentedToken) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.InvalidRequest, + "The CSR key belongs to an identity enrolled with another token."); + } + + if (keyMatch.IdentityRevoked) + { + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.IdentityRevoked, + "The CSR key belongs to a revoked identity."); + } + + return EnrollmentDecision.Reject( + AgentIdentityErrorCode.InvalidRequest, + "The CSR key belongs to a pending or retired certificate."); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs new file mode 100644 index 0000000000..745105e56d --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentToken.cs @@ -0,0 +1,145 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Devolutions.AgentIdentity; + +/// +/// A new enrollment token, dvaet1.<bag>.<secret>, +/// which authorizes enrolling new identities and nothing else. +/// +/// +/// The bag tells the agent where to enroll and is never trusted. +/// The secret authenticates the token, and the authority stores only its hash. +/// +public sealed class EnrollmentToken +{ + /// + /// The format version that starts every token this library creates or reads. + /// + internal const string Prefix = "dvaet1"; + + /// + /// The length of the secret segment: the base64url encoding of 32 bytes, without padding. + /// + internal const int SecretLength = 43; + + /// + /// The largest token, in bytes; a token is ASCII, so this is also its largest number of characters. + /// + internal const int MaxLength = 4096; + + private const int SecretByteCount = 32; + + private EnrollmentToken(string value, byte[] secretHash, string productBaseUrl) + { + Value = value; + SecretHash = secretHash; + ProductBaseUrl = productBaseUrl; + } + + /// + /// The token text, which the authority shows once, to the administrator who creates the token. + /// + /// + /// The authority never stores or logs the token text, not even partially; it stores . + /// + public string Value { get; } + + /// + /// The SHA-256 of the 32 bytes that the secret segment encodes, under which the authority stores the token. + /// + /// + /// returns the same hash for the token text, + /// so that the authority finds the token by it. + /// + public ReadOnlyMemory SecretHash { get; } + + /// + /// The token's u: the product base URL that its bag holds, in normalized form without trailing slashes. + /// + /// + /// The authority stores it with the token and passes it to , + /// since an enrollment's CSR must name it (CONTRACT.agent-identity.md §2.1). + /// + public string ProductBaseUrl { get; } + + /// + /// Creates a token for the authority that serves the agent-facing API under a product base URL. + /// + /// + /// The bag holds the JSON object {"u": <productBaseUrl>}, + /// with the URL in normalized absolute form and without trailing slashes, + /// since the agent appends /api/agent-identity/v1/... to it. + /// Normalizing can change the text, such as a host's case or a default port, + /// so the URL as the administrator entered it may differ from , which the bag holds. + /// The secret is 32 bytes from . + /// + /// + /// The absolute https URL of the product, in ASCII, such as https://host/dvls, + /// without user information or any other @, query or fragment. + /// + /// A cryptographically secure generator, which supplies the secret. + /// An argument is null. + /// + /// is not ASCII, is not an absolute https URL, + /// has user information or any other @, a query or a fragment, or makes the token longer than 4096 bytes. + /// + public static EnrollmentToken Create(string productBaseUrl, RandomNumberGenerator randomNumberGenerator) + { + ArgumentNullException.ThrowIfNull(productBaseUrl); + ArgumentNullException.ThrowIfNull(randomNumberGenerator); + + // CONTRACT.agent-identity.md §2: `u` holds only ASCII characters, since certificates carry it as an IA5String. + if (!Ascii.IsValid(productBaseUrl)) + { + throw new ArgumentException( + "The product base URL is not ASCII; write an internationalized host in its xn-- form.", + nameof(productBaseUrl)); + } + + ArrayBufferWriter json = new(); + + string u = BaseUrl.Normalize(productBaseUrl, "The product base URL", nameof(productBaseUrl)); + + using (Utf8JsonWriter writer = new(json)) + { + writer.WriteStartObject(); + writer.WriteString("u", u); + writer.WriteEndObject(); + } + + string bag = StrictBase64.EncodeBase64Url(json.WrittenSpan); + + if (!(Prefix.Length + 1 + bag.Length + 1 + SecretLength <= MaxLength)) + { + throw new ArgumentException( + "The product base URL makes the token longer than 4096 bytes.", + nameof(productBaseUrl)); + } + + Span secretBytes = stackalloc byte[SecretByteCount]; + randomNumberGenerator.GetBytes(secretBytes); + string secret = StrictBase64.EncodeBase64Url(secretBytes); + + return new EnrollmentToken(Prefix + "." + bag + "." + secret, HashSecret(secretBytes), u); + } + + /// + /// Returns the type name, never the token, so that logging the object reveals no part of the token. + /// + public override string ToString() + { + return nameof(EnrollmentToken); + } + + /// + /// Computes the hash under which the authority stores a token: the SHA-256 of the 32 bytes its secret encodes. + /// + /// The 32 bytes that the secret segment of the token encodes. + internal static byte[] HashSecret(ReadOnlySpan secretBytes) + { + return SHA256.HashData(secretBytes); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs new file mode 100644 index 0000000000..dfc83f7fba --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/EnrollmentTokenState.cs @@ -0,0 +1,84 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The stored state of the enrollment token that an enroll request presents. +/// +/// +/// None of it changes after the token is created, except the count of identities it created and its revocation. +/// +public sealed class EnrollmentTokenState +{ + /// + /// Creates the state of a token. + /// + /// Whether the token was revoked. + /// + /// The token's u, the product base URL that gave when the token + /// was created. + /// + /// The instant from which the token is expired. + /// The number of identities that the token may create. + /// The number of identities that the token has created. + /// is null. + /// + /// is empty once its trailing slashes are removed. + /// + /// + /// or is negative. + /// + public EnrollmentTokenState( + bool revoked, + string productBaseUrl, + DateTimeOffset expiresAt, + int maxUses, + int identitiesCreated) + { + ArgumentNullException.ThrowIfNull(productBaseUrl); + if (productBaseUrl.TrimEnd('/').Length == 0) + { + throw new ArgumentException("The product base URL is empty.", nameof(productBaseUrl)); + } + + ArgumentOutOfRangeException.ThrowIfNegative(maxUses); + ArgumentOutOfRangeException.ThrowIfNegative(identitiesCreated); + + Revoked = revoked; + ProductBaseUrl = productBaseUrl; + ExpiresAt = expiresAt; + MaxUses = maxUses; + IdentitiesCreated = identitiesCreated; + } + + /// + /// Whether the token was revoked. + /// + /// + /// A revoked token keeps its secret hash, + /// so that a device can still recover the enrollment response of an identity that the token created. + /// + public bool Revoked { get; } + + /// + /// The token's u, which the CSR of an enrollment must name, without trailing slashes, + /// and which every certificate of the identities the token creates names (CONTRACT.agent-identity.md §4). + /// + public string ProductBaseUrl { get; } + + /// + /// The instant from which the token is expired. + /// + public DateTimeOffset ExpiresAt { get; } + + /// + /// The number of identities that the token may create. + /// + public int MaxUses { get; } + + /// + /// The number of identities that the token has created. + /// + /// + /// Only the creation of a new identity counts; a replay, a failed enrollment and any later operation never do. + /// + public int IdentitiesCreated { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs new file mode 100644 index 0000000000..b8e6c1a0f1 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/FriendlyNameFormat.cs @@ -0,0 +1,233 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace Devolutions.AgentIdentity; + +/// +/// The format from which the authority creates an identity's friendly name once, at enrollment. +/// +/// +/// +/// A format is text with placeholders: {hostname}, {fqdn}, {domain}, {os_name}, +/// {os_version}, {arch}, {agent_version} and {machine_id} for the reported metadata, +/// and {token_name} for the name of the enrollment token. +/// {{ and }} write a literal brace. +/// The name is never sent to the agent, and later metadata updates never rename the identity. +/// +/// +/// A format is administrator input that may hold a pasted token, which never appears in logs, +/// so a parse error and never hold any part of the format text. +/// Only returns its literal text, by design, as part of the friendly name. +/// The host stores the text that it parsed with the token. +/// +/// +public sealed class FriendlyNameFormat +{ + // 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"]; + + // Ends every parse error without repeating the format text. + private static readonly string ErrorGuidance = + " The placeholders are " + + string.Join(", ", Placeholders[..^1].Select(placeholder => "{" + placeholder + "}")) + + " and {" + Placeholders[^1] + "}, and '{{' and '}}' write literal braces."; + + private const string TokenNamePlaceholder = "token_name"; + + private const int MaxScalarValues = 255; + + private readonly Segment[] _segments; + + private FriendlyNameFormat(Segment[] segments) + { + _segments = segments; + } + + /// + /// The format of a token created without one: {hostname}. + /// + public static FriendlyNameFormat Default { get; } = new([new Segment("hostname", true)]); + + /// + /// Parses a format, which the authority does when an administrator creates a token with it. + /// + /// The format text. + /// The format, when the text is valid. + /// + /// Why the text is invalid, for the administrator: an unpaired brace or an unknown placeholder, + /// its position counted in Unicode scalar values from 1, and the valid placeholders. + /// The error holds no part of the format text. + /// + /// Whether the text is a valid format. + /// is null. + public static bool TryParse( + string format, + [NotNullWhen(true)] out FriendlyNameFormat? result, + [NotNullWhen(false)] out string? error) + { + ArgumentNullException.ThrowIfNull(format); + + List segments = []; + string? problem = ParseSegments(format, segments); + + if (problem is not null) + { + result = null; + error = problem; + + return false; + } + + result = new FriendlyNameFormat([.. segments]); + error = null; + + return true; + } + + /// + /// Evaluates the friendly name of a new identity. + /// + /// + /// Each placeholder takes its value, and a missing metadata value is empty. + /// The result is trimmed of Unicode White_Space, + /// truncated to 255 Unicode scalar values without splitting one, and trimmed again, + /// so a name never starts or ends with white space. + /// A lone surrogate is no scalar value, so it becomes U+FFFD REPLACEMENT CHARACTER. + /// An empty result falls back to the identity ID. + /// + /// The metadata that the enroll request reports, as its body parser validated it. + /// The name of the token that the request presents. + /// The ID of the new identity. + /// The friendly name, or the identity ID in lowercase hyphenated form. + /// + /// or is null. + /// + public string Evaluate(AgentMetadata metadata, string tokenName, Guid identityId) + { + ArgumentNullException.ThrowIfNull(metadata); + ArgumentNullException.ThrowIfNull(tokenName); + + StringBuilder text = new(); + + foreach (Segment segment in _segments) + { + if (!segment.IsPlaceholder) + { + text.Append(segment.Text); + } + else if (segment.Text == TokenNamePlaceholder) + { + text.Append(tokenName); + } + else if (metadata.TryGetValue(segment.Text, out string? value)) + { + text.Append(value); + } + } + + string name = Truncate(text.ToString().Trim()).Trim(); + + return name.Length == 0 ? identityId.ToString("D") : name; + } + + /// + /// Returns the type name, never the format text, which may hold a pasted token. + /// + public override string ToString() + { + return nameof(FriendlyNameFormat); + } + + // Adds the segments of the format, or returns why the format is invalid. + private static string? ParseSegments(string format, List segments) + { + StringBuilder literal = new(); + int position = 0; + + while (position < format.Length) + { + char c = format[position]; + + if ((c == '{' || c == '}') && position + 1 < format.Length && format[position + 1] == c) + { + literal.Append(c); + position += 2; + } + else if (c == '{') + { + int end = format.IndexOf('}', position + 1); + + if (end < 0) + { + return ParseError("an unpaired '{'", format, position); + } + + string name = format[(position + 1)..end]; + + if (!Placeholders.Contains(name, StringComparer.Ordinal)) + { + return ParseError("an unknown placeholder", format, position); + } + + segments.Add(new Segment(literal.ToString(), false)); + literal.Clear(); + segments.Add(new Segment(name, true)); + position = end + 1; + } + else if (c == '}') + { + return ParseError("an unpaired '}'", format, position); + } + else + { + literal.Append(c); + position++; + } + } + + segments.Add(new Segment(literal.ToString(), false)); + + return null; + } + + // Says what is wrong and where, without any part of the format text. + // The position counts Unicode scalar values from 1, as an administrator counts characters, + // and a lone surrogate counts as one. + private static string ParseError(string problem, string format, int index) + { + int character = 1; + ReadOnlySpan before = format.AsSpan(0, index); + + while (!before.IsEmpty) + { + Rune.DecodeFromUtf16(before, out _, out int consumed); + before = before[consumed..]; + character++; + } + + return "The format has " + problem + " at character " + character.ToString(CultureInfo.InvariantCulture) + "." + + ErrorGuidance; + } + + // Keeps the first 255 Unicode scalar values, so that a surrogate pair is kept or dropped whole. + private static string Truncate(ReadOnlySpan text) + { + StringBuilder truncated = new(); + Span utf16 = stackalloc char[2]; + + for (int count = 0; count < MaxScalarValues && !text.IsEmpty; count++) + { + // An invalid code unit decodes to U+FFFD and is consumed alone. + Rune.DecodeFromUtf16(text, out Rune scalar, out int consumed); + truncated.Append(utf16[..scalar.EncodeToUtf16(utf16)]); + text = text[consumed..]; + } + + return truncated.ToString(); + } + + // Literal text, or the name of a placeholder. + private readonly record struct Segment(string Text, bool IsPlaceholder); +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs new file mode 100644 index 0000000000..fc8900d0aa --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Enrollment/IssuedKeyMatch.cs @@ -0,0 +1,89 @@ +namespace Devolutions.AgentIdentity; + +/// +/// The issued certificate whose public key is the key of an enroll or renew request's CSR. +/// +/// +/// The host finds it by the SPKI SHA-256 of the CSR key among every certificate the authority has issued: +/// current, pending and retired certificates, and those of deleted identities. +/// +public sealed class IssuedKeyMatch +{ + private static readonly IssuedKeyMatch DeletedIdentity = new(Guid.Empty, true, false, false, default); + + private IssuedKeyMatch( + Guid identityId, + bool identityDeleted, + bool enrolledWithPresentedToken, + bool identityRevoked, + CertificateStatus status) + { + IdentityId = identityId; + IdentityDeleted = identityDeleted; + EnrolledWithPresentedToken = enrolledWithPresentedToken; + IdentityRevoked = identityRevoked; + Status = status; + } + + internal Guid IdentityId { get; } + + internal bool IdentityDeleted { get; } + + internal bool EnrolledWithPresentedToken { get; } + + internal bool IdentityRevoked { get; } + + internal CertificateStatus Status { get; } + + /// + /// Whether the key is the current key of a non-revoked identity enrolled with the presented token, + /// so that the request replays that identity's enrollment. + /// + internal bool IsReplay => + !IdentityDeleted && EnrolledWithPresentedToken && !IdentityRevoked && Status == CertificateStatus.Current; + + /// + /// Creates the match of a certificate of an identity that the authority holds. + /// + /// + /// reads only the identity and the status, + /// since a renewal refuses any key of another identity, revoked or not. + /// + /// The identity that the certificate was issued to. + /// + /// Whether the identity was enrolled with the token that the request presents; + /// false for a renew request, which presents none. + /// + /// Whether the identity is revoked. + /// The status of the certificate. + /// is not a defined status. + public static IssuedKeyMatch ForIdentity( + Guid identityId, + bool enrolledWithPresentedToken, + bool identityRevoked, + CertificateStatus status) + { + if (!Enum.IsDefined(status)) + { + throw new ArgumentOutOfRangeException(nameof(status), status, "Not a defined certificate status."); + } + + return new IssuedKeyMatch(identityId, false, enrolledWithPresentedToken, identityRevoked, status); + } + + /// + /// Returns the match of a certificate of a deleted identity. + /// + /// + /// After an identity is deleted, the authority keeps only the SPKI SHA-256 and the issuance time of its keys, + /// so refuses the key with identity_revoked whatever token enrolled + /// the identity. + /// A revoked presented token gets token_invalid instead, since it authenticates nothing but a replay. + /// refuses the key with invalid_request, as it does any key of another + /// identity. + /// + public static IssuedKeyMatch ForDeletedIdentity() + { + return DeletedIdentity; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs new file mode 100644 index 0000000000..7b82297c42 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityError.cs @@ -0,0 +1,167 @@ +using System.Buffers; +using System.Text.Json; +using Grpc.Core; + +namespace Devolutions.AgentIdentity; + +/// +/// An error response of the agent-facing protocol: a code, the HTTP status that carries it, +/// and a fixed English message. +/// +/// +/// Each message is fixed for its error, so it never repeats request data, a token or a secret. +/// +public sealed class AgentIdentityError +{ + // UNTESTED: every message is a constant sentence. + // Error bodies, trailers and agent logs carry the message, + // so one built from request data could leak a token or a secret. + // When adding an error, give it a constant message and keep this constructor private. + private AgentIdentityError(AgentIdentityErrorCode code, int httpStatusCode, string message) + { + Code = code; + HttpStatusCode = httpStatusCode; + Message = message; + } + + /// + /// The error code. + /// + public AgentIdentityErrorCode Code { get; } + + /// + /// The HTTP status of the response. + /// + public int HttpStatusCode { get; } + + /// + /// A fixed English sentence describing the error. + /// + public string Message { get; } + + /// + /// The error for a request body that the host's framework cannot read or hand to the body parsers, + /// such as one that is not UTF-8 application/json, which CONTRACT.agent-identity.md §1 requires: + /// 400 invalid_request. + /// + /// + /// The body parsers, such as , report their own invalid_request + /// with a failure detail, so a body that they reject needs no other error. + /// + public static AgentIdentityError MalformedBody { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 400, "The request body is malformed."); + + /// + /// The error for a path under the agent identity base path that no endpoint serves: 404 invalid_request. + /// + public static AgentIdentityError UnknownRoute { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 404, "No endpoint serves this path."); + + /// + /// The error for an HTTP method that the endpoint does not accept: 405 invalid_request. + /// + public static AgentIdentityError MethodNotAllowed { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 405, "The endpoint does not accept this method."); + + /// + /// The error for a request body larger than : + /// 413 invalid_request. + /// + public static AgentIdentityError BodyTooLarge { get; } = + new(AgentIdentityErrorCode.InvalidRequest, 413, "The request body is larger than 64 KiB."); + + /// + /// The error for an unexpected server failure: 500 unavailable. + /// + public static AgentIdentityError UnexpectedFailure { get; } = + new(AgentIdentityErrorCode.Unavailable, 500, "The server failed unexpectedly."); + + /// + /// The error for a server that is overloaded or missing a dependency: 503 unavailable. + /// + /// + /// When the host sheds load, it also sends a Retry-After header, which the agent honors; + /// the library writes no HTTP headers. + /// + public static AgentIdentityError ServiceUnavailable { get; } = + new( + AgentIdentityErrorCode.Unavailable, + 503, + "The server is overloaded or a service it depends on is unavailable."); + + /// + /// Returns the error for a code, + /// with the HTTP status of . + /// + /// The error code. + /// is not a defined code. + public static AgentIdentityError From(AgentIdentityErrorCode code) + { + return new AgentIdentityError(code, code.ToHttpStatusCode(), MessageFor(code)); + } + + /// + /// Writes the body of the error response: + /// a UTF-8 JSON object with the members error, message and server_time. + /// + /// + /// The server's current time, + /// which the agent uses to correct its clock after . + /// + public byte[] ToJsonBody(DateTimeOffset serverTime) + { + ArrayBufferWriter buffer = new(); + + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WriteString("error", Code.ToWireValue()); + writer.WriteString("message", Message); + writer.WriteString("server_time", AgentIdentityProtocol.FormatTimestamp(serverTime)); + writer.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + /// + /// Creates the exception that closes an agent channel stream with this error. + /// + /// + /// The exception carries the status of + /// and the trailers error-code and server-time. + /// + /// + /// The server's current time, + /// which the agent uses to correct its clock after . + /// + public RpcException ToRpcException(DateTimeOffset serverTime) + { + Metadata trailers = new() + { + { "error-code", Code.ToWireValue() }, + { "server-time", AgentIdentityProtocol.FormatTimestamp(serverTime) }, + }; + + return new RpcException(new Status(Code.ToGrpcStatusCode(), Message), trailers); + } + + private static string MessageFor(AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.TokenInvalid => "The enrollment token is not valid.", + AgentIdentityErrorCode.TokenExhausted => "The enrollment token has reached its use limit.", + AgentIdentityErrorCode.TokenExpired => "The enrollment token has expired.", + AgentIdentityErrorCode.IdentityRevoked => "The identity is revoked.", + AgentIdentityErrorCode.IdentityUnknown => "The certificate is not registered, or its identity was deleted.", + AgentIdentityErrorCode.CertificateExpired => "The certificate has expired.", + AgentIdentityErrorCode.SignatureInvalid => "The signature is not valid.", + AgentIdentityErrorCode.ClockSkew => "The signature time is outside the accepted window.", + AgentIdentityErrorCode.InvalidRequest => "The request body, CSR or metadata is not valid.", + AgentIdentityErrorCode.Unavailable => "The server cannot serve the request now.", + AgentIdentityErrorCode.TokenUrlMismatch => "The CSR does not name the enrollment token's URL.", + _ => throw AgentIdentityErrorCodeExtensions.UndefinedCode(code), + }; + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs new file mode 100644 index 0000000000..b5369a29c7 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCode.cs @@ -0,0 +1,71 @@ +namespace Devolutions.AgentIdentity; + +/// +/// An error code of the agent-facing protocol. +/// +/// +/// The agent decides from the code whether to retry, to discard a pending enrollment, or to stop using an identity, +/// so each code keeps the meaning and the statuses that gives it. +/// +public enum AgentIdentityErrorCode +{ + // UNTESTED: each value stays the same across releases. + // Consumers compile the values in, so renumbering a code would change what an existing host sends. + // When adding a code, give it the next unused value; + // values start at 1 so that an uninitialized code is not a valid one. + + /// + /// The enrollment token is unknown or revoked, or its secret does not match. + /// + TokenInvalid = 1, + + /// + /// The enrollment token has created its maximum number of identities. + /// + TokenExhausted = 2, + + /// + /// The enrollment token has expired. + /// + TokenExpired = 3, + + /// + /// The identity is revoked. + /// + IdentityRevoked = 4, + + /// + /// The certificate is not registered, or its identity was deleted. + /// + IdentityUnknown = 5, + + /// + /// The certificate has expired, or for a renewal, is beyond its grace. + /// + CertificateExpired = 6, + + /// + /// The signature, its parameters, the body digest or the channel proof is invalid, or the nonce was already used. + /// + SignatureInvalid = 7, + + /// + /// The signature's created or expires time is outside the accepted window. + /// + ClockSkew = 8, + + /// + /// The route, method, body, CSR or metadata is invalid. + /// + InvalidRequest = 9, + + /// + /// The server can't serve the request now, because it is overloaded or a part it needs fails. + /// + Unavailable = 10, + + /// + /// The CSR of an enrollment names no enrollment URL, or not the presented token's `u`. + /// + TokenUrlMismatch = 11, +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs new file mode 100644 index 0000000000..1cd1a55658 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityErrorCodeExtensions.cs @@ -0,0 +1,92 @@ +using Grpc.Core; + +namespace Devolutions.AgentIdentity; + +/// +/// Maps an to its wire value and to the statuses that carry it. +/// +public static class AgentIdentityErrorCodeExtensions +{ + /// + /// Returns the value sent in the error member of an error body and in the error-code trailer, + /// such as token_invalid. + /// + /// The error code. + /// is not a defined code. + public static string ToWireValue(this AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.TokenInvalid => "token_invalid", + AgentIdentityErrorCode.TokenExhausted => "token_exhausted", + AgentIdentityErrorCode.TokenExpired => "token_expired", + AgentIdentityErrorCode.IdentityRevoked => "identity_revoked", + AgentIdentityErrorCode.IdentityUnknown => "identity_unknown", + AgentIdentityErrorCode.CertificateExpired => "certificate_expired", + AgentIdentityErrorCode.SignatureInvalid => "signature_invalid", + AgentIdentityErrorCode.ClockSkew => "clock_skew", + AgentIdentityErrorCode.InvalidRequest => "invalid_request", + AgentIdentityErrorCode.Unavailable => "unavailable", + AgentIdentityErrorCode.TokenUrlMismatch => "token_url_mismatch", + _ => throw UndefinedCode(code), + }; + } + + /// + /// Returns the HTTP status of a response carrying the code. + /// + /// + /// maps to 500, the status of an unexpected failure. + /// The framework-level errors of , + /// such as , carry other statuses with the same codes. + /// + /// The error code. + /// is not a defined code. + public static int ToHttpStatusCode(this AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.TokenInvalid => 401, + AgentIdentityErrorCode.TokenExhausted => 403, + AgentIdentityErrorCode.TokenExpired => 401, + AgentIdentityErrorCode.IdentityRevoked => 403, + AgentIdentityErrorCode.IdentityUnknown => 401, + AgentIdentityErrorCode.CertificateExpired => 401, + AgentIdentityErrorCode.SignatureInvalid => 401, + AgentIdentityErrorCode.ClockSkew => 401, + AgentIdentityErrorCode.InvalidRequest => 400, + AgentIdentityErrorCode.Unavailable => 500, + AgentIdentityErrorCode.TokenUrlMismatch => 403, + _ => throw UndefinedCode(code), + }; + } + + /// + /// Returns the gRPC status that closes an agent channel stream with the code. + /// + /// The error code. + /// is not a defined code. + public static StatusCode ToGrpcStatusCode(this AgentIdentityErrorCode code) + { + return code switch + { + AgentIdentityErrorCode.IdentityRevoked => StatusCode.PermissionDenied, + AgentIdentityErrorCode.InvalidRequest => StatusCode.InvalidArgument, + AgentIdentityErrorCode.Unavailable => StatusCode.Unavailable, + AgentIdentityErrorCode.TokenInvalid + or AgentIdentityErrorCode.TokenExhausted + or AgentIdentityErrorCode.TokenExpired + or AgentIdentityErrorCode.TokenUrlMismatch + or AgentIdentityErrorCode.IdentityUnknown + or AgentIdentityErrorCode.CertificateExpired + or AgentIdentityErrorCode.SignatureInvalid + or AgentIdentityErrorCode.ClockSkew => StatusCode.Unauthenticated, + _ => throw UndefinedCode(code), + }; + } + + internal static ArgumentOutOfRangeException UndefinedCode(AgentIdentityErrorCode code) + { + return new ArgumentOutOfRangeException(nameof(code), code, "Not a defined agent identity error code."); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityResult.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityResult.cs new file mode 100644 index 0000000000..4e2ecbb3fc --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Errors/AgentIdentityResult.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// The outcome of a check of the library: success with the values that the derived type names, +/// or the error to respond with and a detail for host logs. +/// +/// +/// After is false, and are not null; +/// after it is true, the values of the derived type are not null. +/// +public abstract class AgentIdentityResult +{ + private protected AgentIdentityResult(AgentIdentityError? error, string? failureDetail) + { + Error = error; + FailureDetail = failureDetail; + } + + /// + /// Whether the check succeeded. + /// + [MemberNotNullWhen(false, nameof(Error), nameof(FailureDetail))] + public virtual bool Succeeded => Error is null; + + /// + /// The error to respond with, when is false; otherwise null. + /// + public AgentIdentityError? Error { get; } + + /// + /// The rule that the check failed on, for host logs, when is false; otherwise null. + /// + /// + /// It never holds a value taken from the request, such as a metadata key or value, a header, signature data + /// or a token. + /// + public string? FailureDetail { get; } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/AgentIdentityProtocol.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/AgentIdentityProtocol.cs new file mode 100644 index 0000000000..8302b40410 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/AgentIdentityProtocol.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using System.Security.Cryptography; + +namespace Devolutions.AgentIdentity; + +/// +/// Limits and encodings that every agent-facing endpoint shares. +/// +public static class AgentIdentityProtocol +{ + /// + /// The largest request body, in bytes, that an agent-facing endpoint accepts. + /// + /// + /// A larger body gets before any authentication. + /// + public const int MaxRequestBodyBytes = 64 * 1024; + + /// + /// Formats an instant as an RFC 3339 UTC timestamp in whole seconds, such as 2026-09-24T12:00:00Z. + /// + /// + /// The fraction of a second is dropped. + /// + /// The instant, in any offset. + public static string FormatTimestamp(DateTimeOffset value) + { + // The seconds specifier drops the fraction, so the timestamp names the second that contains the instant. + return value.UtcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture); + } + + /// + /// Computes the thumbprint of a certificate: base64url(SHA-256(certificate DER)), without padding. + /// + /// + /// A signed request names its certificate by this thumbprint in its keyid parameter. + /// + /// The DER encoding of the certificate. + public static string ComputeThumbprint(ReadOnlySpan certificateDer) + { + return StrictBase64.EncodeBase64Url(SHA256.HashData(certificateDer)); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs new file mode 100644 index 0000000000..17409eb131 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/BaseUrl.cs @@ -0,0 +1,51 @@ +namespace Devolutions.AgentIdentity; + +/// +/// Checks a base URL to which the agent appends a path, and writes it in one form. +/// +internal static class BaseUrl +{ + /// + /// Returns the URL in normalized absolute form without trailing slashes, + /// so that the path the agent appends follows exactly one slash. + /// + /// The URL text. + /// What the URL is, as error messages start, such as The product base URL. + /// The parameter that holds the URL. + /// + /// The URL is not an absolute https URL, + /// or has user information or any other @, a query or a fragment. + /// + public static string Normalize(string url, string name, string paramName) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? parsed)) + { + throw new ArgumentException($"{name} is not an absolute URL.", paramName); + } + + if (parsed.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException($"{name} does not use the https scheme.", paramName); + } + + // CONTRACT.agent-identity.md §2 reads the URL as text, where any '@' counts as a user name, + // because URL parsers disagree on forms such as https://@host. + if (url.Contains('@')) + { + throw new ArgumentException($"{name} contains an '@', which counts as user information.", paramName); + } + + // Query and Fragment include their delimiter, so an empty query or fragment still makes them non-empty. + if (parsed.Query.Length != 0) + { + throw new ArgumentException($"{name} has a query.", paramName); + } + + if (parsed.Fragment.Length != 0) + { + throw new ArgumentException($"{name} has a fragment.", paramName); + } + + return parsed.AbsoluteUri.TrimEnd('/'); + } +} diff --git a/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/StrictBase64.cs b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/StrictBase64.cs new file mode 100644 index 0000000000..3094197828 --- /dev/null +++ b/dotnet/AgentIdentity/Devolutions.AgentIdentity/Protocol/StrictBase64.cs @@ -0,0 +1,135 @@ +using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; + +namespace Devolutions.AgentIdentity; + +/// +/// Decoders that accept only the canonical RFC 4648 form, so that each byte string has exactly one accepted encoding. +/// +/// +/// The BCL decoders skip white space and ignore the pad bits of the final quantum, +/// so they accept several encodings of one value. +/// +internal static class StrictBase64 +{ + /// + /// Decodes base64 (RFC 4648 §4) with padding. + /// + /// + /// Rejects white space, characters outside the alphabet, missing or extra padding, and non-zero pad bits. + /// + /// The encoded text. + /// The decoded bytes, when the text is canonical. + /// The number of bytes the text must decode to, if any. + public static bool TryDecodeBase64( + ReadOnlySpan text, + [NotNullWhen(true)] out byte[]? bytes, + int? exactLength = null) + { + bytes = null; + + if (text.Length % 4 != 0) + { + return false; + } + + int padding = text.EndsWith("==") ? 2 : text.EndsWith("=") ? 1 : 0; + + return TryDecodeUnpadded(text[..^padding], urlSafe: false, exactLength, out bytes); + } + + /// + /// Decodes base64url (RFC 4648 §5) without padding. + /// + /// + /// Rejects white space, characters outside the alphabet, padding, and non-zero pad bits. + /// + /// The encoded text. + /// The decoded bytes, when the text is canonical. + /// The number of bytes the text must decode to, if any. + public static bool TryDecodeBase64Url( + ReadOnlySpan text, + [NotNullWhen(true)] out byte[]? bytes, + int? exactLength = null) + { + return TryDecodeUnpadded(text, urlSafe: true, exactLength, out bytes); + } + + /// + /// Encodes bytes as base64url (RFC 4648 §5) without padding. + /// + public static string EncodeBase64Url(ReadOnlySpan bytes) + { + return Base64Url.EncodeToString(bytes); + } + + private static bool TryDecodeUnpadded( + ReadOnlySpan text, + bool urlSafe, + int? exactLength, + [NotNullWhen(true)] out byte[]? bytes) + { + bytes = null; + + // A final quantum of one character would hold fewer than 8 bits. + int partial = text.Length % 4; + if (partial == 1) + { + return false; + } + + int length = (text.Length / 4 * 3) + (partial == 0 ? 0 : partial - 1); + if (exactLength is { } expected && length != expected) + { + return false; + } + + byte[] decoded = new byte[length]; + int written = 0; + int accumulator = 0; + int bitCount = 0; + + foreach (char c in text) + { + int sextet = DecodeSextet(c, urlSafe); + if (sextet < 0) + { + return false; + } + + accumulator = (accumulator << 6) | sextet; + bitCount += 6; + + if (bitCount >= 8) + { + bitCount -= 8; + decoded[written++] = (byte)(accumulator >> bitCount); + accumulator &= (1 << bitCount) - 1; + } + } + + // The bits left over are the pad bits of the final quantum, which the canonical encoding sets to zero. + if (accumulator != 0) + { + return false; + } + + bytes = decoded; + return true; + } + + private static int DecodeSextet(char c, bool urlSafe) + { + return c switch + { + >= 'A' and <= 'Z' => c - 'A', + >= 'a' and <= 'z' => c - 'a' + 26, + >= '0' and <= '9' => c - '0' + 52, + '+' when !urlSafe => 62, + '/' when !urlSafe => 63, + '-' when urlSafe => 62, + '_' when urlSafe => 63, + _ => -1, + }; + } +} diff --git a/dotnet/AgentIdentity/INTENT.agent-identity.md b/dotnet/AgentIdentity/INTENT.agent-identity.md new file mode 120000 index 0000000000..56d20dc7a1 --- /dev/null +++ b/dotnet/AgentIdentity/INTENT.agent-identity.md @@ -0,0 +1 @@ +../../docs/agent-identity/INTENT.agent-identity.md \ No newline at end of file diff --git a/dotnet/AgentIdentity/INTENT.md b/dotnet/AgentIdentity/INTENT.md new file mode 100644 index 0000000000..0ea06295db --- /dev/null +++ b/dotnet/AgentIdentity/INTENT.md @@ -0,0 +1,15 @@ +This library holds everything every authority must do the same way under `docs/agent-identity/CONTRACT.agent-identity.md`, and nothing about storage, hosting or management, so that DVLS and PSU cannot drift apart on the agent-facing protocol. +The feature's shared intent is `INTENT.agent-identity.md` beside this file. + +## Authenticated identity + +- An authority must use the identity produced by the signature check. + Code that acts for an identity takes it as a type only that check can create; an identity is never taken from a value the agent or a caller sent. + +## Failure details + +- A failure detail meant for host logs never contains a value taken from the request, such as a metadata key or value, a header, signature data or a token, so that an agent can neither write into an authority's logs nor leak replayable material through them. + +## Enrollment tokens + +- A token the library creates carries a bag with `u` alone and no insignificant white space, so the longest base URL that fits the contract's 4096-byte limit is fixed. diff --git a/dotnet/AgentIdentity/README.md b/dotnet/AgentIdentity/README.md new file mode 100644 index 0000000000..5e4dbba30d --- /dev/null +++ b/dotnet/AgentIdentity/README.md @@ -0,0 +1,208 @@ +# Devolutions.AgentIdentity + +`Devolutions.AgentIdentity` implements what every Agent Identity authority must do the same way under [CONTRACT.agent-identity.md][contract], so that its hosts, such as DVLS and PSU, cannot drift apart on the protocol that Devolutions Agent speaks. +The contract is `docs/agent-identity/CONTRACT.agent-identity.md` in the [devolutions-gateway repository][repo], which also holds this library. + +## What the library covers + +- Enrollment tokens: `EnrollmentToken.Create` creates a token, `FriendlyNameFormat` parses and evaluates its friendly-name format, and `EnrollmentAuthorization.ReadBearerToken` reads it from an `enroll` request. + The host stores the token's `SecretHash` and its `ProductBaseUrl`, never its `Value`, which it shows once and never logs. +- Request authentication: `RequestAuthenticator` verifies the signed `renew`, `confirm` and `check-in` requests and the signed opening of an agent channel stream (CONTRACT.agent-identity.md [§6]). +- Request bodies: `EnrollRequestBody`, `RenewRequestBody` and `CheckInRequestBody` parse a body and validate its CSR (`CertificateSigningRequest`) and metadata (`AgentMetadata`), and `ConfirmRequestBody` checks that a `confirm` body is empty. +- Decisions: `EnrollmentRules.Decide` decides an enrollment and `RenewalRules.Decide` a renewal, given the issued certificate that holds the CSR key. +- Certificates: `IdentityCertificateIssuer.Issue` issues the certificate of the new identity that an enrollment decision creates, or of the identity that a renew request proved. +- Responses: `IdentityConfig` holds an identity's config, and `ResponseBodies` writes the bodies of the `trust-anchor`, `enroll`, `renew` and `check-in` responses. +- Agent channel: `ChannelOpening` verifies the handshake that authenticates a stream, and `ChannelMessages` builds the `ConfigUpdate`, `RenewRequested` and `Reconnect` messages that the authority pushes. +- Errors: `AgentIdentityError` gives each error its code, HTTP status and message, and writes its JSON body and gRPC exception. +- Encodings: `AgentIdentityProtocol` computes certificate thumbprints and formats timestamps. + +The host keeps everything else: + +- Storage: identities, certificates and their statuses, the SPKI SHA-256 of every issued key, token hashes, revoked tokens included, accepted nonces, metadata, configs and request-renewal flags. +- Hosting: routes, TLS, the gRPC service and its timers, and when to send each push message. +- Management: tokens, identities and root rotation, and the permissions that guard them. +- The root CA and its private key at rest; the library reaches the key only through `IRootSigner`. + +## Host dependencies + +The host provides these dependencies, with the guarantees the library relies on: + +- `ICertificateRegistry` finds, by thumbprint, a certificate that the authority issued, in any status including `Retired`, or returns null when there is none or its identity was deleted. + Index certificates by `AgentIdentityProtocol.ComputeThumbprint`, not by the SHA-1 `X509Certificate2.Thumbprint`. + Each `RegisteredCertificate` holds the DER exactly as issued, and the current `Status` and `IdentityRevoked`, so that a revocation takes effect from the next request. +- `INonceStore.TryRecordAsync` records a `keyid` and `nonce` pair, or returns false when the pair is already recorded, in one atomic operation, and keeps the pair until `retainUntil`. +- `IRootSigner` holds the root that issues new certificates and a `SignatureGenerator` over its private key. + Its `Certificate` must have a Subject Key Identifier extension, which becomes the Authority Key Identifier of each issued certificate; without one, `IdentityCertificateIssuer.Issue` throws `ArgumentException`. + The root must also fit the chain profile (CONTRACT.agent-identity.md [§4]): a P-256 or P-384 key, basicConstraints with `cA` set, key usage with `keyCertSign`, and a `SignatureGenerator` that signs with `ecdsa-with-SHA256` for a P-256 key or `ecdsa-with-SHA384` for a P-384 key; otherwise `Issue` throws `ArgumentException`. + Once a root rotation starts, it holds the new root (CONTRACT.agent-identity.md [§8.3]). +- `TimeProvider` is the authority's clock, usually `TimeProvider.System`, which decides signature freshness, certificate validity and handshake deadlines. + Take the `now` of `EnrollmentRules.Decide` and the `serverTime` of every error from the same clock, since the agent corrects its own clock from `server_time`. +- `RandomNumberGenerator` must be cryptographically secure, such as `RandomNumberGenerator.Create()`, since it supplies channel challenges, token secrets and certificate serial numbers. + +The library puts this load on the registry and the nonce store: + +- Each signed request that passes parsing, the tag and the time window costs one `FindByThumbprintAsync`, and only one whose signature verifies also costs one `TryRecordAsync` (CONTRACT.agent-identity.md [§6]). + The thumbprint comes from the request, so an unauthenticated client can drive lookups with random `keyid`s. +- Each channel stream whose opening verifies costs two registry reads, one for the opening and one when the handshake ends, as CONTRACT.agent-identity.md [§7.4] requires, and one nonce write. + +To bear that load, it is safe to: + +- look certificates up through an index on their thumbprint; +- cache a certificate's DER by thumbprint, since it never changes; +- answer misses from a bounded negative cache, or rate-limit them per client; +- index the nonce store by `keyid` and `nonce`, and expire each entry at its `retainUntil`. + +Never cache `Status` or `IdentityRevoked` beyond one request, nor whether the identity still exists: a revocation, a deletion or a retirement takes effect from the next request only if every lookup reads them afresh. + +`RequestAuthenticator` keeps no state between requests, so one instance can serve them all when its dependencies accept concurrent calls. +Exceptions from the registry and the nonce store propagate to the caller. + +## Call order + +Each HTTP request goes through the body size check, then authentication, then body parsing, so that the error precedence of CONTRACT.agent-identity.md [§5.4] holds: a body over 64 KiB fails first, and an authentication error wins over an invalid body, CSR or metadata. +When a step fails, answer with the `Error` of its result, as [Errors][errors] describes. + +### `trust-anchor` + +Answer with `ResponseBodies.TrustAnchor`, given the published roots: one normally, the old and the new during a rotation (CONTRACT.agent-identity.md [§5.1]). + +### `enroll` + +1. Answer a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge`. +2. Pass the `Authorization` field lines, as received, to `EnrollmentAuthorization.ReadBearerToken`. + Find the stored token by the result's `SecretHash`, revoked tokens included, and answer `AgentIdentityError.From(AgentIdentityErrorCode.TokenInvalid)` when there is none. +3. Parse the body with `EnrollRequestBody.Parse`. +4. Look up `Csr.SubjectPublicKeyInfoSha256` among the keys of every certificate the authority has issued, deleted identities included. + Describe the match with `IssuedKeyMatch.ForIdentity` or `IssuedKeyMatch.ForDeletedIdentity`, or use null when there is none. +5. Pass the token's `EnrollmentTokenState`, with the `ProductBaseUrl` stored for it, the body's `Csr`, the match and the current time to `EnrollmentRules.Decide`, and act on its `Outcome`; a CSR that names no enrollment URL, or not the token's, gets `token_url_mismatch` before any key rule applies (CONTRACT.agent-identity.md [§2.1]): + - `Reject`: answer with `Error`. + - `ReturnExistingIdentity`: take identity `ExistingIdentityId`, and change nothing, not even its `last_seen_at`, since a replay proves no current possession of the device key. + - `CreateIdentity`: take `NewIdentityId`, which the library minted, as the identity's ID, issue its certificate with the `IdentityCertificateIssuer.Issue` overload that takes the decision, which names the decision's `EnrollmentUrl` in it, name it with `FriendlyNameFormat.Evaluate`, create its config with `IdentityConfig.Create`, and store it with its metadata, its `EnrollmentUrl`, the key's `SubjectPublicKeyInfoSha256`, and the current time as its `last_seen_at`. + Create the identity and count the token use atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent enrollments. +6. Answer with `ResponseBodies.Enroll`, given the authority ID and the ID taken in step 5, with that identity's current certificate chain, leaf first, and config (CONTRACT.agent-identity.md [§5.2]). + +### Signed requests + +`renew`, `confirm` and `check-in` start alike: + +1. Build a `SignedHttpRequest` from the method, the `Signature-Input`, `Signature` and `Content-Digest` field lines, and the body bytes, all as received. +2. Pass it to `RequestAuthenticator.AuthenticateAsync` with `AgentOperation.Renew`, `AgentOperation.Confirm` or `AgentOperation.CheckIn`. + It answers a body longer than `AgentIdentityProtocol.MaxRequestBodyBytes` with `AgentIdentityError.BodyTooLarge` before it checks the signature. +3. Parse a `renew` body with `RenewRequestBody.Parse`, a `confirm` body with `ConfirmRequestBody.Parse` and a `check-in` body with `CheckInRequestBody.Parse`, and answer an invalid body with the `Error` of the parse result. + +Each then acts for the result's `Identity` only. + +### `renew` + +1. Look up the CSR key as for `enroll`, passing false for `enrolledWithPresentedToken`. +2. Pass `Identity` and the match to `RenewalRules.Decide`, and act on its `Outcome`: + - `Reject`: answer with `Error`. + - `ReturnPendingCertificate`: keep the identity's pending certificate, which holds the key. + - `IssuePendingCertificate`: issue a certificate with the `IdentityCertificateIssuer.Issue` overload that takes the `AuthenticatedIdentity`, passing the `EnrollmentUrl` stored with the identity so that every certificate of the identity names the same URL, and store it as the identity's pending certificate, retiring the previous one; the renew CSR's own enrollment URL is ignored (CONTRACT.agent-identity.md [§4]). + Store it atomically, as CONTRACT.agent-identity.md [§2.1] requires of concurrent renewals. +3. Replace the identity's metadata, set its `last_seen_at` to the current time, and answer with `ResponseBodies.Renew`, given the pending certificate's chain (CONTRACT.agent-identity.md [§5.3]). + +### `confirm` + +Parse the body with `ConfirmRequestBody.Parse`, and answer a non-empty body with the result's `Error` before changing any state (CONTRACT.agent-identity.md [§5.5]). +When `Identity.CertificateStatus` is `CertificateStatus.Pending`, make the certificate current, retire the previous current certificate, and clear the request-renewal flag if it was set before the certificate was issued (CONTRACT.agent-identity.md [§8.2]). +Then send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` on each stream that the retired certificate opened, and close those streams with `OK` 60 s later (CONTRACT.agent-identity.md [§7.4]). +Set the identity's `last_seen_at` to the current time and answer `204`, also when the certificate was already current (CONTRACT.agent-identity.md [§5.5]). + +### `check-in` + +Replace the identity's metadata, set its `last_seen_at` to the current time, and answer with `ResponseBodies.CheckIn`, given the identity's config and its request-renewal flag (CONTRACT.agent-identity.md [§5.6], [§8.2]). + +### Agent channel + +Set the gRPC `MaxReceiveMessageSize` and `MaxSendMessageSize` options of the `AgentChannel` service to 65,536 bytes, the message limit of CONTRACT.agent-identity.md [§7.1]. +The send limit is only a backstop, since `IdentityConfig.Create` refuses a config whose `ConfigUpdate` message would exceed it. + +1. Pass the request method and the values of the `signature-input` and `signature` request headers, in received order, to `RequestAuthenticator.AuthenticateChannelOpeningAsync`. +2. Send the result's `Opening.Challenge` as the first message on the stream. +3. Before any call of step 4, add the stream to the live streams that the revocation and deletion paths close, under `Opening.CertificateThumbprint` since its identity is still hidden, and remove it if the handshake fails. +4. Pass the first agent message to `Opening.VerifyHelloAsync`. + Call `Opening.HelloTimedOutAsync` instead when no message arrives within `ChannelHandshake.HelloTimeout`, when `Opening.CertificateNotAfter` passes first, or when the agent ends the stream without a message. + Call `Opening.FirstMessageUnreadableAsync` instead when the first message cannot be decoded, for example because it exceeds `MaxReceiveMessageSize` or a protobuf `string` holds invalid UTF-8 (CONTRACT.agent-identity.md [§7.1], [§7.3] step 4). + The first of these calls ends the handshake, and a second one throws `InvalidOperationException`. +5. On success, replace the identity's metadata with `Metadata`, set its `last_seen_at` to the current time, send `Welcome`, and only then treat the stream as authenticated for `Identity`. + Set `last_seen_at` again on every later agent message, but not when the stream closes, since a closure proves no possession of the device key (Configuration and metadata in [INTENT.agent-identity.md][last-seen-at]). +6. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.agent-identity.md [§7.3] steps 5 and 7). +7. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.agent-identity.md [§7.4]). +8. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.agent-identity.md [§7.4]). +9. When a later agent message cannot be decoded or exceeds `MaxReceiveMessageSize`, close the stream with `AgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest).ToRpcException(serverTime)` (CONTRACT.agent-identity.md [§7.4]). + +`VerifyHelloAsync`, `HelloTimedOutAsync` and `FirstMessageUnreadableAsync` read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.agent-identity.md [§7.4]). +Step 3 lets no revocation or deletion slip past that read: one that commits before the read fails the handshake, and one that commits after it finds the stream among the live streams. +Without step 3, a stream whose identity is revoked just after the read would stay authenticated until `Identity.CertificateNotAfter`. +A channel opening has no body: `VerifyHelloAsync` then checks the deadline, the message and its proof, and the metadata of the `Hello` last (CONTRACT.agent-identity.md [§7.3] step 4). + +When an identity's config changes, create its `IdentityConfig` again with a higher `Revision`, and send `ChannelMessages.ConfigUpdate` on each authenticated stream of the identity. +`ChannelMessages.RenewRequested` takes `RenewalRequestReason.Rotation` for the notifications of a root rotation (CONTRACT.agent-identity.md [§8.3]), and `RenewalRequestReason.Admin` for an administrator's request. + +## Errors + +Over HTTP, every non-2xx response under `{u}/api/agent-identity/v1/` must carry an error body, including those of the web framework (CONTRACT.agent-identity.md [§5.4]). +Answer with status `HttpStatusCode`, header `Content-Type: application/json` and body `ToJsonBody(serverTime)`. +For a failure that the library does not report, use `AgentIdentityError.From` with its code, or one of these errors: + +| Failure | Error | +| ---------------------------------------- | ------------------------- | +| Path that no endpoint serves | `UnknownRoute`, 404 | +| Method that the endpoint does not accept | `MethodNotAllowed`, 405 | +| Body that the web framework cannot read | `MalformedBody`, 400 | +| Body larger than 64 KiB | `BodyTooLarge`, 413 | +| Unexpected exception | `UnexpectedFailure`, 500 | +| Overload or a missing dependency | `ServiceUnavailable`, 503 | + +The library writes no headers, so the host adds `Retry-After` to `ServiceUnavailable` when it sheds load. + +Over gRPC, end the call by throwing `ToRpcException(serverTime)`, which carries the status of `ToGrpcStatusCode` and the trailers `error-code` and `server-time` (CONTRACT.agent-identity.md [§7.4]). +For a closure that the host decides, such as a revocation, build the error with `AgentIdentityError.From`. + +Log `FailureDetail` beside the error: it names the rule that the request broke and never holds a value taken from the request, such as a metadata key or value, a header, signature data or a token. + +## Authenticated identities + +Only the signature check, `RequestAuthenticator.AuthenticateAsync`, and the channel handshake, `ChannelOpening.VerifyHelloAsync`, create an `AuthenticatedIdentity`, which has no public constructor. +Code that acts for an identity should take one, so that it acts for the identity a signature proved, never for an identity ID that the agent or a caller sent. +The library does so itself: `RenewalRules.Decide` and the renewal overload of `IdentityCertificateIssuer.Issue` take the renewing `AuthenticatedIdentity`, and throw `ArgumentException` for one authenticated for another operation. +The certificate of a new identity is issued from the `EnrollmentDecision` that creates it, which only `EnrollmentRules.Decide` produces, under the `NewIdentityId` that it minted; the overload that takes the decision throws `ArgumentException` for one of another outcome. +No `IdentityCertificateIssuer.Issue` overload takes an identity ID. +A verified channel opening yields only a `ChannelOpening`, whose identity stays hidden until the agent signs the stream's challenge, since signed opening headers are not tied to one stream and can be captured by a TLS-terminating proxy, TLS inspection or a header log (security invariant 7 of [INTENT.agent-identity.md][invariants]). +For the same reason, `AuthenticateAsync` throws `ArgumentOutOfRangeException` for `AgentOperation.Connect`. + +## Build, test and pack + +From the repository root, with the .NET 10 SDK: + +```powershell +pwsh ./dotnet/AgentIdentity/build.ps1 +dotnet test ./dotnet/AgentIdentity/AgentIdentity.sln +``` + +`build.ps1` builds the library in Release and packs it, with its symbol package, into `dotnet/AgentIdentity/Devolutions.AgentIdentity/bin/Release/`. +The tests also check the library against the shared vectors in `docs/agent-identity/test-vectors.json`. + +The package depends on `Devolutions.Agent.Channel`, the gRPC types generated from the channel schema (CONTRACT.agent-identity.md [§7.1]): the handshake takes its `AgentMessage` and returns its `ServerMessage`. +Implement the `AgentChannel` service with that package; a feed that serves this package must serve that one too. + +[contract]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md +[repo]: https://github.com/Devolutions/devolutions-gateway +[§2.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#token-rules +[§4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#certificates +[§5.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#trust-anchor +[§5.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#enroll +[§5.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#renew +[§5.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#errors +[§5.5]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#confirm +[§5.6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#check-in +[§6]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#signatures +[§7.1]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#channel-service +[§7.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#channel-sequence +[§7.4]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#channel-termination +[§8.2]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#server-certificate-state +[§8.3]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/CONTRACT.agent-identity.md#root-rotation +[invariants]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/INTENT.agent-identity.md#security-invariants +[last-seen-at]: https://github.com/Devolutions/devolutions-gateway/blob/master/docs/agent-identity/INTENT.agent-identity.md#configuration-and-metadata +[errors]: #errors diff --git a/dotnet/AgentIdentity/build.ps1 b/dotnet/AgentIdentity/build.ps1 new file mode 100644 index 0000000000..4d02b1ba83 --- /dev/null +++ b/dotnet/AgentIdentity/build.ps1 @@ -0,0 +1,17 @@ +#!/bin/env pwsh + +$ErrorActionPreference = "Stop" + +Push-Location -Path $PSScriptRoot + +try { + $Project = "./Devolutions.AgentIdentity/Devolutions.AgentIdentity.csproj" + + dotnet build $Project --configuration Release + if ($LASTEXITCODE -ne 0) { throw "dotnet build failed" } + + dotnet pack $Project --configuration Release --no-build + if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed" } +} finally { + Pop-Location +}