feat(client): add CryptoService for login encryption

Implements ICryptoService for encrypting login credentials using RSA-OAEP.
Uses JavaScript interop with browser's native SubtleCrypto API instead of
Blazor.SubtleCrypto package (which only supports AES-GCM, not RSA-OAEP).

- ICryptoService interface in JdeScoping.Client.Services namespace
- CryptoService fetches server's public key once, caches it
- interop.js rsaEncrypt function for RSA-OAEP encryption via Web Crypto API
This commit is contained in:
Joseph Doherty
2026-01-03 08:33:10 -05:00
parent 1c1752a4fd
commit 30153dcbf8
3 changed files with 125 additions and 0 deletions
@@ -0,0 +1,63 @@
using System.Net.Http.Json;
using System.Text.Json;
using JdeScoping.Core.Models.Auth;
using Microsoft.JSInterop;
namespace JdeScoping.Client.Services;
/// <summary>
/// Encrypts login credentials using Web Crypto API via JavaScript interop.
/// Uses RSA-OAEP with SHA-256 to encrypt credentials before transmission.
/// </summary>
public class CryptoService : ICryptoService
{
private readonly HttpClient _httpClient;
private readonly IJSRuntime _jsRuntime;
private string? _cachedPublicKeyPem;
private readonly SemaphoreSlim _keyLock = new(1, 1);
public CryptoService(HttpClient httpClient, IJSRuntime jsRuntime)
{
_httpClient = httpClient;
_jsRuntime = jsRuntime;
}
/// <inheritdoc />
public async Task<string> EncryptLoginAsync(LoginModel model)
{
var publicKeyPem = await GetOrFetchPublicKeyAsync();
var json = JsonSerializer.Serialize(model);
// Use JavaScript interop to encrypt with RSA-OAEP via browser's SubtleCrypto API
var encryptedBase64 = await _jsRuntime.InvokeAsync<string>(
"jdeScopingInterop.rsaEncrypt",
publicKeyPem,
json);
return encryptedBase64;
}
private async Task<string> GetOrFetchPublicKeyAsync()
{
if (_cachedPublicKeyPem is not null)
return _cachedPublicKeyPem;
await _keyLock.WaitAsync();
try
{
if (_cachedPublicKeyPem is not null)
return _cachedPublicKeyPem;
var response = await _httpClient.GetFromJsonAsync<PublicKeyResponse>("api/auth/public-key")
?? throw new InvalidOperationException("Failed to fetch public key from server");
_cachedPublicKeyPem = response.PublicKeyPem;
return _cachedPublicKeyPem;
}
finally
{
_keyLock.Release();
}
}
}
@@ -0,0 +1,16 @@
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for encrypting data using server's RSA public key.
/// </summary>
public interface ICryptoService
{
/// <summary>
/// Encrypts login credentials for transmission to server.
/// </summary>
/// <param name="model">Login credentials to encrypt</param>
/// <returns>Base64-encoded encrypted data</returns>
Task<string> EncryptLoginAsync(LoginModel model);
}
@@ -66,5 +66,51 @@ window.jdeScopingInterop = {
// Remove value from sessionStorage
removeSessionStorage: function (key) {
sessionStorage.removeItem(key);
},
// RSA-OAEP encryption for login credentials
// Takes a PEM-encoded public key and plaintext, returns base64-encoded ciphertext
rsaEncrypt: async function (publicKeyPem, plaintext) {
// Extract base64 content from PEM format
const pemHeader = '-----BEGIN PUBLIC KEY-----';
const pemFooter = '-----END PUBLIC KEY-----';
const pemContents = publicKeyPem
.replace(pemHeader, '')
.replace(pemFooter, '')
.replace(/\s/g, '');
// Decode base64 to binary
const binaryDer = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0));
// Import the public key for RSA-OAEP encryption
const publicKey = await window.crypto.subtle.importKey(
'spki',
binaryDer,
{
name: 'RSA-OAEP',
hash: 'SHA-256'
},
false,
['encrypt']
);
// Encode plaintext to bytes
const encoder = new TextEncoder();
const plaintextBytes = encoder.encode(plaintext);
// Encrypt with RSA-OAEP
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
plaintextBytes
);
// Convert to base64 for transmission
const ciphertextArray = new Uint8Array(ciphertext);
let binary = '';
for (let i = 0; i < ciphertextArray.length; i++) {
binary += String.fromCharCode(ciphertextArray[i]);
}
return btoa(binary);
}
};