@lapo/asn1js
Version:
Generic ASN.1 parser/decoder that can decode any valid ASN.1 DER or BER structures.
656 lines (644 loc) • 26.3 kB
JavaScript
// ASN.1 JavaScript decoder
// Copyright (c) 2008 Lapo Luchini <lapo@lapo.it>
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
import { Int10 } from './int10.js';
import { oids } from './oids.js';
const
ellipsis = '\u2026',
reTimeS = /^(\d\d)(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([01]\d|2[0-3])(?:([0-5]\d)(?:([0-5]\d)(?:[.,](\d{1,3}))?)?)?(Z|(-(?:0\d|1[0-2])|[+](?:0\d|1[0-4]))([0-5]\d)?)?$/,
reTimeL = /^(\d\d\d\d)(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([01]\d|2[0-3])(?:([0-5]\d)(?:([0-5]\d)(?:[.,](\d{1,3}))?)?)?(Z|(-(?:0\d|1[0-2])|[+](?:0\d|1[0-4]))([0-5]\d)?)?$/,
hexDigits = '0123456789ABCDEF',
b64Std = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/',
b64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
tableT61 = [
['', ''],
['AEIOUaeiou', 'ÀÈÌÒÙàèìòù'], // Grave
['ACEILNORSUYZacegilnorsuyz', 'ÁĆÉÍĹŃÓŔŚÚÝŹáćéģíĺńóŕśúýź'], // Acute
['ACEGHIJOSUWYaceghijosuwy', 'ÂĈÊĜĤÎĴÔŜÛŴŶâĉêĝĥîĵôŝûŵŷ'], // Circumflex
['AINOUainou', 'ÃĨÑÕŨãĩñõũ'], // Tilde
['AEIOUaeiou', 'ĀĒĪŌŪāēīōū'], // Macron
['AGUagu', 'ĂĞŬăğŭ'], // Breve
['CEGIZcegz', 'ĊĖĠİŻċėġż'], // Dot
['AEIOUYaeiouy', 'ÄËÏÖÜŸäëïöüÿ'], // Umlaut or diæresis
['', ''],
['AUau', 'ÅŮåů'], // Ring
['CGKLNRSTcklnrst', 'ÇĢĶĻŅŖŞŢçķļņŗşţ'], // Cedilla
['', ''],
['OUou', 'ŐŰőű'], // Double Acute
['AEIUaeiu', 'ĄĘĮŲąęįų'], // Ogonek
['CDELNRSTZcdelnrstz', 'ČĎĚĽŇŘŠŤŽčďěľňřšťž'], // Caron
];
function stringCut(str, len) {
if (str.length > len)
str = str.substring(0, len) + ellipsis;
return str;
}
function checkPrintable(s) {
let i, v;
for (i = 0; i < s.length; ++i) {
v = s.charCodeAt(i);
if (v < 32 && v != 9 && v != 10 && v != 13) // [\t\r\n] are (kinda) printable
throw new Error('Unprintable character at index ' + i + ' (code ' + s.str.charCodeAt(i) + ')');
}
}
/** Class to manage a stream of bytes, with a zero-copy approach.
* It uses an existing array or binary string and advances a position index. */
export class Stream {
/**
* @param {Stream|array|string} enc data (will not be copied)
* @param {?number} pos starting position (mandatory when `end` is not a Stream)
*/
constructor(enc, pos) {
if (enc instanceof Stream) {
this.enc = enc.enc;
this.pos = enc.pos;
} else {
this.enc = enc;
this.pos = pos;
}
if (typeof this.pos != 'number')
throw new Error('"pos" must be a numeric value');
if (typeof this.enc == 'string')
this.getRaw = pos => this.enc.charCodeAt(pos);
else if (typeof this.enc[0] == 'number')
this.getRaw = pos => this.enc[pos];
else
throw new Error('"enc" must be a numeric array or a string');
}
/** Get the byte at current position (and increment it) or at a specified position (and avoid moving current position).
* @param {?number} pos read position if specified, else current position (and increment it) */
get(pos) {
if (pos === undefined)
pos = this.pos++;
if (pos >= this.enc.length)
throw new Error('Requesting byte offset ' + pos + ' on a stream of length ' + this.enc.length);
return this.getRaw(pos);
}
/** Convert a single byte to an hexadcimal string (of length 2).
* @param {number} b */
static hexByte(b) {
return hexDigits.charAt((b >> 4) & 0xF) + hexDigits.charAt(b & 0xF);
}
/** Hexadecimal dump of a specified region of the stream.
* @param {number} start starting position (included)
* @param {number} end ending position (excluded)
* @param {string} type 'raw', 'byte' or 'dump' (default) */
hexDump(start, end, type = 'dump') {
let s = '';
for (let i = start; i < end; ++i) {
if (type == 'byte' && i > start)
s += ' ';
s += Stream.hexByte(this.get(i));
if (type == 'dump')
switch (i & 0xF) {
case 0x7: s += ' '; break;
case 0xF: s += '\n'; break;
default: s += ' ';
}
}
return s;
}
/** Base64url dump of a specified region of the stream (according to RFC 4648 section 5).
* @param {number} start starting position (included)
* @param {number} end ending position (excluded)
* @param {string} type 'url' (default, section 5 without padding) or 'std' (section 4 with padding) */
b64Dump(start, end, type = 'url') {
const b64 = type === 'url' ? b64URL : b64Std;
let extra = (end - start) % 3,
s = '',
i, c;
for (i = start; i + 2 < end; i += 3) {
c = this.get(i) << 16 | this.get(i + 1) << 8 | this.get(i + 2);
s += b64.charAt(c >> 18 & 0x3F);
s += b64.charAt(c >> 12 & 0x3F);
s += b64.charAt(c >> 6 & 0x3F);
s += b64.charAt(c & 0x3F);
}
if (extra > 0) {
c = this.get(i) << 16;
if (extra > 1) c |= this.get(i + 1) << 8;
s += b64.charAt(c >> 18 & 0x3F);
s += b64.charAt(c >> 12 & 0x3F);
if (extra == 2) s += b64.charAt(c >> 6 & 0x3F);
if (b64 === b64Std) s += '==='.slice(0, 3 - extra);
}
return s;
}
isASCII(start, end) {
for (let i = start; i < end; ++i) {
let c = this.get(i);
if (c < 32 || c > 176)
return false;
}
return true;
}
parseStringISO(start, end, maxLength) {
let s = '';
for (let i = start; i < end; ++i)
s += String.fromCharCode(this.get(i));
return { size: s.length, str: stringCut(s, maxLength) };
}
parseStringT61(start, end, maxLength) {
// warning: this code is not very well tested so far
function merge(c, d) {
let t = tableT61[c - 0xC0];
let i = t[0].indexOf(String.fromCharCode(d));
return (i < 0) ? '\0' : t[1].charAt(i);
}
let s = '', c;
for (let i = start; i < end; ++i) {
c = this.get(i);
if (c >= 0xA4 && c <= 0xBF)
s += '$¥#§¤\0\0«\0\0\0\0°±²³×µ¶·÷\0\0»¼½¾¿'.charAt(c - 0xA4);
else if (c >= 0xE0 && c <= 0xFF)
s += 'ΩÆÐªĦ\0IJĿŁØŒºÞŦŊʼnĸæđðħıijŀłøœßþŧŋ\0'.charAt(c - 0xE0);
else if (c >= 0xC0 && c <= 0xCF)
s += merge(c, this.get(++i));
else // using ISO 8859-1 for characters undefined (or equal) in T.61
s += String.fromCharCode(c);
}
return { size: s.length, str: stringCut(s, maxLength) };
}
parseStringUTF(start, end, maxLength) {
function ex(c) { // must be 10xxxxxx
if ((c < 0x80) || (c >= 0xC0))
throw new Error('Invalid UTF-8 continuation byte: ' + c);
return (c & 0x3F);
}
function surrogate(cp) {
if (cp < 0x10000)
throw new Error('UTF-8 overlong encoding, codepoint encoded in 4 bytes: ' + cp);
// we could use String.fromCodePoint(cp) but let's be nice to older browsers and use surrogate pairs
cp -= 0x10000;
return String.fromCharCode((cp >> 10) + 0xD800, (cp & 0x3FF) + 0xDC00);
}
let s = '';
for (let i = start; i < end; ) {
let c = this.get(i++);
if (c < 0x80) // 0xxxxxxx (7 bit)
s += String.fromCharCode(c);
else if (c < 0xC0)
throw new Error('Invalid UTF-8 starting byte: ' + c);
else if (c < 0xE0) // 110xxxxx 10xxxxxx (11 bit)
s += String.fromCharCode(((c & 0x1F) << 6) | ex(this.get(i++)));
else if (c < 0xF0) // 1110xxxx 10xxxxxx 10xxxxxx (16 bit)
s += String.fromCharCode(((c & 0x0F) << 12) | (ex(this.get(i++)) << 6) | ex(this.get(i++)));
else if (c < 0xF8) // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (21 bit)
s += surrogate(((c & 0x07) << 18) | (ex(this.get(i++)) << 12) | (ex(this.get(i++)) << 6) | ex(this.get(i++)));
else
throw new Error('Invalid UTF-8 starting byte (since 2003 it is restricted to 4 bytes): ' + c);
}
return { size: s.length, str: stringCut(s, maxLength) };
}
parseStringBMP(start, end, maxLength) {
let s = '', hi, lo;
for (let i = start; i < end; ) {
hi = this.get(i++);
lo = this.get(i++);
s += String.fromCharCode((hi << 8) | lo);
}
return { size: s.length, str: stringCut(s, maxLength) };
}
parseTime(start, end, shortYear) {
let s = this.parseStringISO(start, end).str,
m = (shortYear ? reTimeS : reTimeL).exec(s);
if (!m)
throw new Error('Unrecognized time: ' + s);
if (shortYear) {
// to avoid querying the timer, use the fixed range [1970, 2069]
// it will conform with ITU X.400 [-10, +40] sliding window until 2030
m[1] = +m[1];
m[1] += (m[1] < 70) ? 2000 : 1900;
}
s = m[1] + '-' + m[2] + '-' + m[3] + ' ' + m[4];
if (m[5]) {
s += ':' + m[5];
if (m[6]) {
s += ':' + m[6];
if (m[7])
s += '.' + m[7];
}
}
if (m[8]) {
s += ' UTC';
if (m[9])
s += m[9] + ':' + (m[10] || '00');
}
return s;
}
parseInteger(start, end) {
let v = this.get(start),
neg = (v > 127),
pad = neg ? 255 : 0,
len,
s = '';
// skip unuseful bits (not allowed in DER)
while (v == pad && ++start < end)
v = this.get(start);
len = end - start;
if (len === 0)
return neg ? '-1' : '0';
// show bit length of huge integers
if (len > 4) {
s = v;
len <<= 3;
while (((s ^ pad) & 0x80) == 0) {
s <<= 1;
--len;
}
s = '(' + len + ' bit)\n';
}
// decode the integer
if (neg) v = v - 256;
let n = new Int10(v);
for (let i = start + 1; i < end; ++i)
n.mulAdd(256, this.get(i));
return s + n.toString();
}
parseBitString(start, end, maxLength) {
let unusedBits = this.get(start);
if (unusedBits > 7)
throw new Error('Invalid BitString with unusedBits=' + unusedBits);
let lenBit = ((end - start - 1) << 3) - unusedBits,
s = '';
for (let i = start + 1; i < end; ++i) {
let b = this.get(i),
skip = (i == end - 1) ? unusedBits : 0;
for (let j = 7; j >= skip; --j)
s += (b >> j) & 1 ? '1' : '0';
if (s.length > maxLength)
s = stringCut(s, maxLength);
}
return { size: lenBit, str: s };
}
parseOctetString(start, end, maxLength) {
let len = end - start,
s;
try {
s = this.parseStringUTF(start, end, maxLength);
checkPrintable(s.str);
return { size: end - start, str: s.str };
} catch (ignore) {
// ignore
}
maxLength /= 2; // we work in bytes
if (len > maxLength)
end = start + maxLength;
s = '';
for (let i = start; i < end; ++i)
s += Stream.hexByte(this.get(i));
if (len > maxLength)
s += ellipsis;
return { size: len, str: s };
}
parseOID(start, end, maxLength, isRelative) {
let s = '',
n = new Int10(),
bits = 0;
for (let i = start; i < end; ++i) {
let v = this.get(i);
n.mulAdd(128, v & 0x7F);
bits += 7;
if (!(v & 0x80)) { // finished
if (s === '') {
n = n.simplify();
if (isRelative) {
s = (n instanceof Int10) ? n.toString() : '' + n;
} else if (n instanceof Int10) {
n.sub(80);
s = '2.' + n.toString();
} else {
let m = n < 80 ? n < 40 ? 0 : 1 : 2;
s = m + '.' + (n - m * 40);
}
} else
s += '.' + n.toString();
if (s.length > maxLength)
return stringCut(s, maxLength);
n = new Int10();
bits = 0;
}
}
if (bits > 0)
s += '.incomplete';
if (typeof oids === 'object' && !isRelative) {
let oid = oids[s];
if (oid) {
if (oid.d) s += '\n' + oid.d;
if (oid.c) s += '\n' + oid.c;
if (oid.w) s += '\n(warning!)';
}
}
return s;
}
parseRelativeOID(start, end, maxLength) {
return this.parseOID(start, end, maxLength, true);
}
}
function recurse(el, parser, maxLength) {
let avoidRecurse = true;
if (el.tag.tagConstructed && el.sub) {
avoidRecurse = false;
el.sub.forEach(function (e1) {
if (e1.tag.tagClass != el.tag.tagClass || e1.tag.tagNumber != el.tag.tagNumber)
avoidRecurse = true;
});
}
if (avoidRecurse)
return el.stream[parser](el.posContent(), el.posContent() + Math.abs(el.length), maxLength);
let d = { size: 0, str: '' };
el.sub.forEach(function (el) {
let d1 = recurse(el, parser, maxLength - d.str.length);
d.size += d1.size;
d.str += d1.str;
});
return d;
}
class ASN1Tag {
constructor(stream) {
let buf = stream.get();
this.tagClass = buf >> 6;
this.tagConstructed = ((buf & 0x20) !== 0);
this.tagNumber = buf & 0x1F;
if (this.tagNumber == 0x1F) { // long tag
let n = new Int10();
do {
buf = stream.get();
n.mulAdd(128, buf & 0x7F);
} while (buf & 0x80);
this.tagNumber = n.simplify();
}
}
isUniversal() {
return this.tagClass === 0x00;
}
isEOC() {
return this.tagClass === 0x00 && this.tagNumber === 0x00;
}
}
export class ASN1 {
constructor(stream, header, length, tag, tagLen, sub) {
if (!(tag instanceof ASN1Tag)) throw new Error('Invalid tag value.');
this.stream = stream;
this.header = header;
this.length = length;
this.tag = tag;
this.tagLen = tagLen;
this.sub = sub;
}
typeName() {
switch (this.tag.tagClass) {
case 0: // universal
switch (this.tag.tagNumber) {
case 0x00: return 'EOC';
case 0x01: return 'BOOLEAN';
case 0x02: return 'INTEGER';
case 0x03: return 'BIT_STRING';
case 0x04: return 'OCTET_STRING';
case 0x05: return 'NULL';
case 0x06: return 'OBJECT_IDENTIFIER';
case 0x07: return 'ObjectDescriptor';
case 0x08: return 'EXTERNAL';
case 0x09: return 'REAL';
case 0x0A: return 'ENUMERATED';
case 0x0B: return 'EMBEDDED_PDV';
case 0x0C: return 'UTF8String';
case 0x0D: return 'RELATIVE_OID';
case 0x10: return 'SEQUENCE';
case 0x11: return 'SET';
case 0x12: return 'NumericString';
case 0x13: return 'PrintableString'; // ASCII subset
case 0x14: return 'TeletexString'; // aka T61String
case 0x15: return 'VideotexString';
case 0x16: return 'IA5String'; // ASCII
case 0x17: return 'UTCTime';
case 0x18: return 'GeneralizedTime';
case 0x19: return 'GraphicString';
case 0x1A: return 'VisibleString'; // ASCII subset
case 0x1B: return 'GeneralString';
case 0x1C: return 'UniversalString';
case 0x1E: return 'BMPString';
}
return 'Universal_' + this.tag.tagNumber.toString();
case 1: return 'Application_' + this.tag.tagNumber.toString();
case 2: return '[' + this.tag.tagNumber.toString() + ']'; // Context
case 3: return 'Private_' + this.tag.tagNumber.toString();
}
}
/** A string preview of the content (intended for humans). */
content(maxLength) {
if (this.tag === undefined)
return null;
if (maxLength === undefined)
maxLength = Infinity;
let content = this.posContent(),
len = Math.abs(this.length);
if (!this.tag.isUniversal()) {
if (this.sub !== null)
return '(' + this.sub.length + ' elem)';
let d1 = this.stream.parseOctetString(content, content + len, maxLength);
return '(' + d1.size + ' byte)\n' + d1.str;
}
switch (this.tag.tagNumber) {
case 0x01: // BOOLEAN
return (this.stream.get(content) === 0) ? 'false' : 'true';
case 0x02: // INTEGER
return this.stream.parseInteger(content, content + len);
case 0x03: { // BIT_STRING
let d = recurse(this, 'parseBitString', maxLength);
return '(' + d.size + ' bit)\n' + d.str;
}
case 0x04: { // OCTET_STRING
let d = recurse(this, 'parseOctetString', maxLength);
return '(' + d.size + ' byte)\n' + d.str;
}
//case 0x05: // NULL
case 0x06: // OBJECT_IDENTIFIER
return this.stream.parseOID(content, content + len, maxLength);
//case 0x07: // ObjectDescriptor
//case 0x08: // EXTERNAL
//case 0x09: // REAL
case 0x0A: // ENUMERATED
return this.stream.parseInteger(content, content + len);
//case 0x0B: // EMBEDDED_PDV
case 0x0D: // RELATIVE-OID
return this.stream.parseRelativeOID(content, content + len, maxLength);
case 0x10: // SEQUENCE
case 0x11: // SET
if (this.sub !== null)
return '(' + this.sub.length + ' elem)';
else
return '(no elem)';
case 0x0C: // UTF8String
return recurse(this, 'parseStringUTF', maxLength).str;
case 0x14: // TeletexString
return recurse(this, 'parseStringT61', maxLength).str;
case 0x12: // NumericString
case 0x13: // PrintableString
case 0x15: // VideotexString
case 0x16: // IA5String
case 0x1A: // VisibleString
case 0x1B: // GeneralString
//case 0x19: // GraphicString
//case 0x1C: // UniversalString
return recurse(this, 'parseStringISO', maxLength).str;
case 0x1E: // BMPString
return recurse(this, 'parseStringBMP', maxLength).str;
case 0x17: // UTCTime
case 0x18: // GeneralizedTime
return this.stream.parseTime(content, content + len, (this.tag.tagNumber == 0x17));
}
return null;
}
toString() {
return this.typeName() + '@' + this.stream.pos + '[header:' + this.header + ',length:' + this.length + ',sub:' + ((this.sub === null) ? 'null' : this.sub.length) + ']';
}
toPrettyString(indent) {
if (indent === undefined) indent = '';
let s = indent;
if (this.def) {
if (this.def.id)
s += this.def.id + ' ';
if (this.def.name && this.def.name != this.typeName().replace(/_/g, ' '))
s+= this.def.name + ' ';
if (this.def.mismatch)
s += '[?] ';
}
s += this.typeName() + ' @' + this.stream.pos;
if (this.length >= 0)
s += '+';
s += this.length;
if (this.tag.tagConstructed)
s += ' (constructed)';
else if ((this.tag.isUniversal() && ((this.tag.tagNumber == 0x03) || (this.tag.tagNumber == 0x04))) && (this.sub !== null))
s += ' (encapsulates)';
let content = this.content();
if (content)
s += ': ' + content.replace(/\n/g, '|');
s += '\n';
if (this.sub !== null) {
indent += ' ';
for (let i = 0, max = this.sub.length; i < max; ++i)
s += this.sub[i].toPrettyString(indent);
}
return s;
}
posStart() {
return this.stream.pos;
}
posContent() {
return this.stream.pos + this.header;
}
posEnd() {
return this.stream.pos + this.header + Math.abs(this.length);
}
/** Position of the length. */
posLen() {
return this.stream.pos + this.tagLen;
}
/** Hexadecimal dump of the node.
* @param type 'raw', 'byte' or 'dump' */
toHexString(type = 'raw') {
return this.stream.hexDump(this.posStart(), this.posEnd(), type);
}
/** Base64url dump of the node (according to RFC 4648 section 5).
* @param {string} type 'url' (default, section 5 without padding) or 'std' (section 4 with padding)
*/
toB64String(type = 'url') {
return this.stream.b64Dump(this.posStart(), this.posEnd(), type);
}
static decodeLength(stream) {
let buf = stream.get(),
len = buf & 0x7F;
if (len == buf) // first bit was 0, short form
return len;
if (len === 0) // long form with length 0 is a special case
return null; // undefined length
if (len > 6) // no reason to use Int10, as it would be a huge buffer anyways
throw new Error('Length over 48 bits not supported at position ' + (stream.pos - 1));
buf = 0;
for (let i = 0; i < len; ++i)
buf = (buf * 256) + stream.get();
return buf;
}
static decode(stream, offset, type = ASN1) {
if (!(type == ASN1 || type.prototype instanceof ASN1))
throw new Error('Must pass a class that extends ASN1');
if (!(stream instanceof Stream))
stream = new Stream(stream, offset || 0);
let streamStart = new Stream(stream),
tag = new ASN1Tag(stream),
tagLen = stream.pos - streamStart.pos,
len = ASN1.decodeLength(stream),
start = stream.pos,
header = start - streamStart.pos,
sub = null,
getSub = function () {
sub = [];
if (len !== null) {
// definite length
let end = start + len;
if (end > stream.enc.length)
throw new Error('Container at offset ' + start + ' has a length of ' + len + ', which is past the end of the stream');
while (stream.pos < end)
sub[sub.length] = type.decode(stream);
if (stream.pos != end)
throw new Error('Content size is not correct for container at offset ' + start);
} else {
// undefined length
try {
for (;;) {
let s = type.decode(stream);
if (s.tag.isEOC())
break;
sub[sub.length] = s;
}
len = start - stream.pos; // undefined lengths are represented as negative values
} catch (e) {
throw new Error('Exception while decoding undefined length content at offset ' + start + ': ' + e);
}
}
};
if (tag.tagConstructed) {
// must have valid content
getSub();
} else if (tag.isUniversal() && ((tag.tagNumber == 0x03) || (tag.tagNumber == 0x04))) {
// sometimes BitString and OctetString are used to encapsulate ASN.1
try {
if (tag.tagNumber == 0x03)
if (stream.get() != 0)
throw new Error('BIT STRINGs with unused bits cannot encapsulate.');
getSub();
for (let s of sub) {
if (s.tag.isEOC())
throw new Error('EOC is not supposed to be actual content.');
try {
s.content();
} catch (e) {
throw new Error('Unable to parse content: ' + e);
}
}
} catch (ignore) {
// but silently ignore when they don't
sub = null;
//DEBUG console.log('Could not decode structure at ' + start + ':', e);
}
}
if (sub === null) {
if (len === null)
throw new Error("We can't skip over an invalid tag with undefined length at offset " + start);
stream.pos = start + Math.abs(len);
}
return new type(streamStart, header, len, tag, tagLen, sub);
}
}