diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Account/Pages/Manage/IssuingKeys.razor b/Blocktrust.CredentialWorkflow.Web/Components/Account/Pages/Manage/IssuingKeys.razor index 08706a7..b108542 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Account/Pages/Manage/IssuingKeys.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Account/Pages/Manage/IssuingKeys.razor @@ -1,86 +1,174 @@ @page "/Account/Manage/IssuingKeys" @using System.ComponentModel.DataAnnotations +@using System.Text.RegularExpressions @using Blocktrust.CredentialWorkflow.Core.Commands.Tenant.CreateIssuingKey @using Blocktrust.CredentialWorkflow.Core.Commands.Tenant.DeleteIssuingKey @using Blocktrust.CredentialWorkflow.Core.Commands.Tenant.GetIssuingKeys @using Blocktrust.CredentialWorkflow.Core.Domain.IssuingKey @using MediatR +@using Microsoft.AspNetCore.Components.Forms @inject IMediator Mediator @inject IdentityUserAccessor UserAccessor @inject IdentityRedirectManager RedirectManager - Manage Issuing Keys -
-
-
- -

Manage Issuing Keys

- - - - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
- @foreach (var key in IssuingKeysList) - { -
-
-
-

@key.Name

-

DID: @key.Did

-

Type: @key.KeyType

-

Public Key: @TruncateKey(key.PublicKey)

-

Private Key: @TruncateKey(key.PrivateKey)

-

Created: @key.CreatedUtc

+
+
+ +
+

Manage Issuing Keys

+

Create and manage your issuing keys for credential management.

+
+ + + +
+ +
+
+

Add New Issuing Key

+ + +
+

Requirements

+
    +
  • + + DID must be in format: did:prism:[64 hex characters] +
  • +
  • + + Public and private keys must be in base64url format +
  • +
  • + + Private key must be 32 bytes when decoded +
  • +
  • + + Public key must be 33 bytes when decoded +
  • +
+
+ + + + + +
+
+ + +
-
-
- - delete - + +
+ + + +
+ +
+ + + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
-
- } +
+
+
+ + +
+
+

Existing Keys

+ + @if (!IssuingKeysList.Any()) + { +
+

No issuing keys added yet.

+
+ } + else + { +
+ @foreach (var key in IssuingKeysList) + { +
+
+
+

@key.Name

+
+

+ DID: + @TruncateKey(key.Did) +

+

+ Type: + @key.KeyType +

+

+ Public Key: + @TruncateKey(key.PublicKey) +

+

+ Private Key: + @TruncateKey(key.PrivateKey) +

+

+ Created @key.CreatedUtc.ToLocalTime().ToString("g") +

+
+
+ +
+
+ } +
+ } +
@@ -91,14 +179,8 @@ private List IssuingKeysList { get; set; } = new(); private Guid TenantId { get; set; } - // Form model for creating a new key - [SupplyParameterFromForm] private NewKeyModel NewKey { get; set; } = new(); - - // Editing key details - private IssuingKey? EditingKey { get; set; } - private EditKeyModel EditingKeyModel { get; set; } = new(); - [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; + [SupplyParameterFromForm] private NewKeyModel NewKey { get; set; } = new(); protected override async Task OnInitializedAsync() { @@ -138,23 +220,6 @@ } } - private void StartEditingKey(IssuingKey key) - { - EditingKey = key; - EditingKeyModel = new EditKeyModel - { - Name = key.Name, - KeyType = key.KeyType, - PublicKey = key.PublicKey, - PrivateKey = key.PrivateKey - }; - } - - private void CancelEditing() - { - EditingKey = null; - } - private async Task RemoveKeyAsync(Guid keyId) { var request = new DeleteIssuingKeyRequest(keyId); @@ -174,50 +239,106 @@ private string TruncateKey(string key) { if (string.IsNullOrEmpty(key)) - { return ""; - } - // Just show first 10 chars and last 5 chars for safety if (key.Length <= 15) - { return key; - } - return key.Substring(0, 10) + "..." + key.Substring(key.Length - 5); + return $"{key[..10]}...{key[^5..]}"; } - private sealed class NewKeyModel + // Model with inline validation + private sealed class NewKeyModel : IValidatableObject { - [Required] + [Required(ErrorMessage = "Name is required")] [Display(Name = "Key Name")] public string Name { get; set; } = ""; - [Required] [Display(Name = "DID")] public string Did { get; set; } = ""; + [Required(ErrorMessage = "DID is required")] + [Display(Name = "DID")] + public string Did { get; set; } = ""; - [Required] + [Required(ErrorMessage = "Key Type is required")] [Display(Name = "Key Type")] - public string KeyType { get; set; } = ""; + public string KeyType { get; set; } = "secp256k1"; - [Required] + [Required(ErrorMessage = "Public Key is required")] [Display(Name = "Public Key")] public string PublicKey { get; set; } = ""; - [Required] + [Required(ErrorMessage = "Private Key is required")] [Display(Name = "Private Key")] public string PrivateKey { get; set; } = ""; - } - - private sealed class EditKeyModel - { - [Required] public string Name { get; set; } = ""; - [Required] public string Did { get; set; } = ""; - [Required] public string KeyType { get; set; } = ""; + public IEnumerable Validate(ValidationContext validationContext) + { + // Validate DID format + if (!string.IsNullOrWhiteSpace(Did) && !Regex.IsMatch(Did, @"^did:prism:[a-f0-9]{64}$")) + { + yield return new ValidationResult( + "Invalid DID format. Must be in format: did:prism:[64 hex characters]", + new[] { nameof(Did) } + ); + } + + // Validate Public Key + if (!string.IsNullOrWhiteSpace(PublicKey)) + { + var publicKeyValidation = ValidateBase64UrlKey(PublicKey, 33, "Public Key"); + if (publicKeyValidation != null) + { + yield return publicKeyValidation; + } + } - [Required] public string PublicKey { get; set; } = ""; + // Validate Private Key + if (!string.IsNullOrWhiteSpace(PrivateKey)) + { + var privateKeyValidation = ValidateBase64UrlKey(PrivateKey, 32, "Private Key"); + if (privateKeyValidation != null) + { + yield return privateKeyValidation; + } + } + } - [Required] public string PrivateKey { get; set; } = ""; + private ValidationResult? ValidateBase64UrlKey(string key, int expectedLength, string fieldName) + { + // Check base64url format + if (!Regex.IsMatch(key, @"^[A-Za-z0-9_-]*$")) + { + return new ValidationResult( + $"{fieldName} must be in base64url format", + new[] { fieldName == "Public Key" ? nameof(PublicKey) : nameof(PrivateKey) } + ); + } + + try + { + // Convert base64url to base64 + string base64 = key.Replace('-', '+').Replace('_', '/'); + // Add padding if necessary + string paddedBase64 = base64.PadRight(base64.Length + (4 - (base64.Length % 4)) % 4, '='); + // Decode and check length + byte[] decoded = Convert.FromBase64String(paddedBase64); + + if (decoded.Length != expectedLength) + { + return new ValidationResult( + $"{fieldName} has invalid length. Expected {expectedLength} bytes but got {decoded.Length} bytes", + new[] { fieldName == "Public Key" ? nameof(PublicKey) : nameof(PrivateKey) } + ); + } + } + catch + { + return new ValidationResult( + $"{fieldName} has invalid base64url encoding", + new[] { fieldName == "Public Key" ? nameof(PublicKey) : nameof(PrivateKey) } + ); + } + + return null; + } } - -} +} \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/package-lock.json b/Blocktrust.CredentialWorkflow.Web/package-lock.json index 595fbe8..d38fb11 100644 --- a/Blocktrust.CredentialWorkflow.Web/package-lock.json +++ b/Blocktrust.CredentialWorkflow.Web/package-lock.json @@ -296,10 +296,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -610,10 +611,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -658,9 +660,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -668,6 +670,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, diff --git a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css index 97583fe..4036f41 100644 --- a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css +++ b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css @@ -608,6 +608,10 @@ video { position: relative; } +.sticky { + position: sticky; +} + .inset-0 { inset: 0px; } @@ -705,6 +709,10 @@ video { margin-left: 1rem; } +.ml-5 { + margin-left: 1.25rem; +} + .ml-6 { margin-left: 1.5rem; } @@ -757,8 +765,8 @@ video { margin-top: 2rem; } -.ml-5 { - margin-left: 1.25rem; +.mb-8 { + margin-bottom: 2rem; } .block { @@ -769,6 +777,10 @@ video { display: inline-block; } +.inline { + display: inline; +} + .flex { display: flex; } @@ -889,6 +901,10 @@ video { max-width: 28rem; } +.max-w-4xl { + max-width: 56rem; +} + .flex-1 { flex: 1 1 0%; } @@ -928,6 +944,10 @@ video { cursor: pointer; } +.list-inside { + list-style-position: inside; +} + .list-disc { list-style-type: disc; } @@ -984,6 +1004,10 @@ video { gap: 1rem; } +.gap-8 { + gap: 2rem; +} + .space-x-2 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.5rem * var(--tw-space-x-reverse)); @@ -1020,6 +1044,12 @@ video { margin-bottom: calc(1rem * var(--tw-space-y-reverse)); } +.space-y-5 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1.25rem * var(--tw-space-y-reverse)); +} + .divide-y > :not([hidden]) ~ :not([hidden]) { --tw-divide-y-reverse: 0; border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); @@ -1155,6 +1185,11 @@ video { border-color: rgb(15 23 42 / var(--tw-border-opacity)); } +.border-slate-300 { + --tw-border-opacity: 1; + border-color: rgb(203 213 225 / var(--tw-border-opacity)); +} + .bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity)); @@ -1265,6 +1300,11 @@ video { background-color: rgb(254 249 195 / var(--tw-bg-opacity)); } +.bg-slate-50 { + --tw-bg-opacity: 1; + background-color: rgb(248 250 252 / var(--tw-bg-opacity)); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1303,6 +1343,11 @@ video { padding-right: 0.5rem; } +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + .px-4 { padding-left: 1rem; padding-right: 1rem; @@ -1348,6 +1393,11 @@ video { padding-bottom: 1rem; } +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + .pl-4 { padding-left: 1rem; } @@ -1380,6 +1430,10 @@ video { font-family: museo, serif; } +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + .text-2xl { font-size: 1.5rem; line-height: 2rem; @@ -1476,6 +1530,11 @@ video { color: rgb(30 64 175 / var(--tw-text-opacity)); } +.text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + .text-gray-500 { --tw-text-opacity: 1; color: rgb(107 114 128 / var(--tw-text-opacity)); @@ -1561,9 +1620,24 @@ video { color: rgb(133 77 14 / var(--tw-text-opacity)); } -.text-gray-400 { +.text-slate-400 { --tw-text-opacity: 1; - color: rgb(156 163 175 / var(--tw-text-opacity)); + color: rgb(148 163 184 / var(--tw-text-opacity)); +} + +.text-slate-500 { + --tw-text-opacity: 1; + color: rgb(100 116 139 / var(--tw-text-opacity)); +} + +.text-slate-700 { + --tw-text-opacity: 1; + color: rgb(51 65 85 / var(--tw-text-opacity)); +} + +.text-slate-800 { + --tw-text-opacity: 1; + color: rgb(30 41 59 / var(--tw-text-opacity)); } .underline { @@ -1759,6 +1833,11 @@ a { color: rgb(17 24 39 / var(--tw-text-opacity)); } +.hover\:text-green-900:hover { + --tw-text-opacity: 1; + color: rgb(20 83 45 / var(--tw-text-opacity)); +} + .hover\:text-red-700:hover { --tw-text-opacity: 1; color: rgb(185 28 28 / var(--tw-text-opacity)); @@ -1769,9 +1848,14 @@ a { color: rgb(127 29 29 / var(--tw-text-opacity)); } -.hover\:text-green-900:hover { +.hover\:text-red-500:hover { --tw-text-opacity: 1; - color: rgb(20 83 45 / var(--tw-text-opacity)); + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +.focus\:border-slate-400:focus { + --tw-border-opacity: 1; + border-color: rgb(148 163 184 / var(--tw-border-opacity)); } .focus\:outline-none:focus { @@ -1800,6 +1884,15 @@ a { --tw-ring-color: rgb(100 116 139 / var(--tw-ring-opacity)); } +.focus\:ring-slate-400:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(148 163 184 / var(--tw-ring-opacity)); +} + +.focus\:ring-offset-2:focus { + --tw-ring-offset-width: 2px; +} + .group:hover .group-hover\:visible { visibility: visible; } @@ -1808,6 +1901,20 @@ a { opacity: 1; } +@media (min-width: 768px) { + .md\:col-span-2 { + grid-column: span 2 / span 2; + } + + .md\:col-span-3 { + grid-column: span 3 / span 3; + } + + .md\:grid-cols-5 { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } +} + @media (min-width: 1024px) { .lg\:left-auto { left: auto;