diff --git a/src/core/operations/ParseTR34B9Envelope.mjs b/src/core/operations/ParseTR34B9Envelope.mjs index 8e3ee1d7..1843b000 100644 --- a/src/core/operations/ParseTR34B9Envelope.mjs +++ b/src/core/operations/ParseTR34B9Envelope.mjs @@ -6,58 +6,118 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; -/** - * Parses an ASN.1 TLV length field at the given offset. - * - * @param {Uint8Array} bytes - * @param {number} offset - * @returns {{headerLength: number, valueLength: number}} - */ +// ── TR-34 message type table ────────────────────────────────────────────────── + +const TR34_MESSAGE_TYPES = { + B0: "GetCredential — KRD requests KDH credentials", + B1: "KeyCertificate — KDH distributes its certificate", + B2: "GetData — KDH requests data from KRD", + B3: "ReceiveCert — KRD acknowledges certificate receipt", + B4: "BindBegin — KDH initiates key transport binding", + B5: "BindRequest — KRD sends public key / OWHF to KDH", + B6: "BindResponse — KDH sends certificate binding confirmation", + B8: "KeyToken — encrypted key transport object (CMS EnvelopedData)", + B9: "BindResponse — final key delivery; contains CMS EnvelopedData + signature", +}; + +const TR34_ERROR_CODES = { + "00": "Success", + "01": "General failure", + "02": "Invalid message format", + "03": "Unsupported message type", + "04": "Certificate not found", + "05": "Invalid signature", + "06": "Decryption failure", + "07": "Invalid key usage", + "08": "KDH not authorized", + "09": "KRD not authorized", + "10": "Message replay detected", + "11": "Key already loaded", + "12": "Invalid random number", + "FF": "Unspecified error", +}; + +// ── ASN.1 helpers ───────────────────────────────────────────────────────────── + function parseAsnLength(bytes, offset) { - if (offset + 2 > bytes.length) { + if (offset + 2 > bytes.length) throw new OperationError("Insufficient ASN.1 data."); - } const first = bytes[offset + 1]; - if ((first & 0x80) === 0) { + if ((first & 0x80) === 0) return { headerLength: 2, valueLength: first }; - } const lengthOfLength = first & 0x7f; - if (offset + 2 + lengthOfLength > bytes.length) { + if (offset + 2 + lengthOfLength > bytes.length) throw new OperationError("Invalid ASN.1 length field."); - } let valueLength = 0; - for (let i = 0; i < lengthOfLength; i++) { + for (let i = 0; i < lengthOfLength; i++) valueLength = (valueLength << 8) | bytes[offset + 2 + i]; - } return { headerLength: 2 + lengthOfLength, valueLength }; } +/** Best-effort parse of the outermost ASN.1 SEQUENCE tag in a byte array. */ +function peekAsnSequence(bytes) { + if (!bytes || bytes.length < 2) return null; + if (bytes[0] !== 0x30) return null; // not SEQUENCE + try { + const meta = parseAsnLength(bytes, 0); + return { + tag: "0x30 (SEQUENCE)", + headerBytes: meta.headerLength, + valueLength: meta.valueLength, + totalExpected: meta.headerLength + meta.valueLength, + complete: (meta.headerLength + meta.valueLength) === bytes.length, + }; + } catch (_) { + return null; + } +} + +function hexStr(bytes) { + return Array.from(bytes, b => b.toString(16).padStart(2, "0").toUpperCase()).join(""); +} + +// ── Operation ───────────────────────────────────────────────────────────────── + /** - * Parse TR-34 B9 envelope operation + * Parse TR-34 key transport message operation. */ class ParseTR34B9Envelope extends Operation { - /** - * ParseTR34B9Envelope constructor - */ constructor() { super(); - this.name = "Parse TR-34 B9 envelope"; + this.name = "Parse TR-34 key transport"; this.module = "Payment"; - this.description = "Paste the full B9 response frame into the input field as hex.

Input: complete TR-34 B9 response encoded as hex, including the leading length field.

This operation splits the response into header, response code, authentication data, KCV, envelope data, signature length, signature, and any trailing bytes."; - this.inlineHelp = "Input: full B9 response frame as hex, including the 2-byte length field.
Args: none."; + this.description = [ + "Parses a TR-34 key transport message frame (hex input) and decodes each section.", + "

", + "Input: Complete TR-34 message frame encoded as hex, including the 2-byte length prefix.", + "

", + "TR-34 defines a family of messages (B0–B9) for transporting symmetric keys using RSA.", + " This operation auto-detects the message type and labels each field accordingly.", + " The Envelope Data section is a CMS EnvelopedData (ASN.1 SEQUENCE) —", + " the outer tag and length are shown; full CMS parsing is not performed here.", + "

", + "Key transport flow: KDH (Key Distribution Host) wraps a symmetric key under the", + " KRD's RSA public key and signs the result. KRD verifies the signature, then decrypts.", + "

", + "References: ANS X9.143, ANSI TR-34, PCI PIN v3.1 Req 18-4.", + ].join(""); + + this.inlineHelp = "Input: full TR-34 message frame as hex, including the 2-byte length prefix."; + this.testDataSamples = [ { name: "Synthetic B9 parser sample", input: "001730303030423930303100112233300030303034AABBCCDD", - args: [] - } + args: [], + }, ]; + this.infoURL = "https://en.wikipedia.org/wiki/Key_block"; this.inputType = "string"; this.outputType = "string"; @@ -70,69 +130,91 @@ class ParseTR34B9Envelope extends Operation { */ run(input) { const hex = (input || "").replace(/\s+/g, ""); - if (!hex.length) { - throw new OperationError("No input."); - } - if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) { + + if (!hex.length) throw new OperationError("No input."); + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) throw new OperationError("Input must be hex."); - } const bytes = new Uint8Array(hex.match(/.{2}/g).map(h => parseInt(h, 16))); - if (bytes.length < 12) { - throw new OperationError("Input too short."); - } + if (bytes.length < 12) throw new OperationError("Input too short for a TR-34 frame."); + const notes = []; + + // ── Outer frame ───────────────────────────────────────────────────── const declaredLength = (bytes[0] << 8) | bytes[1]; let offset = 2; - const header = String.fromCharCode(...bytes.slice(offset, offset + 4)); - offset += 4; + const header = String.fromCharCode(...bytes.slice(offset, offset + 4)); offset += 4; + const responseType = String.fromCharCode(...bytes.slice(offset, offset + 2)); offset += 2; + const errorCode = String.fromCharCode(...bytes.slice(offset, offset + 2)); offset += 2; - const responseType = String.fromCharCode(...bytes.slice(offset, offset + 2)); - offset += 2; + const msgDesc = TR34_MESSAGE_TYPES[responseType] || "Unknown message type"; + const errDesc = TR34_ERROR_CODES[errorCode] || "Unknown error code"; - const errorCode = String.fromCharCode(...bytes.slice(offset, offset + 2)); - offset += 2; - - const authLenMeta = parseAsnLength(bytes, offset); - const authTotalLen = authLenMeta.headerLength + authLenMeta.valueLength; - const authData = bytes.slice(offset, offset + authTotalLen); - offset += authTotalLen; - - const kcv = bytes.slice(offset, offset + 3); - offset += 3; - - const envLenMeta = parseAsnLength(bytes, offset); - const envTotalLen = envLenMeta.headerLength + envLenMeta.valueLength; - const envelopeData = bytes.slice(offset, offset + envTotalLen); - offset += envTotalLen; - - const signatureLengthAscii = String.fromCharCode(...bytes.slice(offset, offset + 4)); - offset += 4; - const signatureLength = parseInt(signatureLengthAscii, 10); - const signature = Number.isFinite(signatureLength) ? bytes.slice(offset, offset + signatureLength) : new Uint8Array(); - if (Number.isFinite(signatureLength)) { - offset += signatureLength; + if (errorCode !== "00") { + notes.push(`Non-zero error code: ${errorCode} — ${errDesc}`); } + // ── Authentication data (ASN.1 variable length) ────────────────────── + const authLenMeta = parseAsnLength(bytes, offset); + const authTotalLen = authLenMeta.headerLength + authLenMeta.valueLength; + const authData = bytes.slice(offset, offset + authTotalLen); + const authAsn = peekAsnSequence(authData); + offset += authTotalLen; + + // ── KCV (3 bytes) ──────────────────────────────────────────────────── + const kcv = bytes.slice(offset, offset + 3); offset += 3; + + // ── Envelope data (CMS EnvelopedData, ASN.1 variable length) ───────── + const envLenMeta = parseAsnLength(bytes, offset); + const envTotalLen = envLenMeta.headerLength + envLenMeta.valueLength; + const envelopeData = bytes.slice(offset, offset + envTotalLen); + const envAsn = peekAsnSequence(envelopeData); + offset += envTotalLen; + + // ── Signature length (4-byte ASCII decimal) ────────────────────────── + const sigLenAscii = String.fromCharCode(...bytes.slice(offset, offset + 4)); offset += 4; + const sigLength = parseInt(sigLenAscii, 10); + const signature = Number.isFinite(sigLength) ? bytes.slice(offset, offset + sigLength) : new Uint8Array(); + if (Number.isFinite(sigLength)) offset += sigLength; + + // ── Build output ───────────────────────────────────────────────────── const out = { declaredLength, actualLengthExcludingLengthField: bytes.length - 2, header, - responseType, + messageType: responseType, + messageDescription: msgDesc, errorCode, - authDataHex: Buffer.from(authData).toString("hex").toUpperCase(), - kcvHex: Buffer.from(kcv).toString("hex").toUpperCase(), - envelopeDataHex: Buffer.from(envelopeData).toString("hex").toUpperCase(), - signatureLengthAscii, - signatureLength: Number.isFinite(signatureLength) ? signatureLength : null, - signatureHex: Buffer.from(signature).toString("hex").toUpperCase(), - trailingHex: Buffer.from(bytes.slice(offset)).toString("hex").toUpperCase() + errorDescription: errDesc, + authData: { + hex: hexStr(authData), + byteCount: authData.length, + asnOuter: authAsn, + }, + kcvHex: hexStr(kcv), + envelopeData: { + hex: hexStr(envelopeData), + byteCount: envelopeData.length, + description: "CMS EnvelopedData — wrapped symmetric key (decrypt with KRD private RSA key)", + asnOuter: envAsn, + }, + signatureLengthAscii: sigLenAscii, + signatureLength: Number.isFinite(sigLength) ? sigLength : null, + signatureHex: hexStr(signature), + trailingHex: hexStr(bytes.slice(offset)), + notes, }; + if (out.declaredLength !== bytes.length - 2) { + out.notes.push( + `Declared length ${out.declaredLength} does not match ` + + `actual payload length ${bytes.length - 2}.` + ); + } + return JSON.stringify(out, null, 4); } - } export default ParseTR34B9Envelope;