Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 91 additions & 29 deletions Site/ClientApp/src/app/components/keygen/keygen.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,40 +36,44 @@ <h3>Geração e download de chaves</h3>
<mat-icon inline>open_in_new</mat-icon>
</a>
</p>

<button mat-stroked-button color="warn" (click)="openVerifyDialog(verifyTpl)">
verificar chaves
</button>
</div>

<ng-template #pwdTpl>
<h2 mat-dialog-title>Baixar chave privada</h2>

<div mat-dialog-content class="dialog-grid">

<mat-form-field>
<mat-label>Insira uma senha</mat-label>
<input matInput type="password" placeholder="Senha" [formControl]="password" (blur)="passwordError()" required />
@if (password.invalid || passwordError()) {
<mat-error>{{passwordError()}}</mat-error>
}
</mat-form-field>

<mat-form-field>
<mat-label>Confirme a senha</mat-label>
<input matInput type="password" placeholder="Senha" [formControl]="confirm" (blur)="confirmError()" required />
@if (confirm.invalid || confirmError()) {
<mat-error>{{confirmError()}}</mat-error>
}
</mat-form-field>
<form [formGroup]="form">
<h2 mat-dialog-title>Baixar chave privada</h2>

<div mat-dialog-content class="dialog-grid">
<mat-form-field>
<mat-label>Insira uma senha</mat-label>
<input matInput type="password" placeholder="Senha" formControlName="password" required />
<mat-error *ngIf="form.get('password')?.hasError('required')">Informe a senha</mat-error>
<mat-error *ngIf="form.get('password')?.hasError('minlength')">
Digite pelo menos {{minPasswordLength}} caracteres
</mat-error>
</mat-form-field>

<mat-form-field>
<mat-label>Confirme a senha</mat-label>
<input matInput type="password" placeholder="Senha" formControlName="confirm" required />
<mat-error *ngIf="form.get('confirm')?.hasError('required')">Confirme a senha</mat-error>
<mat-error *ngIf="form.errors?.['passwordMismatch'] && form.get('confirmPassword')?.touched">As senhas não coincidem</mat-error>
</mat-form-field>
</div>

</div>
<div mat-dialog-actions class="actions">
<button mat-flat-button [disabled]="formInvalid" [mat-dialog-close]="passwordValue">
Baixar chave privada
</button>
<button mat-stroked-button [mat-dialog-close]="null">
Cancelar
</button>

<div mat-dialog-actions class="actions">
<button mat-flat-button [disabled]="formInvalid" [mat-dialog-close]="password.value">
Baixar chave privada
</button>
<button mat-stroked-button [mat-dialog-close]="null">
Cancelar
</button>

</div>
</div>
</form>
</ng-template>

<ng-template #confirmTpl>
Expand All @@ -88,3 +92,61 @@ <h2 mat-dialog-title>Confirmar descarte</h2>
</button>
</div>
</ng-template>

<ng-template #verifyTpl>
<form [formGroup]="verifyForm" (ngSubmit)="verifyKey()">
<h2 mat-dialog-title>Verificar chave</h2>

<div mat-dialog-content class="dialog-grid">

<mat-form-field appearance="fill">
<mat-label>Chave pública (PEM)</mat-label>
<textarea matInput rows="4" formControlName="publicKey" placeholder="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"></textarea>
<button mat-icon-button matSuffix type="button" (click)="pubFileInput.click()" aria-label="Carregar arquivo">
<mat-icon>folder_open</mat-icon>
</button>
</mat-form-field>
<input hidden #pubFileInput type="file" accept=".pem,.txt" (change)="loadFile($event, 'publicKey')" />

<mat-form-field appearance="fill">
<mat-label>Chave privada (PEM criptografado)</mat-label>
<textarea matInput rows="4" formControlName="privateKey" placeholder="-----BEGIN ENCRYPTED PRIVATE KEY-----\n...\n-----END ENCRYPTED PRIVATE KEY-----"></textarea>
<button mat-icon-button matSuffix type="button" (click)="privFileInput.click()" aria-label="Carregar arquivo">
<mat-icon>folder_open</mat-icon>
</button>
</mat-form-field>
<input hidden #privFileInput type="file" accept=".pem,.txt" (change)="loadFile($event, 'privateKey')" />

<mat-form-field>
<mat-label>senha</mat-label>
<input matInput type="password" placeholder="Senha da chave privada" formControlName="password" required />
</mat-form-field>

<div class="mt-2" *ngIf="verifyState !== 'idle'">
<mat-divider class="my-2"></mat-divider>

<p *ngIf="verifyState === 'ok'">
<strong>Chaves válidas:</strong> a pública corresponde à privada.
</p>
<p *ngIf="verifyState === 'badpass'">
<strong>Senha incorreta ou chave privada inválida.</strong>
</p>
<p *ngIf="verifyState === 'mismatch'">
<strong>Não corresponde:</strong> a pública não bate com a privada.
</p>
<p *ngIf="verifyState === 'parse'">
<strong>Formato inválido:</strong> verifique os blocos PEM fornecidos.
</p>
</div>

</div>

<div mat-dialog-actions class="actions">
<button mat-flat-button color="primary" type="submit" [disabled]="verifyForm.invalid || verifying">
{{ verifying ? 'Verificando…' : 'Verificar chave' }}
</button>
<button mat-stroked-button type="button" [mat-dialog-close]="null">Fechar</button>
</div>

</form>
</ng-template>
149 changes: 118 additions & 31 deletions Site/ClientApp/src/app/components/keygen/keygen.component.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Component, TemplateRef } from '@angular/core';
import { Component, OnInit, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import * as forge from 'node-forge';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { FormsModule, ReactiveFormsModule, FormControl, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
import { FormsModule, ReactiveFormsModule, Validators, FormBuilder, FormGroup } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { firstValueFrom } from 'rxjs';
import { MatIconModule } from '@angular/material/icon';
import { delay } from '../../classes/utils';
import { MatDividerModule } from '@angular/material/divider';

type VerifyState = 'idle' | 'ok' | 'badpass' | 'mismatch' | 'parse';

@Component({
selector: 'app-keygen',
standalone: true,
Expand All @@ -27,48 +29,60 @@ import { MatDividerModule } from '@angular/material/divider';
templateUrl: './keygen.component.html',
styleUrls: ['./keygen.component.scss'],
})
export class KeygenComponent {
constructor(private dialog: MatDialog) { }
export class KeygenComponent implements OnInit {
constructor(
private dialog: MatDialog,
private readonly fb: FormBuilder,
) { }

generating = false;
hasKeys = false;

publicKeyPem = '';
privateKeyEncryptedPem = '';
privateKeyObj: forge.pki.rsa.PrivateKey | null = null;
thumbprint = '';
moniker = '';

password = new FormControl<string>('', { nonNullable: true, validators: [Validators.required, Validators.minLength(4)] });
confirm = new FormControl<string>('', {
nonNullable: true, validators: [
Validators.required,
(control: AbstractControl): ValidationErrors | null => {
if (this.password && control.value != this.password.value) {
return { passwordMismatch: true };
}
return null;
}
form!: FormGroup;
minPasswordLength = 4;

] });
verifyForm!: FormGroup;
verifyState: VerifyState = 'idle';
verifying = false;

passwordError(): string {
if (this.password.hasError('required')) return 'Informe a senha';
if (this.password.hasError('minlength')) return 'Digite pelo menos 4 caracteres';

return '';
ngOnInit(): void {
this.initializeForm();
}
confirmError(): string {
if (this.confirm.hasError('required')) return 'Confirme a senha';
if (this.password.value != this.confirm.value) return 'As senhas não coincidem';

return '';
private initializeForm(): void {
this.form = this.fb.group({
password: ['', [Validators.required, Validators.minLength(this.minPasswordLength)]],
confirm: ['', [Validators.required]]
}, { validators: this.passwordsMatchValidator });

this.verifyForm = this.fb.group({
publicKey: ['', [Validators.required]],
privateKey: ['', [Validators.required]],
password: ['', [Validators.required]]
});
}

get hasKeys(): boolean {
return !!(this.publicKeyPem && this.privateKeyObj);
private passwordsMatchValidator = (group: FormGroup) => {
const p = group.get('password')?.value;
const c = group.get('confirm')?.value;
if (p !== c) {
return { passwordMismatch: true };
}
return null;
};

get passwordValue(): string {
return this.form.get('password')?.value;
}

get formInvalid(): boolean {
return !!this.passwordError() || !!this.confirmError();
return this.form.invalid;
}

async generate(): Promise<void> {
Expand All @@ -94,6 +108,8 @@ export class KeygenComponent {
this.moniker = digest.toHex().slice(0, 6);
this.thumbprint = digest.toHex();

this.hasKeys = true;

} catch (err) {
console.error('Falha ao gerar chaves:', err);
} finally {
Expand All @@ -109,16 +125,16 @@ export class KeygenComponent {
async downloadPrivate(passwordtpl: TemplateRef<boolean>): Promise<void> {
if (!this.hasKeys || !this.privateKeyObj) return;

this.initializeForm();

const ref = this.dialog.open(passwordtpl, { disableClose: true });
const password: string = await firstValueFrom(ref.afterClosed());

const pwdValue = this.password.value;
this.password.reset('');
this.confirm.reset('');
this.initializeForm();

if (!password) return;

const pem = forge.pki.encryptRsaPrivateKey(this.privateKeyObj, pwdValue, {
const pem = forge.pki.encryptRsaPrivateKey(this.privateKeyObj, password, {
algorithm: 'aes256',
count: 200_000,
prfAlgorithm: 'sha256',
Expand All @@ -139,6 +155,7 @@ export class KeygenComponent {
this.privateKeyObj = null;
this.thumbprint = '';
this.moniker = '';
this.hasKeys = false;
}

private download(content: string, filename: string, mime = 'application/octet-stream'): void {
Expand All @@ -153,4 +170,74 @@ export class KeygenComponent {
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}

openVerifyDialog(tpl: TemplateRef<boolean>): void {
this.verifyForm.reset();
this.verifyState = 'idle';
this.verifying = false;
this.dialog.open(tpl, { disableClose: false, width: '450px' });
}

async loadFile(event: Event, controlName: 'publicKey' | 'privateKey'): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;

try {
const text = await file.text();
this.verifyForm.get(controlName)?.setValue(text);
this.verifyForm.get(controlName)?.markAsDirty();
this.verifyForm.updateValueAndValidity();

} finally {
input.value = '';
}
}

async verifyKey(): Promise<void> {
if (this.verifyForm.invalid || this.verifying) return;

this.verifying = true;
this.verifyState = 'idle';

await delay(100);

const publicPem = (this.verifyForm.get('publicKey')?.value).trim();
const privatePem = (this.verifyForm.get('privateKey')?.value).trim();
const password = this.verifyForm.get('password')?.value;

try {
let providedPub: forge.pki.rsa.PublicKey;
try {
providedPub = forge.pki.publicKeyFromPem(publicPem) as forge.pki.rsa.PublicKey;
} catch {
this.verifyState = 'parse';
return;
}

let privateKey = forge.pki.decryptRsaPrivateKey(privatePem, password);

if (!privateKey) {
this.verifyState = 'badpass';
return;
}

const pubFromPriv = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e);
const sameN = pubFromPriv.n.compareTo(providedPub.n) === 0;
const sameE = pubFromPriv.e.compareTo(providedPub.e) === 0;

if (!(sameN && sameE)) {
this.verifyState = 'mismatch';
return;
} else {
this.verifyState = 'ok';
return
}

} catch {
this.verifyState = 'parse';
} finally {
this.verifying = false;
}
}
}