mirror of
https://github.com/Ylianst/MeshCommander
synced 2025-12-15 07:43:27 +00:00
Fixed -kvmdatatrace issue.
This commit is contained in:
634
pki.js/SignedCertificateTimestampList.js
Normal file
634
pki.js/SignedCertificateTimestampList.js
Normal file
@@ -0,0 +1,634 @@
|
||||
import * as asn1js from "asn1js";
|
||||
import { getParametersValue, utilFromBase, utilToBase, bufferToHexCodes, toBase64, fromBase64, arrayBufferToString, stringToArrayBuffer } from "pvutils";
|
||||
import { ByteStream, SeqStream } from "bytestreamjs";
|
||||
import { getCrypto, getEngine } from "./common.js";
|
||||
import PublicKeyInfo from "./PublicKeyInfo.js";
|
||||
//**************************************************************************************
|
||||
export class SignedCertificateTimestamp
|
||||
{
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Constructor for SignedCertificateTimestamp class
|
||||
* @param {Object} [parameters={}]
|
||||
* @param {Object} [parameters.schema] asn1js parsed value to initialize the class from
|
||||
*/
|
||||
constructor(parameters = {})
|
||||
{
|
||||
//region Internal properties of the object
|
||||
/**
|
||||
* @type {number}
|
||||
* @desc version
|
||||
*/
|
||||
this.version = getParametersValue(parameters, "version", SignedCertificateTimestamp.defaultValues("version"));
|
||||
/**
|
||||
* @type {ArrayBuffer}
|
||||
* @desc logID
|
||||
*/
|
||||
this.logID = getParametersValue(parameters, "logID", SignedCertificateTimestamp.defaultValues("logID"));
|
||||
/**
|
||||
* @type {Date}
|
||||
* @desc timestamp
|
||||
*/
|
||||
this.timestamp = getParametersValue(parameters, "timestamp", SignedCertificateTimestamp.defaultValues("timestamp"));
|
||||
/**
|
||||
* @type {ArrayBuffer}
|
||||
* @desc extensions
|
||||
*/
|
||||
this.extensions = getParametersValue(parameters, "extensions", SignedCertificateTimestamp.defaultValues("extensions"));
|
||||
/**
|
||||
* @type {string}
|
||||
* @desc hashAlgorithm
|
||||
*/
|
||||
this.hashAlgorithm = getParametersValue(parameters, "hashAlgorithm", SignedCertificateTimestamp.defaultValues("hashAlgorithm"));
|
||||
/**
|
||||
* @type {string}
|
||||
* @desc signatureAlgorithm
|
||||
*/
|
||||
this.signatureAlgorithm = getParametersValue(parameters, "signatureAlgorithm", SignedCertificateTimestamp.defaultValues("signatureAlgorithm"));
|
||||
/**
|
||||
* @type {Object}
|
||||
* @desc signature
|
||||
*/
|
||||
this.signature = getParametersValue(parameters, "signature", SignedCertificateTimestamp.defaultValues("signature"));
|
||||
//endregion
|
||||
|
||||
//region If input argument array contains "schema" for this object
|
||||
if("schema" in parameters)
|
||||
this.fromSchema(parameters.schema);
|
||||
//endregion
|
||||
|
||||
//region If input argument array contains "stream"
|
||||
if("stream" in parameters)
|
||||
this.fromStream(parameters.stream);
|
||||
//endregion
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Return default values for all class members
|
||||
* @param {string} memberName String name for a class member
|
||||
*/
|
||||
static defaultValues(memberName)
|
||||
{
|
||||
switch(memberName)
|
||||
{
|
||||
case "version":
|
||||
return 0;
|
||||
case "logID":
|
||||
case "extensions":
|
||||
return new ArrayBuffer(0);
|
||||
case "timestamp":
|
||||
return new Date(0);
|
||||
case "hashAlgorithm":
|
||||
case "signatureAlgorithm":
|
||||
return "";
|
||||
case "signature":
|
||||
return new asn1js.Any();
|
||||
default:
|
||||
throw new Error(`Invalid member name for SignedCertificateTimestamp class: ${memberName}`);
|
||||
}
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convert parsed asn1js object into current class
|
||||
* @param {!Object} schema
|
||||
*/
|
||||
fromSchema(schema)
|
||||
{
|
||||
if((schema instanceof asn1js.RawData) === false)
|
||||
throw new Error("Object's schema was not verified against input data for SignedCertificateTimestamp");
|
||||
|
||||
const seqStream = new SeqStream({
|
||||
stream: new ByteStream({
|
||||
buffer: schema.data
|
||||
})
|
||||
});
|
||||
|
||||
this.fromStream(seqStream);
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convert SeqStream data into current class
|
||||
* @param {!SeqStream} stream
|
||||
*/
|
||||
fromStream(stream)
|
||||
{
|
||||
const blockLength = stream.getUint16();
|
||||
|
||||
this.version = (stream.getBlock(1))[0];
|
||||
|
||||
if(this.version === 0)
|
||||
{
|
||||
this.logID = (new Uint8Array(stream.getBlock(32))).buffer.slice(0);
|
||||
this.timestamp = new Date(utilFromBase(new Uint8Array(stream.getBlock(8)), 8));
|
||||
|
||||
//region Extensions
|
||||
const extensionsLength = stream.getUint16();
|
||||
this.extensions = (new Uint8Array(stream.getBlock(extensionsLength))).buffer.slice(0);
|
||||
//endregion
|
||||
|
||||
//region Hash algorithm
|
||||
switch((stream.getBlock(1))[0])
|
||||
{
|
||||
case 0:
|
||||
this.hashAlgorithm = "none";
|
||||
break;
|
||||
case 1:
|
||||
this.hashAlgorithm = "md5";
|
||||
break;
|
||||
case 2:
|
||||
this.hashAlgorithm = "sha1";
|
||||
break;
|
||||
case 3:
|
||||
this.hashAlgorithm = "sha224";
|
||||
break;
|
||||
case 4:
|
||||
this.hashAlgorithm = "sha256";
|
||||
break;
|
||||
case 5:
|
||||
this.hashAlgorithm = "sha384";
|
||||
break;
|
||||
case 6:
|
||||
this.hashAlgorithm = "sha512";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Object's stream was not correct for SignedCertificateTimestamp");
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Signature algorithm
|
||||
switch((stream.getBlock(1))[0])
|
||||
{
|
||||
case 0:
|
||||
this.signatureAlgorithm = "anonymous";
|
||||
break;
|
||||
case 1:
|
||||
this.signatureAlgorithm = "rsa";
|
||||
break;
|
||||
case 2:
|
||||
this.signatureAlgorithm = "dsa";
|
||||
break;
|
||||
case 3:
|
||||
this.signatureAlgorithm = "ecdsa";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Object's stream was not correct for SignedCertificateTimestamp");
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Signature
|
||||
const signatureLength = stream.getUint16();
|
||||
const signatureData = (new Uint8Array(stream.getBlock(signatureLength))).buffer.slice(0);
|
||||
|
||||
const asn1 = asn1js.fromBER(signatureData);
|
||||
if(asn1.offset === (-1))
|
||||
throw new Error("Object's stream was not correct for SignedCertificateTimestamp");
|
||||
|
||||
this.signature = asn1.result;
|
||||
//endregion
|
||||
|
||||
if(blockLength !== (47 + extensionsLength + signatureLength))
|
||||
throw new Error("Object's stream was not correct for SignedCertificateTimestamp");
|
||||
}
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convert current object to asn1js object and set correct values
|
||||
* @returns {Object} asn1js object
|
||||
*/
|
||||
toSchema()
|
||||
{
|
||||
const stream = this.toStream();
|
||||
|
||||
return new asn1js.RawData({ data: stream.stream.buffer });
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convert current object to SeqStream data
|
||||
* @returns {SeqStream} SeqStream object
|
||||
*/
|
||||
toStream()
|
||||
{
|
||||
const stream = new SeqStream();
|
||||
|
||||
stream.appendUint16(47 + this.extensions.byteLength + this.signature.valueBeforeDecode.byteLength);
|
||||
stream.appendChar(this.version);
|
||||
stream.appendView(new Uint8Array(this.logID));
|
||||
|
||||
const timeBuffer = new ArrayBuffer(8);
|
||||
const timeView = new Uint8Array(timeBuffer);
|
||||
|
||||
const baseArray = utilToBase(this.timestamp.valueOf(), 8);
|
||||
timeView.set(new Uint8Array(baseArray), 8 - baseArray.byteLength);
|
||||
|
||||
stream.appendView(timeView);
|
||||
stream.appendUint16(this.extensions.byteLength);
|
||||
|
||||
if(this.extensions.byteLength)
|
||||
stream.appendView(new Uint8Array(this.extensions));
|
||||
|
||||
let _hashAlgorithm;
|
||||
|
||||
switch(this.hashAlgorithm.toLowerCase())
|
||||
{
|
||||
case "none":
|
||||
_hashAlgorithm = 0;
|
||||
break;
|
||||
case "md5":
|
||||
_hashAlgorithm = 1;
|
||||
break;
|
||||
case "sha1":
|
||||
_hashAlgorithm = 2;
|
||||
break;
|
||||
case "sha224":
|
||||
_hashAlgorithm = 3;
|
||||
break;
|
||||
case "sha256":
|
||||
_hashAlgorithm = 4;
|
||||
break;
|
||||
case "sha384":
|
||||
_hashAlgorithm = 5;
|
||||
break;
|
||||
case "sha512":
|
||||
_hashAlgorithm = 6;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Incorrect data for hashAlgorithm: ${this.hashAlgorithm}`);
|
||||
}
|
||||
|
||||
stream.appendChar(_hashAlgorithm);
|
||||
|
||||
let _signatureAlgorithm;
|
||||
|
||||
switch(this.signatureAlgorithm.toLowerCase())
|
||||
{
|
||||
case "anonymous":
|
||||
_signatureAlgorithm = 0;
|
||||
break;
|
||||
case "rsa":
|
||||
_signatureAlgorithm = 1;
|
||||
break;
|
||||
case "dsa":
|
||||
_signatureAlgorithm = 2;
|
||||
break;
|
||||
case "ecdsa":
|
||||
_signatureAlgorithm = 3;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Incorrect data for signatureAlgorithm: ${this.signatureAlgorithm}`);
|
||||
}
|
||||
|
||||
stream.appendChar(_signatureAlgorithm);
|
||||
|
||||
const _signature = this.signature.toBER(false);
|
||||
|
||||
stream.appendUint16(_signature.byteLength);
|
||||
stream.appendView(new Uint8Array(_signature));
|
||||
|
||||
return stream;
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convertion for the class to JSON object
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON()
|
||||
{
|
||||
return {
|
||||
version: this.version,
|
||||
logID: bufferToHexCodes(this.logID),
|
||||
timestamp: this.timestamp,
|
||||
extensions: bufferToHexCodes(this.extensions),
|
||||
hashAlgorithm: this.hashAlgorithm,
|
||||
signatureAlgorithm: this.signatureAlgorithm,
|
||||
signature: this.signature.toJSON()
|
||||
};
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Verify SignedCertificateTimestamp for specific input data
|
||||
* @param {Object[]} logs Array of objects with information about each CT Log (like here: https://ct.grahamedgecombe.com/logs.json)
|
||||
* @param {String} logs.log_id Identifier of the CT Log encoded in BASE-64 format
|
||||
* @param {String} logs.key Public key of the CT Log encoded in BASE-64 format
|
||||
* @param {ArrayBuffer} data Data to verify signature against. Could be encoded Certificate or encoded PreCert
|
||||
* @param {Number} [dataType=0] Type = 0 (data is encoded Certificate), type = 1 (data is encoded PreCert)
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async verify(logs, data, dataType = 0)
|
||||
{
|
||||
//region Initial variables
|
||||
let logId = toBase64(arrayBufferToString(this.logID));
|
||||
|
||||
let publicKeyBase64 = null;
|
||||
let publicKeyInfo;
|
||||
|
||||
let stream = new SeqStream();
|
||||
//endregion
|
||||
|
||||
//region Found and init public key
|
||||
for(const log of logs)
|
||||
{
|
||||
if(log.log_id === logId)
|
||||
{
|
||||
publicKeyBase64 = log.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(publicKeyBase64 === null)
|
||||
throw new Error(`Public key not found for CT with logId: ${logId}`);
|
||||
|
||||
const asn1 = asn1js.fromBER(stringToArrayBuffer(fromBase64(publicKeyBase64)));
|
||||
if(asn1.offset === (-1))
|
||||
throw new Error(`Incorrect key value for CT Log with logId: ${logId}`);
|
||||
|
||||
publicKeyInfo = new PublicKeyInfo({ schema: asn1.result });
|
||||
//endregion
|
||||
|
||||
//region Initialize signed data block
|
||||
stream.appendChar(0x00); // sct_version
|
||||
stream.appendChar(0x00); // signature_type = certificate_timestamp
|
||||
|
||||
const timeBuffer = new ArrayBuffer(8);
|
||||
const timeView = new Uint8Array(timeBuffer);
|
||||
|
||||
const baseArray = utilToBase(this.timestamp.valueOf(), 8);
|
||||
timeView.set(new Uint8Array(baseArray), 8 - baseArray.byteLength);
|
||||
|
||||
stream.appendView(timeView);
|
||||
|
||||
stream.appendUint16(dataType);
|
||||
|
||||
if(dataType === 0)
|
||||
stream.appendUint24(data.byteLength);
|
||||
|
||||
stream.appendView(new Uint8Array(data));
|
||||
|
||||
stream.appendUint16(this.extensions.byteLength);
|
||||
|
||||
if(this.extensions.byteLength !== 0)
|
||||
stream.appendView(new Uint8Array(this.extensions));
|
||||
//endregion
|
||||
|
||||
//region Perform verification
|
||||
return getEngine().subtle.verifyWithPublicKey(
|
||||
stream._stream._buffer.slice(0, stream._length),
|
||||
{ valueBlock: { valueHex: this.signature.toBER(false) } },
|
||||
publicKeyInfo,
|
||||
{ algorithmId: "" },
|
||||
"SHA-256"
|
||||
);
|
||||
//endregion
|
||||
}
|
||||
//**********************************************************************************
|
||||
}
|
||||
//**************************************************************************************
|
||||
/**
|
||||
* Class from RFC6962
|
||||
*/
|
||||
export default class SignedCertificateTimestampList
|
||||
{
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Constructor for SignedCertificateTimestampList class
|
||||
* @param {Object} [parameters={}]
|
||||
* @param {Object} [parameters.schema] asn1js parsed value to initialize the class from
|
||||
*/
|
||||
constructor(parameters = {})
|
||||
{
|
||||
//region Internal properties of the object
|
||||
/**
|
||||
* @type {Array.<SignedCertificateTimestamp>}
|
||||
* @desc timestamps
|
||||
*/
|
||||
this.timestamps = getParametersValue(parameters, "timestamps", SignedCertificateTimestampList.defaultValues("timestamps"));
|
||||
//endregion
|
||||
|
||||
//region If input argument array contains "schema" for this object
|
||||
if("schema" in parameters)
|
||||
this.fromSchema(parameters.schema);
|
||||
//endregion
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Return default values for all class members
|
||||
* @param {string} memberName String name for a class member
|
||||
*/
|
||||
static defaultValues(memberName)
|
||||
{
|
||||
switch(memberName)
|
||||
{
|
||||
case "timestamps":
|
||||
return [];
|
||||
default:
|
||||
throw new Error(`Invalid member name for SignedCertificateTimestampList class: ${memberName}`);
|
||||
}
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Compare values with default values for all class members
|
||||
* @param {string} memberName String name for a class member
|
||||
* @param {*} memberValue Value to compare with default value
|
||||
*/
|
||||
static compareWithDefault(memberName, memberValue)
|
||||
{
|
||||
switch(memberName)
|
||||
{
|
||||
case "timestamps":
|
||||
return (memberValue.length === 0);
|
||||
default:
|
||||
throw new Error(`Invalid member name for SignedCertificateTimestampList class: ${memberName}`);
|
||||
}
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Return value of pre-defined ASN.1 schema for current class
|
||||
*
|
||||
* ASN.1 schema:
|
||||
* ```asn1
|
||||
* SignedCertificateTimestampList ::= OCTET STRING
|
||||
* ```
|
||||
*
|
||||
* @param {Object} parameters Input parameters for the schema
|
||||
* @returns {Object} asn1js schema object
|
||||
*/
|
||||
static schema(parameters = {})
|
||||
{
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {string} [blockName]
|
||||
* @property {string} [optional]
|
||||
*/
|
||||
const names = getParametersValue(parameters, "names", {});
|
||||
|
||||
if(("optional" in names) === false)
|
||||
names.optional = false;
|
||||
|
||||
return (new asn1js.OctetString({
|
||||
name: (names.blockName || "SignedCertificateTimestampList"),
|
||||
optional: names.optional
|
||||
}));
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convert parsed asn1js object into current class
|
||||
* @param {!Object} schema
|
||||
*/
|
||||
fromSchema(schema)
|
||||
{
|
||||
//region Check the schema is valid
|
||||
if((schema instanceof asn1js.OctetString) === false)
|
||||
throw new Error("Object's schema was not verified against input data for SignedCertificateTimestampList");
|
||||
//endregion
|
||||
|
||||
//region Get internal properties from parsed schema
|
||||
const seqStream = new SeqStream({
|
||||
stream: new ByteStream({
|
||||
buffer: schema.valueBlock.valueHex
|
||||
})
|
||||
});
|
||||
|
||||
let dataLength = seqStream.getUint16();
|
||||
if(dataLength !== seqStream.length)
|
||||
throw new Error("Object's schema was not verified against input data for SignedCertificateTimestampList");
|
||||
|
||||
while(seqStream.length)
|
||||
this.timestamps.push(new SignedCertificateTimestamp({ stream: seqStream }));
|
||||
//endregion
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convert current object to asn1js object and set correct values
|
||||
* @returns {Object} asn1js object
|
||||
*/
|
||||
toSchema()
|
||||
{
|
||||
//region Initial variables
|
||||
const stream = new SeqStream();
|
||||
|
||||
let overallLength = 0;
|
||||
|
||||
const timestampsData = [];
|
||||
//endregion
|
||||
|
||||
//region Get overall length
|
||||
for(const timestamp of this.timestamps)
|
||||
{
|
||||
const timestampStream = timestamp.toStream();
|
||||
timestampsData.push(timestampStream);
|
||||
overallLength += timestampStream.stream.buffer.byteLength;
|
||||
}
|
||||
//endregion
|
||||
|
||||
stream.appendUint16(overallLength);
|
||||
|
||||
//region Set data from all timestamps
|
||||
for(const timestamp of timestampsData)
|
||||
stream.appendView(timestamp.stream.view);
|
||||
//endregion
|
||||
|
||||
return new asn1js.OctetString({ valueHex: stream.stream.buffer.slice(0) });
|
||||
}
|
||||
//**********************************************************************************
|
||||
/**
|
||||
* Convertion for the class to JSON object
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON()
|
||||
{
|
||||
return {
|
||||
timestamps: Array.from(this.timestamps, element => element.toJSON())
|
||||
};
|
||||
}
|
||||
//**********************************************************************************
|
||||
}
|
||||
//**************************************************************************************
|
||||
/**
|
||||
* Verify SignedCertificateTimestamp for specific certificate content
|
||||
* @param {Certificate} certificate Certificate for which verification would be performed
|
||||
* @param {Certificate} issuerCertificate Certificate of the issuer of target certificate
|
||||
* @param {Object[]} logs Array of objects with information about each CT Log (like here: https://ct.grahamedgecombe.com/logs.json)
|
||||
* @param {String} logs.log_id Identifier of the CT Log encoded in BASE-64 format
|
||||
* @param {String} logs.key Public key of the CT Log encoded in BASE-64 format
|
||||
* @param {Number} [index=-1] Index of SignedCertificateTimestamp inside SignedCertificateTimestampList (for -1 would verify all)
|
||||
* @return {Array} Array of verification results
|
||||
*/
|
||||
export async function verifySCTsForCertificate(certificate, issuerCertificate, logs, index = (-1))
|
||||
{
|
||||
//region Initial variables
|
||||
let parsedValue = null;
|
||||
let tbs;
|
||||
let issuerId;
|
||||
|
||||
const stream = new SeqStream();
|
||||
|
||||
let preCert;
|
||||
//endregion
|
||||
|
||||
//region Get a "crypto" extension
|
||||
const crypto = getCrypto();
|
||||
if(typeof crypto === "undefined")
|
||||
return Promise.reject("Unable to create WebCrypto object");
|
||||
//endregion
|
||||
|
||||
//region Remove certificate extension
|
||||
for(let i = 0; i < certificate.extensions.length; i++)
|
||||
{
|
||||
switch(certificate.extensions[i].extnID)
|
||||
{
|
||||
case "1.3.6.1.4.1.11129.2.4.2":
|
||||
{
|
||||
parsedValue = certificate.extensions[i].parsedValue;
|
||||
|
||||
if(parsedValue.timestamps.length === 0)
|
||||
throw new Error("Nothing to verify in the certificate");
|
||||
|
||||
certificate.extensions.splice(i, 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Check we do have what to verify
|
||||
if(parsedValue === null)
|
||||
throw new Error("No SignedCertificateTimestampList extension in the specified certificate");
|
||||
//endregion
|
||||
|
||||
//region Prepare modifier TBS value
|
||||
tbs = certificate.encodeTBS().toBER(false);
|
||||
//endregion
|
||||
|
||||
//region Initialize "issuer_key_hash" value
|
||||
issuerId = await crypto.digest({ name: "SHA-256" }, new Uint8Array(issuerCertificate.subjectPublicKeyInfo.toSchema().toBER(false)));
|
||||
//endregion
|
||||
|
||||
//region Make final "PreCert" value
|
||||
stream.appendView(new Uint8Array(issuerId));
|
||||
stream.appendUint24(tbs.byteLength);
|
||||
stream.appendView(new Uint8Array(tbs));
|
||||
|
||||
preCert = stream._stream._buffer.slice(0, stream._length);
|
||||
//endregion
|
||||
|
||||
//region Call verification function for specified index
|
||||
if(index === (-1))
|
||||
{
|
||||
const verifyArray = [];
|
||||
|
||||
for(const timestamp of parsedValue.timestamps)
|
||||
{
|
||||
const verifyResult = await timestamp.verify(logs, preCert, 1);
|
||||
verifyArray.push(verifyResult);
|
||||
}
|
||||
|
||||
return verifyArray;
|
||||
}
|
||||
|
||||
if(index >= parsedValue.timestamps.length)
|
||||
index = (parsedValue.timestamps.length - 1);
|
||||
|
||||
return [await parsedValue.timestamps[index].verify(logs, preCert, 1)];
|
||||
//endregion
|
||||
}
|
||||
//**********************************************************************************
|
||||
Reference in New Issue
Block a user