feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -0,0 +1,172 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { MessageRevision } from '../../../../shared-kernel';
import { ServerDirectoryFacade } from '../../../server-directory';
const STORAGE_KEY = 'metoyou.messageSigningKeyPair';
interface StoredSigningKeyPair {
publicKeyJwk: JsonWebKey;
privateKeyJwk: JsonWebKey;
}
@Injectable({ providedIn: 'root' })
export class MessageSigningService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private keyPairPromise: Promise<CryptoKeyPair> | null = null;
async ensureSigningKeyPair(): Promise<CryptoKeyPair> {
if (!this.keyPairPromise) {
this.keyPairPromise = this.loadOrCreateKeyPair();
}
return await this.keyPairPromise;
}
async getPublicKeyJwk(): Promise<JsonWebKey> {
const keyPair = await this.ensureSigningKeyPair();
return await crypto.subtle.exportKey('jwk', keyPair.publicKey);
}
async registerSigningPublicKeyIfNeeded(): Promise<void> {
const activeServer = this.serverDirectory.activeServer();
if (!activeServer) {
return;
}
const publicKeyJwk = await this.getPublicKeyJwk();
const apiBase = `${activeServer.url}/api`;
await firstValueFrom(this.http.put(`${apiBase}/users/me/signing-key`, { publicKeyJwk }));
}
async signRevision(revision: MessageRevision): Promise<string> {
const keyPair = await this.ensureSigningKeyPair();
const payload = this.canonicalRevisionPayload(revision);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const signature = await crypto.subtle.sign('Ed25519', keyPair.privateKey, digest);
return this.bufferToBase64Url(signature);
}
async fetchSigningPublicKey(userId: string): Promise<JsonWebKey | null> {
const activeServer = this.serverDirectory.activeServer();
if (!activeServer) {
return null;
}
try {
const response = await firstValueFrom(
this.http.get<{ publicKeyJwk: JsonWebKey }>(`${activeServer.url}/api/users/${encodeURIComponent(userId)}/signing-public-key`)
);
return response.publicKeyJwk;
} catch {
return null;
}
}
async verifyRevisionSignature(
revision: MessageRevision,
publicKeyJwk: JsonWebKey
): Promise<boolean> {
if (!revision.signature) {
return false;
}
try {
const publicKey = await crypto.subtle.importKey(
'jwk',
publicKeyJwk,
{ name: 'Ed25519' },
false,
['verify']
);
const payload = this.canonicalRevisionPayload(revision);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const signature = this.base64UrlToBuffer(revision.signature);
return await crypto.subtle.verify('Ed25519', publicKey, signature, digest);
} catch {
return false;
}
}
private async loadOrCreateKeyPair(): Promise<CryptoKeyPair> {
const stored = this.readStoredKeyPair();
if (stored) {
const [publicKey, privateKey] = await Promise.all([
crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']),
crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])
]);
return { publicKey, privateKey };
}
const generated = await crypto.subtle.generateKey(
{ name: 'Ed25519' },
true,
['sign', 'verify']
);
const [publicKeyJwk, privateKeyJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', generated.publicKey),
crypto.subtle.exportKey('jwk', generated.privateKey)
]);
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
return generated;
}
private canonicalRevisionPayload(revision: MessageRevision): string {
const { signature: _signature, ...unsigned } = revision;
return JSON.stringify(unsigned, Object.keys(unsigned).sort());
}
private readStoredKeyPair(): StoredSigningKeyPair | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
return JSON.parse(raw) as StoredSigningKeyPair;
} catch {
return null;
}
}
private writeStoredKeyPair(pair: StoredSigningKeyPair): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pair));
}
private bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
private base64UrlToBuffer(value: string): ArrayBuffer {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
}