Broaden ParseTR34B9Envelope to TR-34 key transport: message type table, error codes, ASN.1 envelope peek
This commit is contained in:
parent
cf3023bc82
commit
dd7a26aa1f
@ -6,58 +6,118 @@
|
|||||||
import Operation from "../Operation.mjs";
|
import Operation from "../Operation.mjs";
|
||||||
import OperationError from "../errors/OperationError.mjs";
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
|
||||||
/**
|
// ── TR-34 message type table ──────────────────────────────────────────────────
|
||||||
* Parses an ASN.1 TLV length field at the given offset.
|
|
||||||
*
|
const TR34_MESSAGE_TYPES = {
|
||||||
* @param {Uint8Array} bytes
|
B0: "GetCredential — KRD requests KDH credentials",
|
||||||
* @param {number} offset
|
B1: "KeyCertificate — KDH distributes its certificate",
|
||||||
* @returns {{headerLength: number, valueLength: number}}
|
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) {
|
function parseAsnLength(bytes, offset) {
|
||||||
if (offset + 2 > bytes.length) {
|
if (offset + 2 > bytes.length)
|
||||||
throw new OperationError("Insufficient ASN.1 data.");
|
throw new OperationError("Insufficient ASN.1 data.");
|
||||||
}
|
|
||||||
|
|
||||||
const first = bytes[offset + 1];
|
const first = bytes[offset + 1];
|
||||||
if ((first & 0x80) === 0) {
|
if ((first & 0x80) === 0)
|
||||||
return { headerLength: 2, valueLength: first };
|
return { headerLength: 2, valueLength: first };
|
||||||
}
|
|
||||||
|
|
||||||
const lengthOfLength = first & 0x7f;
|
const lengthOfLength = first & 0x7f;
|
||||||
if (offset + 2 + lengthOfLength > bytes.length) {
|
if (offset + 2 + lengthOfLength > bytes.length)
|
||||||
throw new OperationError("Invalid ASN.1 length field.");
|
throw new OperationError("Invalid ASN.1 length field.");
|
||||||
}
|
|
||||||
|
|
||||||
let valueLength = 0;
|
let valueLength = 0;
|
||||||
for (let i = 0; i < lengthOfLength; i++) {
|
for (let i = 0; i < lengthOfLength; i++)
|
||||||
valueLength = (valueLength << 8) | bytes[offset + 2 + i];
|
valueLength = (valueLength << 8) | bytes[offset + 2 + i];
|
||||||
}
|
|
||||||
|
|
||||||
return { headerLength: 2 + lengthOfLength, valueLength };
|
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 {
|
class ParseTR34B9Envelope extends Operation {
|
||||||
|
|
||||||
/**
|
|
||||||
* ParseTR34B9Envelope constructor
|
|
||||||
*/
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.name = "Parse TR-34 B9 envelope";
|
this.name = "Parse TR-34 key transport";
|
||||||
this.module = "Payment";
|
this.module = "Payment";
|
||||||
this.description = "Paste the full B9 response frame into the input field as hex.<br><br><b>Input:</b> complete TR-34 B9 response encoded as hex, including the leading length field.<br><br>This operation splits the response into header, response code, authentication data, KCV, envelope data, signature length, signature, and any trailing bytes.";
|
this.description = [
|
||||||
this.inlineHelp = "<strong>Input:</strong> full B9 response frame as hex, including the 2-byte length field.<br><strong>Args:</strong> none.";
|
"Parses a <b>TR-34</b> key transport message frame (hex input) and decodes each section.",
|
||||||
|
"<br><br>",
|
||||||
|
"<b>Input:</b> Complete TR-34 message frame encoded as hex, including the 2-byte length prefix.",
|
||||||
|
"<br><br>",
|
||||||
|
"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 <b>Envelope Data</b> section is a CMS <code>EnvelopedData</code> (ASN.1 SEQUENCE) —",
|
||||||
|
" the outer tag and length are shown; full CMS parsing is not performed here.",
|
||||||
|
"<br><br>",
|
||||||
|
"<b>Key transport flow:</b> 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.",
|
||||||
|
"<br><br>",
|
||||||
|
"<b>References:</b> ANS X9.143, ANSI TR-34, PCI PIN v3.1 Req 18-4.",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> full TR-34 message frame as hex, including the 2-byte length prefix.";
|
||||||
|
|
||||||
this.testDataSamples = [
|
this.testDataSamples = [
|
||||||
{
|
{
|
||||||
name: "Synthetic B9 parser sample",
|
name: "Synthetic B9 parser sample",
|
||||||
input: "001730303030423930303100112233300030303034AABBCCDD",
|
input: "001730303030423930303100112233300030303034AABBCCDD",
|
||||||
args: []
|
args: [],
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
this.infoURL = "https://en.wikipedia.org/wiki/Key_block";
|
this.infoURL = "https://en.wikipedia.org/wiki/Key_block";
|
||||||
this.inputType = "string";
|
this.inputType = "string";
|
||||||
this.outputType = "string";
|
this.outputType = "string";
|
||||||
@ -70,69 +130,91 @@ class ParseTR34B9Envelope extends Operation {
|
|||||||
*/
|
*/
|
||||||
run(input) {
|
run(input) {
|
||||||
const hex = (input || "").replace(/\s+/g, "");
|
const hex = (input || "").replace(/\s+/g, "");
|
||||||
if (!hex.length) {
|
|
||||||
throw new OperationError("No input.");
|
if (!hex.length) throw new OperationError("No input.");
|
||||||
}
|
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
|
||||||
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
|
|
||||||
throw new OperationError("Input must be hex.");
|
throw new OperationError("Input must be hex.");
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = new Uint8Array(hex.match(/.{2}/g).map(h => parseInt(h, 16)));
|
const bytes = new Uint8Array(hex.match(/.{2}/g).map(h => parseInt(h, 16)));
|
||||||
if (bytes.length < 12) {
|
if (bytes.length < 12) throw new OperationError("Input too short for a TR-34 frame.");
|
||||||
throw new OperationError("Input too short.");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const notes = [];
|
||||||
|
|
||||||
|
// ── Outer frame ─────────────────────────────────────────────────────
|
||||||
const declaredLength = (bytes[0] << 8) | bytes[1];
|
const declaredLength = (bytes[0] << 8) | bytes[1];
|
||||||
let offset = 2;
|
let offset = 2;
|
||||||
|
|
||||||
const header = String.fromCharCode(...bytes.slice(offset, offset + 4));
|
const header = String.fromCharCode(...bytes.slice(offset, offset + 4)); 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));
|
const msgDesc = TR34_MESSAGE_TYPES[responseType] || "Unknown message type";
|
||||||
offset += 2;
|
const errDesc = TR34_ERROR_CODES[errorCode] || "Unknown error code";
|
||||||
|
|
||||||
const errorCode = String.fromCharCode(...bytes.slice(offset, offset + 2));
|
if (errorCode !== "00") {
|
||||||
offset += 2;
|
notes.push(`Non-zero error code: ${errorCode} — ${errDesc}`);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 = {
|
const out = {
|
||||||
declaredLength,
|
declaredLength,
|
||||||
actualLengthExcludingLengthField: bytes.length - 2,
|
actualLengthExcludingLengthField: bytes.length - 2,
|
||||||
header,
|
header,
|
||||||
responseType,
|
messageType: responseType,
|
||||||
|
messageDescription: msgDesc,
|
||||||
errorCode,
|
errorCode,
|
||||||
authDataHex: Buffer.from(authData).toString("hex").toUpperCase(),
|
errorDescription: errDesc,
|
||||||
kcvHex: Buffer.from(kcv).toString("hex").toUpperCase(),
|
authData: {
|
||||||
envelopeDataHex: Buffer.from(envelopeData).toString("hex").toUpperCase(),
|
hex: hexStr(authData),
|
||||||
signatureLengthAscii,
|
byteCount: authData.length,
|
||||||
signatureLength: Number.isFinite(signatureLength) ? signatureLength : null,
|
asnOuter: authAsn,
|
||||||
signatureHex: Buffer.from(signature).toString("hex").toUpperCase(),
|
},
|
||||||
trailingHex: Buffer.from(bytes.slice(offset)).toString("hex").toUpperCase()
|
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);
|
return JSON.stringify(out, null, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ParseTR34B9Envelope;
|
export default ParseTR34B9Envelope;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user