diff --git a/wrappers/wasm/demo/src/app/app.component.html b/wrappers/wasm/demo/src/app/app.component.html index 989bfe04..b3c8c24a 100644 --- a/wrappers/wasm/demo/src/app/app.component.html +++ b/wrappers/wasm/demo/src/app/app.component.html @@ -14,6 +14,7 @@

DevolutionsCrypto

Asymmetric Secret Key Encryption Utilities + Inspect (Debug) diff --git a/wrappers/wasm/demo/src/app/app.routes.ts b/wrappers/wasm/demo/src/app/app.routes.ts index 3883d948..eab16e7d 100644 --- a/wrappers/wasm/demo/src/app/app.routes.ts +++ b/wrappers/wasm/demo/src/app/app.routes.ts @@ -5,6 +5,7 @@ import { PasswordComponent } from './password/password.component'; import { UtilitiesComponent } from './utilities/utilities.component'; import { AsymmetricComponent } from './asymmetric/asymmetric.component'; import { SecretKeyEncryptionComponent } from './secret-key-encryption/secret-key-encryption.component'; +import { InspectComponent } from './inspect/inspect.component'; export const routes: Routes = [ { path: '', redirectTo: '/encryption', pathMatch: 'full' }, @@ -13,5 +14,6 @@ export const routes: Routes = [ { path: 'password', component: PasswordComponent }, { path: 'utilities', component: UtilitiesComponent }, { path: 'asymmetric', component: AsymmetricComponent }, - { path: 'secret-key-encryption', component: SecretKeyEncryptionComponent } + { path: 'secret-key-encryption', component: SecretKeyEncryptionComponent }, + { path: 'inspect', component: InspectComponent } ]; diff --git a/wrappers/wasm/demo/src/app/inspect/inspect.component.css b/wrappers/wasm/demo/src/app/inspect/inspect.component.css new file mode 100644 index 00000000..f1e29785 --- /dev/null +++ b/wrappers/wasm/demo/src/app/inspect/inspect.component.css @@ -0,0 +1,77 @@ +.debug-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.debug-table th { + background: #f1f5f9; + color: #475569; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1.5px solid #e2e8f0; +} +.debug-table td { + padding: 0.55rem 0.75rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: top; +} +.debug-table tr:last-child td { + border-bottom: none; +} +.debug-table tr:hover td { + background: #f8fafc; +} +.debug-mono { + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 0.85rem; + color: #0f172a; +} +.debug-hex { + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 0.75rem; + color: #475569; + word-break: break-all; + display: inline-block; + max-width: 420px; +} +.debug-num { + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 0.82rem; + color: #0d6e63; + font-weight: 600; +} +.debug-name { + color: #1e3a5f; + font-weight: 500; +} +.debug-desc { + color: #64748b; + font-size: 0.82rem; +} +.debug-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} +.debug-badge-ok { + background: #dcfce7; + color: #166534; +} +.debug-badge-err { + background: #fee2e2; + color: #991b1b; +} +.debug-error-title { + background: #991b1b !important; +} +.debug-error-msg { + color: #991b1b; + font-weight: 500; + margin: 0; +} diff --git a/wrappers/wasm/demo/src/app/inspect/inspect.component.html b/wrappers/wasm/demo/src/app/inspect/inspect.component.html new file mode 100644 index 00000000..3bc19886 --- /dev/null +++ b/wrappers/wasm/demo/src/app/inspect/inspect.component.html @@ -0,0 +1,125 @@ + +
+ + Devolutions Crypto +
+ +
+ +
+ +

Inspect (Debug)

+
+ +
+
Decode Devolutions-Crypto string
+
+
+
+ + +
+
+ +
+
+
+
+ + @if (parseResult) { + + @if (parseResult.error) { +
+
Error
+
+

{{ parseResult.error }}

+
+
+ } + +
+
Header
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValueDetails
Signature + {{ parseResult.signatureHex }} + + @if (parseResult.signatureValid) { + ✓ valid (0x0C0D) + } @else { + ✗ invalid — expected 0x0C0D + } +
Type{{ parseResult.dataType }}{{ parseResult.dataTypeName }}
Subtype{{ parseResult.subtype }}{{ parseResult.subtypeName }}
Version{{ parseResult.version }}{{ parseResult.versionName }}
Total size{{ parseResult.totalBytes }} bytes (8 header + {{ parseResult.payloadBytes }} payload)
+
+
+ + @if (parseResult.signatureValid && parseResult.payloadFields.length > 0) { +
+
Payload Fields
+
+ + + + + + + + + + + + @for (field of parseResult.payloadFields; track field.name) { + + + + + + + + } + +
FieldOffsetSizeDescriptionHex
{{ field.name }}+{{ field.offset }}{{ field.size }} B{{ field.description }}{{ field.hex }}
+
+
+ } + + } + +
diff --git a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts new file mode 100644 index 00000000..b91912e1 --- /dev/null +++ b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts @@ -0,0 +1,410 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms'; +import { EncryptionService } from '../service/encryption.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faBug } from '@fortawesome/free-solid-svg-icons'; +import { CommonModule } from '@angular/common'; +import * as functions from '../shared/shared.component'; + +type EncryptionServiceInner = typeof import('../service/encryption.inner.service'); + +const SIGNATURE = 0x0c0d; +const HEADER_SIZE = 8; + +const DATA_TYPE_NAMES: Record = { + 0: 'None', + 1: 'Key', + 2: 'Ciphertext', + 3: 'PasswordHash', + 4: 'Share', + 5: 'SigningKey', + 6: 'Signature', + 7: 'OnlineCiphertext', +}; + +const SUBTYPE_NAMES: Record> = { + 1: { 0: 'None', 1: 'Private', 2: 'Public', 3: 'Pair', 4: 'Secret' }, + 2: { 0: 'None (treated as Symmetric)', 1: 'Symmetric', 2: 'Asymmetric' }, + 3: { 0: 'None' }, + 4: { 0: 'None' }, + 5: { 0: 'None', 1: 'Pair', 2: 'Public' }, + 6: { 0: 'None' }, +}; + +const VERSION_NAMES: Record> = { + 1: { 0: 'Latest', 1: 'V1 – Curve25519 / x25519' }, + 2: { 0: 'Latest', 1: 'V1 – AES256-CBC + HMAC-SHA256', 2: 'V2 – XChaCha20-Poly1305' }, + 3: { 0: 'Latest', 1: 'V1 – PBKDF2-HMAC-SHA256' }, + 4: { 0: 'Latest', 1: 'V1 – Shamir Secret Sharing over GF256' }, + 5: { 0: 'Latest', 1: 'V1 – Ed25519' }, + 6: { 0: 'Latest', 1: 'V1 – Ed25519' }, +}; + +export interface PayloadField { + name: string; + offset: number; + size: number; + hex: string; + description: string; +} + +export interface ParseResult { + signatureHex: string; + signatureValid: boolean; + dataType: number; + dataTypeName: string; + subtype: number; + subtypeName: string; + version: number; + versionName: string; + totalBytes: number; + payloadBytes: number; + payloadFields: PayloadField[]; + error?: string; +} + +function readUInt16LE(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(offset, true); +} + +function toHex(bytes: Uint8Array, maxBytes?: number): string { + const slice = maxBytes !== undefined ? bytes.slice(0, maxBytes) : bytes; + const hex = Array.from(slice) + .map((b) => b.toString(16).padStart(2, '0')) + .join(' '); + return maxBytes !== undefined && bytes.length > maxBytes ? hex + ' ...' : hex; +} + +@Component({ + selector: 'app-inspect', + standalone: true, + imports: [ReactiveFormsModule, FaIconComponent, CommonModule], + templateUrl: './inspect.component.html', + styleUrl: './inspect.component.css', +}) +export class InspectComponent implements OnInit { + faBug = faBug; + + debugForm: FormGroup; + parseResult: ParseResult | null = null; + + constructor(private encryptionService: EncryptionService) { + this.debugForm = new FormGroup({ + input: new FormControl(''), + }); + } + + ngOnInit() {} + + w3Open() { + functions.w3_open(); + } + + w3Close() { + functions.w3_close(); + } + + async decode() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + const input: string = this.debugForm.value.input?.trim(); + if (!input) { + return; + } + + try { + const bytes = service.base64decode(input); + this.parseResult = this.parseBytes(bytes); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + this.parseResult = { + signatureHex: '', + signatureValid: false, + dataType: 0, + dataTypeName: '', + subtype: 0, + subtypeName: '', + version: 0, + versionName: '', + totalBytes: 0, + payloadBytes: 0, + payloadFields: [], + error: `Base64 decode failed: ${msg}`, + }; + } + } + + private parseBytes(bytes: Uint8Array): ParseResult { + if (bytes.length < HEADER_SIZE) { + return this.errorResult( + bytes.length, + `Too short: need at least ${HEADER_SIZE} bytes, got ${bytes.length}` + ); + } + + const signature = readUInt16LE(bytes, 0); + const signatureValid = signature === SIGNATURE; + const dataType = readUInt16LE(bytes, 2); + const subtype = readUInt16LE(bytes, 4); + const version = readUInt16LE(bytes, 6); + + const subtypeMap = SUBTYPE_NAMES[dataType] ?? {}; + const versionMap = VERSION_NAMES[dataType] ?? {}; + + const result: ParseResult = { + signatureHex: `0x${signature.toString(16).toUpperCase().padStart(4, '0')}`, + signatureValid, + dataType, + dataTypeName: DATA_TYPE_NAMES[dataType] ?? `Unknown (${dataType})`, + subtype, + subtypeName: subtypeMap[subtype] ?? `Unknown (${subtype})`, + version, + versionName: versionMap[version] ?? `Unknown (${version})`, + totalBytes: bytes.length, + payloadBytes: bytes.length - HEADER_SIZE, + payloadFields: [], + }; + + if (!signatureValid) { + result.error = `Invalid signature: expected 0x0C0D, got ${result.signatureHex}`; + return result; + } + + result.payloadFields = this.parsePayload(bytes, dataType, subtype, version); + return result; + } + + private parsePayload( + bytes: Uint8Array, + dataType: number, + subtype: number, + version: number + ): PayloadField[] { + const payload = bytes.slice(HEADER_SIZE); + const abs = (rel: number) => HEADER_SIZE + rel; + + switch (dataType) { + case 2: + return this.parseCiphertextPayload(payload, subtype, version, abs); + case 1: + return this.parseKeyPayload(payload, subtype, abs); + case 3: + return this.parsePasswordHashPayload(payload, abs); + case 4: + return this.parseSharePayload(payload, abs); + default: + return [ + { + name: 'Raw Payload', + offset: HEADER_SIZE, + size: payload.length, + hex: toHex(payload, 32), + description: `${payload.length} bytes — no known structure for this data type`, + }, + ]; + } + } + + private parseCiphertextPayload( + payload: Uint8Array, + subtype: number, + version: number, + abs: (n: number) => number + ): PayloadField[] { + const fields: PayloadField[] = []; + + if (version === 1) { + // V1: IV(16) + Ciphertext(var) + HMAC(32) + if (payload.length < 48) { + fields.push({ + name: 'Error', + offset: abs(0), + size: payload.length, + hex: toHex(payload), + description: `Payload too short for V1 ciphertext (min 48 bytes, got ${payload.length})`, + }); + return fields; + } + const iv = payload.slice(0, 16); + const ct = payload.slice(16, payload.length - 32); + const hmac = payload.slice(payload.length - 32); + fields.push({ + name: 'IV', + offset: abs(0), + size: 16, + hex: toHex(iv), + description: 'AES-256-CBC Initialization Vector (16 bytes)', + }); + fields.push({ + name: 'Ciphertext', + offset: abs(16), + size: ct.length, + hex: toHex(ct, 32), + description: `AES-256-CBC encrypted data with PKCS7 padding (${ct.length} bytes)`, + }); + fields.push({ + name: 'HMAC-SHA256', + offset: abs(payload.length - 32), + size: 32, + hex: toHex(hmac), + description: 'Authentication tag over header + IV + ciphertext (32 bytes)', + }); + return fields; + } + + // V2 (or Latest/0 which resolves to V2): XChaCha20-Poly1305 + if (subtype === 2) { + // Asymmetric: EphemeralPubKey(32) + Nonce(24) + Ciphertext+Tag(var) + if (payload.length < 56) { + fields.push({ + name: 'Error', + offset: abs(0), + size: payload.length, + hex: toHex(payload), + description: `Payload too short for V2 asymmetric ciphertext (min 56 bytes, got ${payload.length})`, + }); + return fields; + } + const ephKey = payload.slice(0, 32); + const nonce = payload.slice(32, 56); + const ctWithTag = payload.slice(56); + const ct = ctWithTag.length > 16 ? ctWithTag.slice(0, ctWithTag.length - 16) : new Uint8Array(0); + const tag = ctWithTag.slice(Math.max(0, ctWithTag.length - 16)); + fields.push({ + name: 'Ephemeral Public Key', + offset: abs(0), + size: 32, + hex: toHex(ephKey), + description: 'x25519 ephemeral public key for ECDH key derivation (32 bytes)', + }); + fields.push({ + name: 'Nonce', + offset: abs(32), + size: 24, + hex: toHex(nonce), + description: 'XChaCha20-Poly1305 nonce (24 bytes)', + }); + fields.push({ + name: 'Ciphertext', + offset: abs(56), + size: ct.length, + hex: toHex(ct, 32), + description: `XChaCha20 encrypted data (${ct.length} bytes)`, + }); + fields.push({ + name: 'Auth Tag (Poly1305)', + offset: abs(56 + ct.length), + size: 16, + hex: toHex(tag), + description: 'Poly1305 AEAD authentication tag (16 bytes)', + }); + } else { + // Symmetric (subtype None=0 or Symmetric=1): Nonce(24) + Ciphertext+Tag(var) + if (payload.length < 24) { + fields.push({ + name: 'Error', + offset: abs(0), + size: payload.length, + hex: toHex(payload), + description: `Payload too short for V2 symmetric ciphertext (min 24 bytes, got ${payload.length})`, + }); + return fields; + } + const nonce = payload.slice(0, 24); + const ctWithTag = payload.slice(24); + const ct = ctWithTag.length > 16 ? ctWithTag.slice(0, ctWithTag.length - 16) : new Uint8Array(0); + const tag = ctWithTag.slice(Math.max(0, ctWithTag.length - 16)); + fields.push({ + name: 'Nonce', + offset: abs(0), + size: 24, + hex: toHex(nonce), + description: 'XChaCha20-Poly1305 nonce (24 bytes)', + }); + fields.push({ + name: 'Ciphertext', + offset: abs(24), + size: ct.length, + hex: toHex(ct, 32), + description: `XChaCha20 encrypted data (${ct.length} bytes)`, + }); + fields.push({ + name: 'Auth Tag (Poly1305)', + offset: abs(24 + ct.length), + size: 16, + hex: toHex(tag), + description: 'Poly1305 AEAD authentication tag (16 bytes)', + }); + } + + return fields; + } + + private parseKeyPayload( + payload: Uint8Array, + subtype: number, + abs: (n: number) => number + ): PayloadField[] { + const label = + subtype === 1 ? 'Private Key' : + subtype === 2 ? 'Public Key' : + subtype === 3 ? 'Key Pair' : + subtype === 4 ? 'Secret Key' : + 'Key'; + return [ + { + name: `${label} Bytes`, + offset: abs(0), + size: payload.length, + hex: toHex(payload, 32), + description: `${label} raw bytes (${payload.length} bytes)`, + }, + ]; + } + + private parsePasswordHashPayload( + payload: Uint8Array, + abs: (n: number) => number + ): PayloadField[] { + return [ + { + name: 'Hash Data', + offset: abs(0), + size: payload.length, + hex: toHex(payload, 32), + description: `Password hash payload (${payload.length} bytes)`, + }, + ]; + } + + private parseSharePayload( + payload: Uint8Array, + abs: (n: number) => number + ): PayloadField[] { + return [ + { + name: 'Share Data', + offset: abs(0), + size: payload.length, + hex: toHex(payload, 32), + description: `Secret share payload (${payload.length} bytes)`, + }, + ]; + } + + private errorResult(totalBytes: number, message: string): ParseResult { + return { + signatureHex: '', + signatureValid: false, + dataType: 0, + dataTypeName: '', + subtype: 0, + subtypeName: '', + version: 0, + versionName: '', + totalBytes, + payloadBytes: 0, + payloadFields: [], + error: message, + }; + } +}