Remove member-ordering and complexity eslint-disable comments by reordering class members and applying targeted fixes. Add metoyou/no-maybe-in-naming, type-safe WebRTC e2e harness helpers, and resolve remaining lint errors so npm run lint exits cleanly. Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
5.1 KiB
TypeScript
181 lines
5.1 KiB
TypeScript
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 = await crypto.subtle.importKey(
|
|
'jwk',
|
|
stored.publicKeyJwk,
|
|
{ name: 'Ed25519' },
|
|
true,
|
|
['verify']
|
|
);
|
|
const privateKey = await 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 = await crypto.subtle.exportKey('jwk', generated.publicKey);
|
|
const privateKeyJwk = await 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;
|
|
}
|
|
}
|