@keeex/tsr
Version:
Manipulate data related to TSR with pki.js
431 lines (430 loc) • 19.1 kB
JavaScript
/**
* @license
* @preserve
*
* KeeeX SAS Public code
* https://keeex.me
* Copyright 2013-2024 KeeeX All Rights Reserved.
*
* These computer program listings and specifications, herein,
* are and remain the property of KeeeX SAS. The intellectual
* and technical concepts herein are proprietary to KeeeX SAS
* and may be covered by EU and foreign patents,
* patents in process, trade secrets and copyright law.
*
* These listings are published as a way to provide third party
* with the ability to process KeeeX data.
* As such, support for public inquiries is limited.
* They are provided "as-is", without warrany of any kind.
*
* They shall not be reproduced or copied or used in whole or
* in part as the basis for manufacture or sale of items unless
* prior written permission is obtained from KeeeX SAS.
*
* For a license agreement, please contact:
* <mailto: contact@keeex.net>
*
*/
// #region Imports
import * as digest from "@keeex/crypto/digest.js";
import { randomBytes } from "@keeex/crypto/random.js";
import { asArray } from "@keeex/utils/array.js";
import { abEqual } from "@keeex/utils/arraybuffer.js";
import { asError } from "@keeex/utils/error.js";
import * as uint8Array from "@keeex/utils/uint8array.js";
import * as asn1js from "asn1js";
import * as pkijsLib from "pkijs";
import * as consts from "./consts.js";
import * as pkijsUtils from "./pkijs.js";
// #endregion
// #region Constants
const MS_DIVIDER = 1000;
// #endregion
// #region Creation
const getNonce = async () => {
let tries = 10000;
while (--tries > 0) {
/* eslint-disable-next-line no-await-in-loop */
const valueHex = await randomBytes(consts.NONCE_LENGTH);
// Forbidden prefixes for valid Integer ASN1 values
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
if (valueHex[0] !== 0x00 && valueHex[0] !== 0xff)
return new asn1js.Integer({ valueHex });
}
throw new Error("Unexpected monte-carlo error");
};
const genTstInfo = async (serialNumber, genTime, policy, ordering, accuracy, query, tsaCert) => new pkijsLib.TSTInfo({
...(accuracy ? { accuracy: new pkijsLib.Accuracy(accuracy) } : undefined),
genTime,
messageImprint: await pkijsUtils.messageImprintFromInput(query),
nonce: await getNonce(),
ordering,
policy,
serialNumber: asn1js.Integer.fromBigInt(serialNumber),
tsa: new pkijsLib.GeneralName({ type: 4, value: tsaCert.subject }),
version: 1,
});
const genSignedData = (content, signers, digestAlgorithm, certificatesChain) => new pkijsLib.SignedData({
certificates: certificatesChain,
encapContentInfo: new pkijsLib.EncapsulatedContentInfo({
eContentType: pkijsLib.id_eContentType_TSTInfo,
eContent: new asn1js.OctetString({ valueHex: content.toSchema().toBER() }),
}),
signerInfos: signers.map((signerInput) => new pkijsLib.SignerInfo({
digestAlgorithm: new pkijsLib.AlgorithmIdentifier({
algorithmId: pkijsUtils.cryptoDigestAlgToPkiOid(digestAlgorithm),
}),
sid: new pkijsLib.IssuerAndSerialNumber({
issuer: signerInput.certificate.issuer,
serialNumber: signerInput.certificate.serialNumber,
}),
version: 1,
})),
version: 3,
});
/**
* Helper to get TSTInfo from SignedData
*
* Digest algorithms are extracted from SignedData itself; if an additional algorithm is required
* (for exemple when signing) it can be requested here; pkijs code will add it to the list later on.
*/
const getTstFromSignedData = async (signedData, extraAlgorithms) => {
const eContent = signedData.encapContentInfo.eContent;
if (!eContent)
throw new Error("Missing encapsulated content");
const tstInfo = pkijsLib.TSTInfo.fromBER(eContent.getValue());
const contentDigests = {};
for (const digestAlgorithm of signedData.digestAlgorithms) {
const actualAlgorithm = pkijsUtils.pkiOidToCryptoDigestAlg(digestAlgorithm.algorithmId);
if (actualAlgorithm in contentDigests)
continue;
/* eslint-disable-next-line no-await-in-loop */
contentDigests[actualAlgorithm] = await pkijsUtils.getContentDigest(eContent, actualAlgorithm);
}
if (extraAlgorithms) {
for (const extraAlgorithm of extraAlgorithms) {
if (extraAlgorithm in contentDigests)
continue;
/* eslint-disable-next-line no-await-in-loop */
contentDigests[extraAlgorithm] = await pkijsUtils.getContentDigest(eContent, extraAlgorithm);
}
}
return { tstInfo, contentDigests };
};
/** Get the correct certificate from a list */
const getSignerCertificate = (certificates, signerInfo) => {
const info = signerInfo.sid;
if (!(info instanceof pkijsLib.IssuerAndSerialNumber))
throw new Error("Unexpected sid value");
for (const candidate of certificates) {
if (!(candidate instanceof pkijsLib.Certificate)) {
throw new Error("Unexpected certificate type");
}
if (candidate.issuer.isEqual(info.issuer) &&
candidate.serialNumber.isEqual(info.serialNumber)) {
return candidate;
}
}
throw new Error("Missing certificate for signerInfo");
};
/**
* Sign a SignedData for a TSR
*
* If multiple signerInfo are provided, the private keys must be provided in the same order
*/
const signTsrSignedAttrs = async (signedData, privateKey) => {
const extraDigests = signedData.signerInfos.map((c) => pkijsUtils.pkiOidToCryptoDigestAlg(c.digestAlgorithm.algorithmId));
const { tstInfo, contentDigests } = await getTstFromSignedData(signedData, extraDigests);
await Promise.all(signedData.signerInfos.map(async (signerInfo) => {
const digestAlgorithm = pkijsUtils.pkiOidToCryptoDigestAlg(signerInfo.digestAlgorithm.algorithmId);
const attributes = [];
attributes.push(new pkijsLib.Attribute({
type: consts.OID_CONTENT_TYPE,
values: [new asn1js.ObjectIdentifier({ value: pkijsLib.id_eContentType_TSTInfo })],
}));
attributes.push(new pkijsLib.Attribute({
type: consts.OID_SIGNING_TIME,
values: [new asn1js.UTCTime({ valueDate: tstInfo.genTime })],
}));
const certificate = getSignerCertificate(signedData.certificates ?? [], signerInfo);
attributes.push(new pkijsLib.Attribute({
type: consts.OID_SIGNING_CERTIFICATE_V1,
values: [await pkijsUtils.createSigningCertificate(certificate)],
}));
const contentDigest = contentDigests[digestAlgorithm];
if (!contentDigest)
throw new Error("Unexpected state");
attributes.push(new pkijsLib.Attribute({ type: consts.OID_DIGEST, values: [contentDigest] }));
// eslint-disable-next-line require-atomic-updates
signerInfo.signedAttrs = new pkijsLib.SignedAndUnsignedAttributes({ type: 0, attributes });
}));
for (let i = 0; i < signedData.signerInfos.length; ++i) {
const subtleAlgo = pkijsUtils.cryptoDigestAlgToSubtleAlg(pkijsUtils.pkiOidToCryptoDigestAlg(signedData.signerInfos[i].digestAlgorithm.algorithmId));
const currentPrivateKey = privateKey[i];
/* eslint-disable-next-line no-await-in-loop */
if (currentPrivateKey)
await signedData.sign(currentPrivateKey, i, subtleAlgo);
}
};
// #endregion
// #region Verify
const getTsrFromInput = (input) => {
if (input instanceof pkijsLib.TimeStampResp)
return input;
return pkijsLib.TimeStampResp.fromBER(input);
};
/** Extract signed data from TimeStampResp */
const getSignedData = (input) => {
const contentInfo = input.timeStampToken;
if (!contentInfo)
throw new Error("Missing content info");
return new pkijsLib.SignedData({ schema: contentInfo.content });
};
const checkAttrOid = (attribute, expectedOID) => {
const oid = attribute.values[0];
if (!(oid instanceof asn1js.ObjectIdentifier) || oid.getValue() !== expectedOID) {
throw new Error("Invalid OID");
}
};
const checkAttrTime = (attribute, tstInfo) => {
const utcTime = attribute.values[0];
if (!(utcTime instanceof asn1js.UTCTime))
throw new Error("Unexpected value");
const expectedTime = Math.floor(tstInfo.genTime.getTime() / MS_DIVIDER);
const signedTime = Math.floor(utcTime.toDate().getTime() / MS_DIVIDER);
if (signedTime !== expectedTime)
throw new Error("Invalid date");
};
const checkAttrCertificateV2 = async (attribute, signedData) => {
const seq1 = attribute.values[0];
if (!(seq1 instanceof asn1js.Sequence))
throw new Error("Unexpected value");
const certSeq2 = seq1.valueBlock.value[0];
if (!(certSeq2 instanceof asn1js.Sequence))
throw new Error("Unexpected value");
const essCertID2 = certSeq2.valueBlock.value[0];
if (!(essCertID2 instanceof asn1js.Sequence))
throw new Error("Unexpected value");
const certDigest = essCertID2.valueBlock.value[0];
if (!(certDigest instanceof asn1js.OctetString))
throw new Error("Unexpected value");
if (!signedData.certificates)
throw new Error("Missing certificate");
const expectedCert = signedData.certificates[signedData.certificates.length - 1];
const expectedCertDigest = await digest.digestImmediate(digest.Algorithms.SHA256, uint8Array.getUint8Array(expectedCert.toSchema().toBER()));
if (!uint8Array.buf8Equal(expectedCertDigest, uint8Array.getUint8Array(certDigest.getValue()))) {
throw new Error("Mismatched certificate");
}
};
const checkAttrCertificateV1 = async (attribute, signedData) => {
const seq1 = attribute.values[0];
if (!(seq1 instanceof asn1js.Sequence))
throw new Error("Unexpected value");
const certSeq1 = seq1.valueBlock.value[0];
if (!(certSeq1 instanceof asn1js.Sequence))
throw new Error("Unexpected value");
const essCertID1 = certSeq1.valueBlock.value[0];
if (!(essCertID1 instanceof asn1js.Sequence))
throw new Error("Unexpected value");
const certDigest = essCertID1.valueBlock.value[0];
if (!(certDigest instanceof asn1js.OctetString))
throw new Error("Unexpected value");
const expectedCert = signedData.certificates?.[0];
if (!expectedCert)
throw new Error("Missing certificate");
const expectedCertDigest = await digest.digestImmediate(digest.Algorithms.SHA1, uint8Array.getUint8Array(expectedCert.toSchema().toBER()));
if (!uint8Array.buf8Equal(expectedCertDigest, uint8Array.getUint8Array(certDigest.getValue()))) {
throw new Error("Mismatched certificate");
}
};
const checkAttrDigest = (attribute, contentDigest) => {
const attrDigest = attribute.values[0];
if (!(attrDigest instanceof asn1js.OctetString) ||
!abEqual(attrDigest.getValue(), contentDigest.getValue())) {
throw new Error("Mismatched digest");
}
};
const getAttributeType = (attributeOid) => {
switch (attributeOid) {
case consts.OID_CONTENT_TYPE:
return "contentType";
case consts.OID_DIGEST:
return "digest";
case consts.OID_SIGNING_TIME:
return "signingTime";
case consts.OID_SIGNING_CERTIFICATE_V1:
return "signingCertificate";
case consts.OID_SIGNING_CERTIFICATE_V2:
return "signingCertificate";
}
return null;
};
const checkAttribute = async (expectedCheck, attribute, signedData, tstInfo, contentDigest) => {
const attrType = getAttributeType(attribute.type);
if (attrType === null)
return;
if (expectedCheck[attrType])
throw new Error("Repeated attribute");
switch (attribute.type) {
case consts.OID_CONTENT_TYPE:
checkAttrOid(attribute, pkijsLib.id_eContentType_TSTInfo);
break;
case consts.OID_SIGNING_TIME:
checkAttrTime(attribute, tstInfo);
break;
case consts.OID_SIGNING_CERTIFICATE_V1:
await checkAttrCertificateV1(attribute, signedData);
break;
case consts.OID_SIGNING_CERTIFICATE_V2:
await checkAttrCertificateV2(attribute, signedData);
break;
case consts.OID_DIGEST:
checkAttrDigest(attribute, contentDigest);
break;
}
// eslint-disable-next-line require-atomic-updates
expectedCheck[attrType] = true;
};
/**
* Check that SignedAttrs is present and match the TSR content
*
* @returns
* The data that were signed (based on the validated signed attributes)
*/
const checkTsrSignedAttrs = async (signedData) => {
const { tstInfo, contentDigests } = await getTstFromSignedData(signedData);
for (const signerInfo of signedData.signerInfos) {
const signedAttrs = signerInfo.signedAttrs;
const expectedCheck = {
contentType: false,
digest: false,
signingCertificate: false,
signingTime: false,
};
if (!signedAttrs)
throw new Error("Missing signed attributes");
const contentDigest = contentDigests[pkijsUtils.pkiOidToCryptoDigestAlg(signerInfo.digestAlgorithm.algorithmId)];
if (!contentDigest)
throw new Error("Missing digest");
for (const attribute of signedAttrs.attributes) {
/* eslint-disable-next-line no-await-in-loop */
await checkAttribute(expectedCheck, attribute, signedData, tstInfo, contentDigest);
}
if (!expectedCheck.contentType ||
!expectedCheck.signingTime ||
!expectedCheck.signingCertificate ||
!expectedCheck.digest) {
throw new Error("Invalid signed attributes");
}
}
return signedData.signerInfos.map((signerInfo) => {
const data = signerInfo.signedAttrs?.toSchema().toBER();
if (!data)
throw new Error("Unexpected state");
const buf8 = uint8Array.getUint8Array(data, undefined, undefined, true);
// See RFC on how to handle SignedAttr
buf8[0] = 0x31;
return buf8;
});
};
const isStatusGranted = (status) => status === pkijsLib.PKIStatus.granted || status === pkijsLib.PKIStatus.grantedWithMods;
const getAccuracySeconds = (accuracy) => {
if (accuracy === undefined)
return null;
if (accuracy.seconds === undefined &&
accuracy.millis === undefined &&
accuracy.micros === undefined) {
return null;
}
const seconds = accuracy.seconds ?? 0;
const millis = accuracy.millis ?? 0;
const micros = accuracy.micros ?? 0;
// Conversion from millis and micros to seconds
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
return seconds + millis / 1000 + micros / 1000000;
};
// #endregion
// #region Service
export const generateTimestampResponse = async ({ serialNumber, policy, query, signers, accuracy, ordering, digestAlgorithm, genTime, tsaCert, certificatesChain, status, }) => {
const tst = await genTstInfo(serialNumber, genTime, policy, ordering, accuracy, query, tsaCert);
const signedData = genSignedData(tst, asArray(signers), digestAlgorithm, certificatesChain);
await signTsrSignedAttrs(signedData, asArray(signers).map((c) => c.privateKey));
const contentInfo = new pkijsLib.ContentInfo({
contentType: pkijsLib.ContentInfo.SIGNED_DATA,
content: signedData.toSchema(true),
});
const timestampResp = new pkijsLib.TimeStampResp({
status: new pkijsLib.PKIStatusInfo({ status }),
timeStampToken: new pkijsLib.ContentInfo({ schema: contentInfo.toSchema() }),
});
return new Uint8Array(timestampResp.toSchema().toBER());
};
export const extractTimestampResponse = (input) => {
const tsr = getTsrFromInput(input);
const granted = isStatusGranted(tsr.status.status);
if (!granted)
return { granted: false };
const signedData = getSignedData(tsr);
const signedContent = signedData.encapContentInfo.eContent;
if (!signedContent)
throw new Error("Missing encapsulated content");
const tstInfo = pkijsLib.TSTInfo.fromBER(signedContent.getValue());
const signerCertificate = getSignerCertificate(signedData.certificates ?? [], signedData.signerInfos[0]);
return {
accuracyInSeconds: getAccuracySeconds(tstInfo.accuracy),
date: tstInfo.genTime,
digest: pkijsUtils.pkiOidToCryptoDigestAlg(tstInfo.messageImprint.hashAlgorithm.algorithmId),
granted: true,
policy: tstInfo.policy,
serialNumber: tstInfo.serialNumber.toBigInt(),
signatureDigest: pkijsUtils.pkiOidToCryptoDigestAlg(signedData.digestAlgorithms[0].algorithmId),
subject: uint8Array.getUint8Array(tstInfo.messageImprint.hashedMessage.getValue()),
tsa: pkijsUtils.getCnString(signerCertificate.subject),
};
};
/**
* This is not a FULL verification, but a simple check that the TSR is locally valid.
*
* @param messageInput - Validate that the timestamped message match the provided message input.
*/
export const verifyTimestampResponse = async (input, messageInput) => {
await pkijsUtils.getCryptoImpl();
/* eslint-disable-next-line @typescript-eslint/init-declarations */
let tsrInfo;
try {
const tsr = getTsrFromInput(input);
tsrInfo = extractTimestampResponse(tsr);
if (!tsrInfo.granted) {
return { error: new Error("Not granted"), tsrInfo: { granted: false }, valid: false };
}
if (messageInput !== undefined) {
const inputDigest = await pkijsUtils.digestFromMessageInput(messageInput);
if (inputDigest.algorithm !== tsrInfo.digest ||
!uint8Array.buf8Equal(inputDigest.digest, tsrInfo.subject)) {
return { valid: false, error: new Error("Message mismatch") };
}
}
const signedData = getSignedData(tsr);
const checkedData = await checkTsrSignedAttrs(signedData);
const cryptoEngine = pkijsLib.getCrypto();
const signers = [];
for (let i = 0; i < signedData.signerInfos.length; ++i) {
const signerInfo = signedData.signerInfos[i];
const tbs = checkedData[i];
const cert = getSignerCertificate(signedData.certificates ?? [], signerInfo);
if (tsrInfo.date.getTime() < cert.notBefore.value.getTime()) {
return { error: new Error("Date mismatch"), tsrInfo, valid: false };
}
/* eslint-disable-next-line no-await-in-loop */
const res = await cryptoEngine?.verifyWithPublicKey(tbs, signerInfo.signature, cert.subjectPublicKeyInfo, signerInfo.signatureAlgorithm);
if (!res)
return { error: new Error("Invalid signature"), tsrInfo, valid: false };
signers.push(cert);
}
return { signers, tsrInfo, valid: true };
}
catch (e) {
return { error: asError(e), tsrInfo, valid: false };
}
};
// #endregion