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:
@@ -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
|
// Remove value from sessionStorage
|
||||||
removeSessionStorage: function (key) {
|
removeSessionStorage: function (key) {
|
||||||
sessionStorage.removeItem(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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user