@aokiapp/tlv
Version:
Tag-Length-Value (TLV) parser and builder library with schema support. Provides both parsing and building APIs as submodules.
386 lines (385 loc) • 17.8 kB
JavaScript
import { inferIsSetFromTag } from "../common/index.js";
import { TagClass } from "../common/types.js";
import { BasicTLVParser } from "./basic-parser.js";
/**
* A parser that parses TLV data based on a given schema.
* Provides synchronous parse operations.
*/
// Consumes TLV buffers and produces structured data following the schema layout.
export class SchemaParser {
schema;
strict;
depthCounter = 0;
maxDepth;
constructor(schema, options) {
this.schema = schema;
this.strict = options?.strict ?? true;
this.maxDepth = options?.maxDepth ?? 100;
this.depthCounter = 0;
}
// Parses the TLV buffer and returns schema-typed data.
parse(buffer) {
// Reset depth counter per top-level parse invocation
this.depthCounter = 0;
return this.parseTopLevel(this.schema, buffer);
}
parseTopLevel(schema, buffer) {
if (this.isConstructed(schema)) {
return this.parseConstructed(schema, buffer);
}
if (this.isRepeated(schema)) {
// Top-level repeated has no tag to wrap items; disallow to keep TLV well-formed.
throw new Error(`Top-level repeated schema '${schema.name}' is not supported. Wrap it in a constructed container.`);
}
return this.parsePrimitive(schema, buffer);
}
parsePrimitive(schema, buffer) {
this.ensureDepth();
try {
const tlv = BasicTLVParser.parse(buffer);
if (tlv.tag.tagClass !== schema.tagClass ||
tlv.tag.tagNumber !== schema.tagNumber ||
tlv.tag.constructed) {
throw new Error(`TLV tag mismatch for primitive '${schema.name}' (expected class=${schema.tagClass} number=${schema.tagNumber} constructed=false; found class=${tlv.tag.tagClass} number=${tlv.tag.tagNumber} constructed=${tlv.tag.constructed})`);
}
// Enforce full buffer consumption at top-level when strict
if (this.strict && tlv.endOffset !== buffer.byteLength) {
throw new Error(`Unexpected trailing bytes after TLV at offset ${tlv.endOffset} (buffer length ${buffer.byteLength}) for primitive '${schema.name}'`);
}
return schema.decode(tlv.value);
}
finally {
this.depthCounter--;
}
}
parseConstructed(schema, buffer) {
this.ensureDepth();
try {
const outer = BasicTLVParser.parse(buffer);
if (outer.tag.tagClass !== schema.tagClass ||
outer.tag.tagNumber !== schema.tagNumber ||
!outer.tag.constructed) {
throw new Error(`Container tag mismatch for constructed '${schema.name}' (expected class=${schema.tagClass} number=${schema.tagNumber} constructed=true; found class=${outer.tag.tagClass} number=${outer.tag.tagNumber} constructed=${outer.tag.constructed})`);
}
// Enforce full buffer consumption at top-level when strict
if (this.strict && outer.endOffset !== buffer.byteLength) {
throw new Error(`Unexpected trailing bytes after TLV at offset ${outer.endOffset} (buffer length ${buffer.byteLength}) for constructed '${schema.name}'`);
}
const inner = outer.value;
// If this constructed schema declares no child fields, accept any inner content without validation.
// This preserves placeholder containers like header.sender/recipient and certTemplate.subject used in examples.
if (schema.fields.length === 0) {
return {};
}
// Determine SET vs SEQUENCE using explicit flag or tag inference (UNIVERSAL 17=SET, 16=SEQUENCE).
const treatAsSet = schema.isSet === true;
if (treatAsSet) {
return this.parseConstructedSet(schema, inner);
}
return this.parseConstructedSequence(schema, inner);
}
finally {
this.depthCounter--;
}
}
/**
* Strict, linear SEQUENCE matching (unchanged behavior).
* - Consumes children in schema order
* - Optional fields may be skipped
* - Repeated fields consume zero or more consecutive matching children
* - Any mismatch immediately fails (independent of 'strict')
* - No extra children are allowed
*/
parseConstructedSequence(schema, inner) {
const out = {};
// Pre-initialize arrays for repeated fields (always present)
for (const field of schema.fields) {
if (this.isRepeated(field)) {
out[field.name] = [];
}
}
let offset = 0;
let fIdx = 0;
while (fIdx < schema.fields.length) {
const field = schema.fields[fIdx];
// Repeated field: consume zero or more consecutive children
if (this.isRepeated(field)) {
const item = field.item;
const itemConstructed = this.isConstructed(item);
while (offset < inner.byteLength) {
const slice = inner.slice(offset);
const childTLV = BasicTLVParser.parse(slice);
if (childTLV.tag.tagClass === item.tagClass &&
childTLV.tag.tagNumber === item.tagNumber &&
childTLV.tag.constructed === itemConstructed) {
const childRaw = inner.slice(offset, offset + childTLV.endOffset);
const parsedItem = this.parseTopLevel(item, childRaw);
out[field.name].push(parsedItem);
offset += childTLV.endOffset;
}
else {
break;
}
}
fIdx++;
continue;
}
// Non-repeated field
if (offset >= inner.byteLength) {
// No more children; required field missing => fail immediately
if (!field.optional) {
throw new Error(`Missing required property '${field.name}' in constructed '${schema.name}'`);
}
// Optional: skip, proceed to next field
fIdx++;
continue;
}
const slice = inner.slice(offset);
const childTLV = BasicTLVParser.parse(slice);
if (this.isConstructed(field)) {
if (childTLV.tag.tagClass === field.tagClass &&
childTLV.tag.tagNumber === field.tagNumber &&
childTLV.tag.constructed === true) {
const childRaw = inner.slice(offset, offset + childTLV.endOffset);
const parsedChild = this.parseConstructed(field, childRaw);
out[field.name] = parsedChild;
offset += childTLV.endOffset;
fIdx++;
continue;
}
// Expected constructed field did not match
if (field.optional) {
// Skip the optional field (do not consume child), proceed to next schema field
fIdx++;
continue;
}
throw new Error(`Sequence order mismatch in constructed '${schema.name}': expected constructed field '${field.name}' tagClass=${field.tagClass} tagNumber=${field.tagNumber} but found tagClass=${childTLV.tag.tagClass} tagNumber=${childTLV.tag.tagNumber} constructed=${childTLV.tag.constructed}`);
}
// Primitive field
if (childTLV.tag.tagClass === field.tagClass &&
childTLV.tag.tagNumber === field.tagNumber &&
childTLV.tag.constructed === false) {
const childRaw = inner.slice(offset, offset + childTLV.endOffset);
const parsedValue = this.parsePrimitive(field, childRaw);
out[field.name] = parsedValue;
offset += childTLV.endOffset;
fIdx++;
continue;
}
// Expected primitive field did not match
if (field.optional) {
// Skip optional field (do not consume child)
fIdx++;
continue;
}
throw new Error(`Sequence order mismatch in constructed '${schema.name}': expected primitive field '${field.name}' tagClass=${field.tagClass} tagNumber=${field.tagNumber} but found tagClass=${childTLV.tag.tagClass} tagNumber=${childTLV.tag.tagNumber} constructed=${childTLV.tag.constructed}`);
}
// After consuming all schema fields, no extra children are allowed.
if (offset < inner.byteLength) {
const extraSlice = inner.slice(offset);
const extraTLV = BasicTLVParser.parse(extraSlice);
throw new Error(`Unexpected extra child TLV tagClass=${extraTLV.tag.tagClass} tagNumber=${extraTLV.tag.tagNumber} constructed=${extraTLV.tag.constructed} in constructed '${schema.name}'`);
}
// Presence check (strict mode) retained for compatibility; most missing required fields are already caught above.
if (this.strict) {
for (const field of schema.fields) {
const name = field.name;
if (this.isRepeated(field)) {
continue;
}
if (!field.optional && out[name] === undefined) {
throw new Error(`Missing required property '${name}' in constructed '${schema.name}'`);
}
}
}
return out;
}
/**
* SET parsing with order-independent matching and DER canonical validation when strict is true.
* - Collects all child TLVs (raw bytes + TLV)
* - Immediately fails on unknown children (independent of 'strict')
* - Non-repeated fields: matches at most one child based on tagClass, tagNumber, constructed
* - Repeated fields: collects all matching children (SET OF) in any order
* - Fails when a required non-repeated field is missing or leftover children remain
*/
parseConstructedSet(schema, inner) {
// Allow repeated fields (SET OF) in SET; handled below
// Collect children (raw + TLV)
const children = [];
{
let offset = 0;
while (offset < inner.byteLength) {
const slice = inner.slice(offset);
const childTLV = BasicTLVParser.parse(slice);
const childRaw = inner.slice(offset, offset + childTLV.endOffset);
children.push({ raw: childRaw, tlv: childTLV });
offset += childTLV.endOffset;
}
}
// Unknown child detection (independent of 'strict')
for (const c of children) {
const known = schema.fields.some((field) => this.matchesFieldTag(field, c.tlv.tag));
if (!known) {
throw new Error(`Unknown child TLV tagClass=${c.tlv.tag.tagClass} tagNumber=${c.tlv.tag.tagNumber} constructed=${c.tlv.tag.constructed} in SET '${schema.name}'`);
}
}
if (this.strict === true) {
for (let i = 1; i < children.length; i++) {
if (this.compareUnsignedLex(children[i - 1].raw, children[i].raw) > 0) {
throw new Error(`DER canonical order violation in SET '${schema.name}': element at index ${i - 1} should come after index ${i}`);
}
}
}
// Field matching:
// - Non-repeated fields: at most one matching child
// - Repeated fields: collect all matching children (SET OF)
const consumed = new Array(children.length).fill(false);
const out = {};
for (const field of schema.fields) {
if (this.isRepeated(field)) {
// Pre-initialize array result as in SEQUENCE behavior
out[field.name] = [];
const item = field.item;
for (let i = 0; i < children.length; i++) {
if (consumed[i])
continue;
const c = children[i];
if (this.matchesFieldTag(field, c.tlv.tag)) {
// Parse according to item schema
const parsedItem = this.parseTopLevel(item, c.raw);
out[field.name].push(parsedItem);
consumed[i] = true;
}
}
// For SET OF, allow empty arrays even when the repeated field is not
// marked optional. This aligns SET behavior with SEQUENCE, where
// repeated fields are always present as arrays and may legitimately be empty.
continue;
}
// Non-repeated field
let matchedIndex = -1;
for (let i = 0; i < children.length; i++) {
if (consumed[i])
continue;
const c = children[i];
if (this.matchesFieldTag(field, c.tlv.tag)) {
matchedIndex = i;
break;
}
}
if (matchedIndex === -1) {
if (!field.optional) {
throw new Error(`Missing required property '${field.name}' in SET '${schema.name}'`);
}
continue;
}
const child = children[matchedIndex];
const parsed = this.isConstructed(field)
? this.parseConstructed(field, child.raw)
: this.parsePrimitive(field, child.raw);
out[field.name] = parsed;
consumed[matchedIndex] = true;
}
// No leftover children permitted
const extraIdx = consumed.findIndex((v) => v === false);
if (extraIdx !== -1) {
const extraTag = children[extraIdx].tlv.tag;
throw new Error(`Unexpected extra child TLV tagClass=${extraTag.tagClass} tagNumber=${extraTag.tagNumber} constructed=${extraTag.constructed} in SET '${schema.name}'`);
}
return out;
}
// Tag match utility for fields vs TLV child
matchesFieldTag(field, tag) {
// If field is repeated, compare against the item's tag
if (this.isRepeated(field)) {
const item = field.item;
const itemClass = item.tagClass ?? TagClass.Universal;
const itemNumber = item.tagNumber;
if (typeof itemNumber !== "number")
return false;
const itemConstructed = this.isConstructed(item);
return (tag.tagClass === itemClass &&
tag.tagNumber === itemNumber &&
tag.constructed === itemConstructed);
}
const fieldClass = field.tagClass ?? TagClass.Universal;
const fieldNumber = field.tagNumber;
if (typeof fieldNumber !== "number") {
return false;
}
const fieldConstructed = this.isConstructed(field);
return (tag.tagClass === fieldClass &&
tag.tagNumber === fieldNumber &&
tag.constructed === fieldConstructed);
}
// Unsigned lexicographic comparator for raw DER bytes (a < b => negative, a > b => positive)
compareUnsignedLex(a, b) {
const ua = new Uint8Array(a);
const ub = new Uint8Array(b);
const len = Math.min(ua.length, ub.length);
for (let i = 0; i < len; i++) {
if (ua[i] !== ub[i])
return ua[i] - ub[i];
}
return ua.length - ub.length;
}
// Depth guard to prevent stack overflows and pathological nested inputs
ensureDepth() {
if (this.depthCounter >= this.maxDepth) {
throw new Error(`Maximum parsing depth exceeded: ${this.maxDepth}`);
}
this.depthCounter++;
}
isConstructed(schema) {
return Object.prototype.hasOwnProperty.call(schema, "fields");
}
isRepeated(schema) {
return Object.prototype.hasOwnProperty.call(schema, "item");
}
}
/**
* Utility class for creating new TLV schemas (identical to builder schemas).
*/
// Convenience factory for constructing schema descriptors consumed by the parser.
export class Schema {
static primitive(name, options, decode) {
const tagNumber = options.tagNumber;
if (typeof tagNumber !== "number") {
throw new Error(`Primitive schema '${name}' requires tagNumber`);
}
const obj = {
name,
decode,
tagClass: options?.tagClass ?? TagClass.Universal,
tagNumber,
optional: options?.optional ? true : false,
};
return obj;
}
static constructed(name, options, fields) {
const tagClassNormalized = options?.tagClass ?? TagClass.Universal;
const inferredIsSet = options?.isSet !== undefined
? options.isSet
: inferIsSetFromTag(tagClassNormalized, options?.tagNumber);
const inferredTagNumber = inferredIsSet ? 17 : 16;
const obj = {
name,
fields,
tagClass: tagClassNormalized,
tagNumber: options?.tagNumber ?? inferredTagNumber,
optional: options?.optional ? true : false,
isSet: inferredIsSet,
};
return obj;
}
static repeated(name, options, item) {
const obj = {
name,
item,
tagClass: options?.tagClass ?? TagClass.Universal,
tagNumber: options?.tagNumber,
optional: options?.optional ? true : false,
};
return obj;
}
}