This commit is contained in:
6d486f49
2026-08-14 15:56:17 -04:00
parent af64b9e95c
commit 09a8adfc4a
827 changed files with 65045 additions and 71849 deletions
@@ -0,0 +1,509 @@
@page "/passkeys"
@implements IDisposable
@using System.Security.Cryptography
<PageTitle>Passkeys Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Passkey Credentials Database (`PasskeyCredential`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete WebAuthn / FIDO2 security credentials with raw binary byte arrays.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadPasskeysAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add New Passkey
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<div class="chrono-card">
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by AaGuid, ID, or SignCount..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterPasskeys" />
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
}
</div>
<div class="text-secondary small">
Showing @passkeys.Count credential(s)
</div>
</div>
@if (isLoading)
{
<div class="text-center py-5 text-secondary">
<div class="spinner-border text-primary mb-2" role="status"></div>
<div>Loading passkeys from database...</div>
</div>
}
else if (!passkeys.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-shield-lock fs-1 d-block mb-2"></i>
<h5>No Passkeys Found</h5>
<p class="small">No passkey credentials were found in the database.</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add First Passkey
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedSamplePasskeysAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Passkeys
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th style="width: 220px;">AAGUID</th>
<th style="width: 120px;">Sign Count</th>
<th>Credential ID (Byte[])</th>
<th>Public Key (Byte[])</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var p in passkeys)
{
<tr>
<td>
<span class="badge bg-dark border border-secondary text-white">#@p.Id</span>
</td>
<td>
<div class="text-white small font-monospace">@(string.IsNullOrEmpty(p.AaGuid) ? "(None)" : p.AaGuid)</div>
</td>
<td>
<span class="badge bg-purple">@p.SignCount</span>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.CredentialId)">
@Convert.ToHexString(p.CredentialId)[..Math.Min(16, p.CredentialId.Length * 2)]... (@p.CredentialId.Length bytes)
</div>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.PublicKey)">
@Convert.ToHexString(p.PublicKey)[..Math.Min(16, p.PublicKey.Length * 2)]... (@p.PublicKey.Length bytes)
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(p)" title="View Binary Details">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(p)" title="Edit Passkey">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(p)" title="Delete Passkey">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Add / Edit Modal -->
@if (showModal)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 700px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isEditing ? "bi-pencil-square" : "bi-plus-circle") text-primary me-2"></i>
@(isEditing ? $"Edit Passkey #{currentPasskey.Id}" : "Add New Passkey")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
@if (!isEditing)
{
<div class="mb-3 d-flex justify-content-end">
<button type="button" class="btn btn-sm btn-chrono-secondary" @onclick="GenerateRandomPasskey">
<i class="bi bi-dice-5-fill text-warning me-1"></i> Generate Random Test Key
</button>
</div>
}
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label text-secondary small fw-bold">AAGUID</label>
<input type="text" class="form-control form-control-chrono font-monospace" placeholder="e.g. 00000000-0000-0000-0000-000000000000"
@bind="currentPasskey.AaGuid" />
</div>
<div class="col-md-4">
<label class="form-label text-secondary small fw-bold">SIGN COUNT</label>
<input type="number" class="form-control form-control-chrono" min="0"
@bind="currentPasskey.SignCount" />
</div>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">CREDENTIAL ID (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(credIdHex) bytes</span>
</div>
<textarea class="form-control form-control-chrono font-monospace" rows="2"
placeholder="e.g. AABBCCDD01020304"
@bind="credIdHex"></textarea>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">PUBLIC KEY (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(pubKeyHex) bytes</span>
</div>
<textarea class="form-control form-control-chrono font-monospace" rows="2"
placeholder="e.g. 3059301306072A8648CE3D020106082A8648CE3D030107"
@bind="pubKeyHex"></textarea>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">USER HANDLE (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(userHandleHex) bytes</span>
</div>
<input type="text" class="form-control form-control-chrono font-monospace"
placeholder="e.g. DEADBEAF01020304"
@bind="userHandleHex" />
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SavePasskeyAsync" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Passkey")
</button>
</div>
</div>
</div>
}
<!-- View Binary Details Modal -->
@if (showViewModal && viewedPasskey != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 650px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi bi-shield-check text-purple me-2"></i>
Passkey Binary Inspection (#@viewedPasskey.Id)
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showViewModal = false"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">CREDENTIAL ID</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.CredentialId)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.CredentialId)</pre>
</div>
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">PUBLIC KEY</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.PublicKey)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.PublicKey)</pre>
</div>
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">USER HANDLE</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.UserHandle)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.UserHandle)</pre>
</div>
<div class="row g-2">
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary">
<span class="text-secondary small d-block">AAGUID</span>
<span class="text-white font-monospace small">@viewedPasskey.AaGuid</span>
</div>
</div>
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary">
<span class="text-secondary small d-block">SIGN COUNT</span>
<span class="text-purple font-monospace small fw-bold">@viewedPasskey.SignCount</span>
</div>
</div>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (showDeleteModal && passkeyToDelete != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Passkey
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete passkey credential:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3 font-monospace">
Passkey #@passkeyToDelete.Id (AAGUID: @(passkeyToDelete.AaGuid ?? "N/A"))
</div>
<p class="text-secondary small mb-0">This will permanently delete the binary security credential from the database.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Passkey")
</button>
</div>
</div>
</div>
}
@code {
private List<PasskeyCredential> passkeys = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private PasskeyCredential currentPasskey = new()
{
CredentialId = [],
PublicKey = [],
UserHandle = []
};
private string credIdHex = "";
private string pubKeyHex = "";
private string userHandleHex = "";
private bool showViewModal;
private PasskeyCredential? viewedPasskey;
private bool showDeleteModal;
private PasskeyCredential? passkeyToDelete;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await LoadPasskeysAsync();
}
private void HandleConfigChanged()
{
_ = LoadPasskeysAsync();
}
private async Task LoadPasskeysAsync()
{
isLoading = true;
try
{
passkeys = await DbService.GetPasskeysAsync(searchTerm);
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Error loading passkeys: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task FilterPasskeys()
{
passkeys = await DbService.GetPasskeysAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = "";
await LoadPasskeysAsync();
}
private void OpenAddModal()
{
isEditing = false;
currentPasskey = new PasskeyCredential
{
AaGuid = Guid.NewGuid().ToString(),
SignCount = 0,
CredentialId = [],
PublicKey = [],
UserHandle = []
};
GenerateRandomPasskey();
showModal = true;
}
private void GenerateRandomPasskey()
{
var cred = new byte[16];
var pub = new byte[32];
var user = new byte[8];
RandomNumberGenerator.Fill(cred);
RandomNumberGenerator.Fill(pub);
RandomNumberGenerator.Fill(user);
credIdHex = Convert.ToHexString(cred);
pubKeyHex = Convert.ToHexString(pub);
userHandleHex = Convert.ToHexString(user);
currentPasskey.AaGuid = Guid.NewGuid().ToString();
currentPasskey.SignCount = Random.Shared.Next(1, 100);
}
private void OpenEditModal(PasskeyCredential passkey)
{
isEditing = true;
currentPasskey = new PasskeyCredential
{
Id = passkey.Id,
AaGuid = passkey.AaGuid,
SignCount = passkey.SignCount,
CredentialId = passkey.CredentialId,
PublicKey = passkey.PublicKey,
UserHandle = passkey.UserHandle
};
credIdHex = Convert.ToHexString(passkey.CredentialId);
pubKeyHex = Convert.ToHexString(passkey.PublicKey);
userHandleHex = Convert.ToHexString(passkey.UserHandle);
showModal = true;
}
private void CloseModal()
{
showModal = false;
}
private static int GetByteCount(string hex)
{
var clean = hex.Replace(" ", "").Replace("-", "");
return clean.Length / 2;
}
private async Task SavePasskeyAsync()
{
try
{
var cleanCred = credIdHex.Replace(" ", "").Replace("-", "");
var cleanPub = pubKeyHex.Replace(" ", "").Replace("-", "");
var cleanUser = userHandleHex.Replace(" ", "").Replace("-", "");
if (string.IsNullOrWhiteSpace(cleanCred) || cleanCred.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid Credential ID hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanPub) || cleanPub.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid Public Key hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanUser) || cleanUser.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid User Handle hex string (must be even length).";
return;
}
currentPasskey.CredentialId = Convert.FromHexString(cleanCred);
currentPasskey.PublicKey = Convert.FromHexString(cleanPub);
currentPasskey.UserHandle = Convert.FromHexString(cleanUser);
isSaving = true;
var result = await DbService.SavePasskeyAsync(currentPasskey, !isEditing);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
if (result.Success)
{
showModal = false;
await LoadPasskeysAsync();
}
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Failed to parse hex or save passkey: " + ex.Message;
}
}
private void OpenViewModal(PasskeyCredential passkey)
{
viewedPasskey = passkey;
showViewModal = true;
}
private void OpenDeleteModal(PasskeyCredential passkey)
{
passkeyToDelete = passkey;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (passkeyToDelete == null) return;
isSaving = true;
var result = await DbService.DeletePasskeyAsync(passkeyToDelete.Id);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
showDeleteModal = false;
passkeyToDelete = null;
await LoadPasskeysAsync();
}
private async Task SeedSamplePasskeysAsync()
{
var result = await DbService.SeedSampleDataAsync();
alertIsSuccess = result.Success;
alertMessage = result.Message;
await LoadPasskeysAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}