diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a0bf8e3..c6d839e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -46,8 +46,9 @@ jobs: - name: Install Dependencies run: yarn install --frozen-lockfile --network-concurrency 1 - - name: Lint - run: yarn lint - - name: Build & Test run: yarn test + + # Run lint after testing for more info + - name: Lint + run: yarn lint diff --git a/package.json b/package.json index 55baab2..72fd892 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@typescript-eslint/eslint-plugin": "^5.5.0", "@typescript-eslint/parser": "^5.5.0", "eslint": "^8.3.0", + "fast-check": "^2.20.0", "jest": "^27.2.0", "rimraf": "^3.0.2", "ts-jest": "^27.0.5", diff --git a/src/builder.ts b/src/builder.ts index ee7944a..6f85093 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -209,8 +209,7 @@ export class Builder> { } function isProof(proof: Store | Chained): proof is Chained { - // @ts-ignore - const encodedFnc = proof.encoded + const encodedFnc = (proof as unknown as Record).encoded return typeof encodedFnc === "function" } diff --git a/src/compatibility.ts b/src/compatibility.ts new file mode 100644 index 0000000..4e7ef72 --- /dev/null +++ b/src/compatibility.ts @@ -0,0 +1,92 @@ +// A module to hold all the ugly compatibility logic +// for getting from old UCANs to newer version UCANs. +import * as util from "./util" +import { UcanParts, isUcanHeader, isUcanPayload } from "./types" + + +type UcanHeader_0_0_1 = { + alg: string + typ: string + uav: string +} + +type UcanPayload_0_0_1 = { + iss: string + aud: string + nbf?: number + exp: number + rsc: string + ptc: string + prf?: string +} + +function isUcanHeader_0_0_1(obj: unknown): obj is UcanHeader_0_0_1 { + return util.isRecord(obj) + && util.hasProp(obj, "alg") && typeof obj.alg === "string" + && util.hasProp(obj, "typ") && typeof obj.typ === "string" + && util.hasProp(obj, "uav") && typeof obj.uav === "string" +} + +function isUcanPayload_0_0_1(obj: unknown): obj is UcanPayload_0_0_1 { + return util.isRecord(obj) + && util.hasProp(obj, "iss") && typeof obj.iss === "string" + && util.hasProp(obj, "aud") && typeof obj.aud === "string" + && (!util.hasProp(obj, "nbf") || typeof obj.nbf === "number") + && util.hasProp(obj, "exp") && typeof obj.exp === "number" + && util.hasProp(obj, "rsc") && typeof obj.rsc === "string" + && util.hasProp(obj, "ptc") && typeof obj.ptc === "string" + && (!util.hasProp(obj, "prf") || typeof obj.prf === "string") +} + + +export function handleCompatibility(header: unknown, payload: unknown): UcanParts { + const fail = (place: string, reason: string) => new Error(`Can't parse UCAN ${place}: ${reason}`) + + if (!util.isRecord(header)) throw fail("header", "Invalid format: Expected a record") + + // parse either the "ucv" or "uav" as a version in the header + // we translate 'uav: 1.0.0' into 'ucv: 0.0.1' + // we only support versions 0.7.0 and 0.0.1 + let version: "0.7.0" | "0.0.1" = "0.7.0" + if (!util.hasProp(header, "ucv") || typeof header.ucv !== "string") { + if (!util.hasProp(header, "uav") || typeof header.uav !== "string") { + throw fail("header", "Invalid format: Missing version indicator") + } else if (header.uav !== "1.0.0") { + throw fail("header", `Unsupported version 'uav: ${header.uav}'`) + } + version = "0.0.1" + } else if (header.ucv !== "0.7.0") { + throw fail("header", `Unsupported version 'ucv: ${header.ucv}'`) + } + + if (version === "0.7.0") { + if (!isUcanHeader(header)) throw fail("header", "Invalid format") + if (!isUcanPayload(payload)) throw fail("payload", "Invalid format") + return { header, payload } + } + + // we know it's version 0.0.1 + + if (!isUcanHeader_0_0_1(header)) throw fail("header", "Invalid version 0.0.1 format") + if (!isUcanPayload_0_0_1(payload)) throw fail("payload", "Invalid version 0.0.1 format") + + return { + header: { + alg: header.alg, + typ: header.typ, + ucv: "0.0.1", + }, + payload: { + iss: payload.iss, + aud: payload.aud, + nbf: payload.nbf, + exp: payload.exp, + att: [{ + rsc: payload.rsc, + cap: payload.ptc, + }], + prf: payload.prf != null ? [payload.prf] : [] + }, + } +} + diff --git a/src/crypto/rsa.ts b/src/crypto/rsa.ts index 01a3c23..b5be7d8 100644 --- a/src/crypto/rsa.ts +++ b/src/crypto/rsa.ts @@ -1,4 +1,5 @@ import { webcrypto } from "one-webcrypto" +import * as uint8arrays from "uint8arrays" export const RSA_ALG = "RSASSA-PKCS1-v1_5" export const DEFAULT_KEY_SIZE = 2048 @@ -7,7 +8,7 @@ export const SALT_LEGNTH = 128 export const generateKeypair = async (size: number = DEFAULT_KEY_SIZE): Promise => { return await webcrypto.subtle.generateKey( - { + { name: RSA_ALG, modulusLength: size, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), @@ -27,7 +28,7 @@ export const importKey = async (key: Uint8Array): Promise => { return await webcrypto.subtle.importKey( "spki", key.buffer, - { name: RSA_ALG, hash: { name: DEFAULT_HASH_ALG }}, + { name: RSA_ALG, hash: { name: DEFAULT_HASH_ALG } }, true, ["verify"] ) @@ -50,3 +51,118 @@ export const verify = async (msg: Uint8Array, sig: Uint8Array, pubKey: Uint8Arra msg.buffer ) } + +/** + * The ASN.1 DER encoded header that needs to be added to an + * ASN.1 DER encoded RSAPublicKey to make it a SubjectPublicKeyInfo. + * + * This byte sequence is always the same. + * + * A human-readable version of this as part of a dumpasn1 dump: + * + * SEQUENCE { + * OBJECT IDENTIFIER rsaEncryption (1 2 840 113549 1 1 1) + * NULL + * } + * + * See https://github.com/ucan-wg/ts-ucan/issues/30 + */ +const SPKI_PARAMS_ENCODED = new Uint8Array([48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0]) +const ASN_SEQUENCE_TAG = new Uint8Array([0x30]) +const ASN_BITSTRING_TAG = new Uint8Array([0x03]) + +export const convertRSAPublicKeyToSubjectPublicKeyInfo = (rsaPublicKey: Uint8Array): Uint8Array => { + // More info on bitstring encoding: https://docs.microsoft.com/en-us/windows/win32/seccertenroll/about-bit-string + const bitStringEncoded = uint8arrays.concat([ + ASN_BITSTRING_TAG, + asn1DERLengthEncode(rsaPublicKey.length + 1), + new Uint8Array([0x00]), // amount of unused bits at the end of our bitstring (counts into length?!) + rsaPublicKey + ]) + return uint8arrays.concat([ + ASN_SEQUENCE_TAG, + asn1DERLengthEncode(SPKI_PARAMS_ENCODED.length + bitStringEncoded.length), + SPKI_PARAMS_ENCODED, + bitStringEncoded, + ]) +} + +export const convertSubjectPublicKeyInfoToRSAPublicKey = (subjectPublicKeyInfo: Uint8Array): Uint8Array => { + let position = 0 + // go into the top-level SEQUENCE + position = asn1Into(subjectPublicKeyInfo, ASN_SEQUENCE_TAG, position).position + // skip the header we expect (SKPI_PARAMS_ENCODED) + position = asn1Skip(subjectPublicKeyInfo, ASN_SEQUENCE_TAG, position) + // we expect the bitstring next + const bitstringParams = asn1Into(subjectPublicKeyInfo, ASN_BITSTRING_TAG, position) + const bitstring = subjectPublicKeyInfo.subarray(bitstringParams.position, bitstringParams.position + bitstringParams.length) + const unusedBitPadding = bitstring[0] + if (unusedBitPadding !== 0) { + throw new Error(`Can't convert SPKI to PKCS: Expected bitstring length to be multiple of 8, but got ${unusedBitPadding} unused bits in last byte.`) + } + return bitstring.slice(1) +} + +// ㊙️ +// but some exposed for testing :/ + +export function asn1DERLengthEncode(length: number): Uint8Array { + if (length < 0 || !isFinite(length)) { + throw new TypeError(`Expected non-negative number. Got ${length}`) + } + + if (length <= 127) { + return new Uint8Array([length]) + } + + const octets: number[] = [] + while (length !== 0) { + octets.push(length & 0xFF) + length = length >>> 8 + } + octets.reverse() + return new Uint8Array([0x80 | (octets.length & 0xFF), ...octets]) +} + +function asn1DERLengthDecodeWithConsumed(bytes: Uint8Array): { number: number; consumed: number } { + if ((bytes[0] & 0x80) === 0) { + return { number: bytes[0], consumed: 1 } + } + + const numberBytes = bytes[0] & 0x7F + if (bytes.length < numberBytes + 1) { + throw new Error(`ASN parsing error: Too few bytes. Expected encoded length's length to be at least ${numberBytes}`) + } + + let length = 0 + for (let i = 0; i < numberBytes; i++) { + length = length << 8 + length = length | bytes[i + 1] + } + return { number: length, consumed: numberBytes + 1 } +} + +export function asn1DERLengthDecode(bytes: Uint8Array): number { + return asn1DERLengthDecodeWithConsumed(bytes).number +} + +function asn1Skip(input: Uint8Array, expectedTag: Uint8Array, position: number): number { + const parsed = asn1Into(input, expectedTag, position) + return parsed.position + parsed.length +} + +function asn1Into(input: Uint8Array, expectedTag: Uint8Array, position: number): { position: number; length: number } { + // tag + const lengthPos = position + expectedTag.length + const actualTag = input.subarray(position, lengthPos) + if (!uint8arrays.equals(actualTag, expectedTag)) { + throw new Error(`ASN parsing error: Expected tag 0x${uint8arrays.toString(expectedTag, "hex")} at position ${position}, but got ${uint8arrays.toString(actualTag, "hex")}.`) + } + + // length + const length = asn1DERLengthDecodeWithConsumed(input.subarray(lengthPos/*, we don't know the end */)) + const contentPos = position + 1 + length.consumed + + // content + return { position: contentPos, length: length.number } +} diff --git a/src/did/prefix.ts b/src/did/prefix.ts index 883943c..ffa6e81 100644 --- a/src/did/prefix.ts +++ b/src/did/prefix.ts @@ -2,10 +2,23 @@ import * as uint8arrays from "uint8arrays" import { KeyType } from "../types" +// Each prefix is varint-encoded. So e.g. 0x1205 gets varint-encoded to 0x8524 +// The varint encoding is described here: https://github.com/multiformats/unsigned-varint +// These varints are encoded big-endian in 7-bit pieces. +// So 0x1205 is split up into 0x12 and 0x05 +// Because there's another byte to be read, the MSB of 0x05 is set: 0x85 +// The next 7 bits encode as 0x24 (instead of 0x12) => 0x8524 + +/** https://github.com/multiformats/multicodec/blob/e9ecf587558964715054a0afcc01f7ace220952c/table.csv#L94 */ export const EDWARDS_DID_PREFIX = new Uint8Array([ 0xed, 0x01 ]) +/** https://github.com/multiformats/multicodec/blob/e9ecf587558964715054a0afcc01f7ace220952c/table.csv#L91 */ export const BLS_DID_PREFIX = new Uint8Array([ 0xea, 0x01 ]) -export const RSA_DID_PREFIX = new Uint8Array([ 0x00, 0xf5, 0x02 ]) -export const BASE58_DID_PREFIX = "did:key:z" +/** https://github.com/multiformats/multicodec/blob/e9ecf587558964715054a0afcc01f7ace220952c/table.csv#L146 */ +export const RSA_DID_PREFIX = new Uint8Array([ 0x85, 0x24 ]) +/** Old RSA DID prefix, used pre-standardisation */ +export const RSA_DID_PREFIX_OLD = new Uint8Array([ 0x00, 0xf5, 0x02 ]) + +export const BASE58_DID_PREFIX = "did:key:z" // z is the multibase prefix for base58btc byte encoding /** * Magic bytes. @@ -34,6 +47,13 @@ export const parseMagicBytes = (prefixedKey: Uint8Array): { type: "rsa" } + // RSA OLD + } else if (hasPrefix(prefixedKey, RSA_DID_PREFIX_OLD)) { + return { + keyBytes: prefixedKey.slice(RSA_DID_PREFIX_OLD.byteLength), + type: "rsa" + } + // EDWARDS } else if (hasPrefix(prefixedKey, EDWARDS_DID_PREFIX)) { return { @@ -56,5 +76,5 @@ export const parseMagicBytes = (prefixedKey: Uint8Array): { * Determines if a Uint8Array has a given indeterminate length-prefix. */ export const hasPrefix = (prefixedKey: Uint8Array, prefix: Uint8Array): boolean => { - return uint8arrays.equals(prefix, prefixedKey.slice(0, prefix.byteLength)) + return uint8arrays.equals(prefix, prefixedKey.subarray(0, prefix.byteLength)) } diff --git a/src/did/transformers.ts b/src/did/transformers.ts index faac6f5..059c15a 100644 --- a/src/did/transformers.ts +++ b/src/did/transformers.ts @@ -1,6 +1,7 @@ import * as uint8arrays from "uint8arrays" -import { BASE58_DID_PREFIX, magicBytes, parseMagicBytes } from "./prefix" +import * as rsa from "../crypto/rsa" +import { BASE58_DID_PREFIX, RSA_DID_PREFIX_OLD, magicBytes, parseMagicBytes, hasPrefix } from "./prefix" import { KeyType, Encodings } from "../types" /** @@ -16,6 +17,14 @@ export function publicKeyBytesToDid( throw new Error(`Key type '${type}' not supported`) } + if (type === "rsa") { + // See also the comment in didToPublicKeyBytes + // In this library, we're assuming a single byte encoding for all types of keys. + // For RSA that is "SubjectPublicKeyInfo", because that's what the WebCrypto API understands. + // But DIDs assume that all public keys are encoded as "RSAPublicKey". + publicKeyBytes = rsa.convertSubjectPublicKeyInfoToRSAPublicKey(publicKeyBytes) + } + const prefixedBytes = uint8arrays.concat([prefix, publicKeyBytes]) // Encode prefixed @@ -36,7 +45,9 @@ export function publicKeyToDid( /** - * Convert a DID (did:key) to the public key in bytes + * Convert a DID (did:key) to the public key into bytes in SubjectPublicKeyInfo (spki) format. + * + * For consumption e.g. in the WebCrypto API. */ export function didToPublicKeyBytes(did: string): { publicKey: Uint8Array @@ -48,11 +59,21 @@ export function didToPublicKeyBytes(did: string): { const didWithoutPrefix = did.slice(BASE58_DID_PREFIX.length) const magicBytes = uint8arrays.fromString(didWithoutPrefix, "base58btc") - const { keyBytes, type } = parseMagicBytes(magicBytes) + const parsed = parseMagicBytes(magicBytes) + + if (parsed.type === "rsa" && !hasPrefix(magicBytes, RSA_DID_PREFIX_OLD)) { + // DID RSA keys are ASN.1 DER encoded "RSAPublicKeys" (PKCS #1). + // But the WebCrypto API mostly works with "SubjectPublicKeyInfo" (SPKI), + // which wraps RSAPublicKey with some metadata. + // In an unofficial RSA multiformat we were using, we used SPKI, + // so we have to be careful not to transform *every* RSA DID to SPKI, but + // only newer DIDs. + parsed.keyBytes = rsa.convertRSAPublicKeyToSubjectPublicKeyInfo(parsed.keyBytes) + } return { - publicKey: keyBytes, - type + publicKey: parsed.keyBytes, + type: parsed.type, } } diff --git a/src/did/validation.ts b/src/did/validation.ts index 089ccba..3ebdb90 100644 --- a/src/did/validation.ts +++ b/src/did/validation.ts @@ -14,10 +14,10 @@ export async function verifySignature(data: Uint8Array, signature: Uint8Array, d switch (type) { case "ed25519": - return await nacl.sign.detached.verify(data, signature, publicKey) + return nacl.sign.detached.verify(data, signature, publicKey) - case "rsa": - return await rsa.verify(data, signature, publicKey) + case "rsa": + return await rsa.verify(data, signature, publicKey) default: return false } diff --git a/src/keypair/rsa.ts b/src/keypair/rsa.ts index 1d99283..edd3151 100644 --- a/src/keypair/rsa.ts +++ b/src/keypair/rsa.ts @@ -1,3 +1,6 @@ +import { webcrypto } from "one-webcrypto" +import * as uint8arrays from "uint8arrays" + import * as rsa from "../crypto/rsa" import BaseKeypair from "./base" import { Encodings, AvailableCryptoKeyPair, isAvailableCryptoKeyPair } from "../types" @@ -32,7 +35,8 @@ export class RsaKeypair extends BaseKeypair { if (!this.exportable) { throw new Error("Key is not exportable") } - throw new Error("Exporting not enabled for RSA yet") + const arrayBuffer = await webcrypto.subtle.exportKey("pkcs8", this.keypair.privateKey) + return uint8arrays.toString(new Uint8Array(arrayBuffer), format) } } diff --git a/src/token.ts b/src/token.ts index 5b65441..7ba6bee 100644 --- a/src/token.ts +++ b/src/token.ts @@ -1,8 +1,9 @@ import * as uint8arrays from "uint8arrays" import * as util from "./util" import * as did from "./did" +import { handleCompatibility } from "./compatibility" import { verifySignatureUtf8 } from "./did/validation" -import { Keypair, KeyType, Capability, Fact, Ucan, UcanHeader, UcanPayload, UcanParts, isUcanHeader, isUcanPayload } from "./types" +import { Keypair, KeyType, Capability, Fact, Ucan, UcanHeader, UcanPayload, UcanParts } from "./types" /** * Create a UCAN, User Controlled Authorization Networks, JWT. @@ -203,15 +204,10 @@ export async function validate(encodedUcan: string, options?: ValidateOptions): throw new Error(`Can't parse UCAN: ${encodedUcan}: Expected JWT format: 3 dot-separated base64url-encoded values.`) } - const header = parseHeader(encodedHeader) - const payload = parsePayload(encodedPayload) + const headerDecoded = parseHeader(encodedHeader) + const payloadDecoded = parsePayload(encodedPayload) - if (!isUcanHeader(header)) { - throw new Error(`Can't parse UCAN header: ${encodedHeader}: Invalid format.`) - } - if (!isUcanPayload(payload)) { - throw new Error(`Can't parse UCAN payload. ${encodedPayload}: Invalid format.`) - } + const { header, payload } = handleCompatibility(headerDecoded, payloadDecoded) if (checkSignature) { if (!await verifySignatureUtf8(`${encodedHeader}.${encodedPayload}`, signature, payload.iss)) { diff --git a/tests/compatibility.test.ts b/tests/compatibility.test.ts new file mode 100644 index 0000000..c00efc0 --- /dev/null +++ b/tests/compatibility.test.ts @@ -0,0 +1,35 @@ +import * as uint8arrays from "uint8arrays" +import * as token from "../src/token" + + +const oldUcan = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsInVhdiI6IjEuMC4wIn0.eyJhdWQiOiJkaWQ6a2V5OnoxM1YzU29nMllhVUtoZEdDbWd4OVVadVcxbzFTaEZKWWM2RHZHWWU3TlR0Njg5Tm9MMXRrZUd3NGMydGFQa2dBdWloUjh0cmg2azg2VHRVaTNIR2ZrNEh1NDg3czNiTWY4V1MzWjJoU3VwRktiNmhnV3VwajFIRzhheUxRdDFmeWJSdThjTGdBMkNKanFRYm16YzRFOEFKU0tKeDNndVFYa2F4c3R2Um5RRGN1eDFkZzhVR1BRS3haN2lLeUFKWkFuQlcyWXJUM2o0TVQxdTJNcWZQWG9RYU01WFZQMk04clBFN0FCSEREOXdMbWlKdjkzUUFDRFR5MllnZkVSS3JualNWaTdFb3RNOFR3NHg3M1pNUXJEQnZRRW01Zm9tTWZVaTZVSmJUTmVaaldDTUJQYllNbXRKUDZQZlRpaWZYZG0zdXprVFg5NnExUkVFOExodkU2Rzg2cUR0Wjg5MzdFYUdXdXFpNkRHVDFvc2FRMUVnR3NFN3Jac2JSdDFLNnRXeTZpYktlNTlKZWtnTWFlNW9XNER2IiwiZXhwIjozMjc0NDE5MTQyMywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6MTNWM1NvZzJZYVVLaGRHQ21neDlVWnVXMW8xU2hGSlljNkR2R1llN05UdDY4OU5vTDJWanZBR2JXdTFrdmZWUWFyVTVWMXBTUnNjOWFwR2h2dDdaODJmUWg1QWE1NW41Zm0zZGs2SnFuTXczZGU4WG91dWZUV2Z1eHpEVkhrSFNGV0sxOW1SWWI4d205d1VwZkxtUWl4QVdtMndFWVZqU2dENEd6YzhVUDlDSjFxMkY4ZXlpVXViMThGbld4Y2djUWhqdXB3OTNxUlMzWDlXUDViemlSYjE4TTZ0Vm8zaUJ4ZUozb2lrRTNaa3RScEtTZDlkcHU5WWNXZFhoeDZDQmY5NTZ1UXhkTDZoTkppNmVMbmZ1eFY2NEhpZU1rZFVoTTJSeThRd3lqZjQ4ZnZWMVhFVU1zeEM5YWFjNEtCcGJONDJHR3U4UmFkRDU3cjZuMWFOc2IyTjU3RkNOYnFIMXVLdHhNTmVHZHJ2QWlUUGRzVjJBRmppczJvN243ajhMNW41YmJ4TFl4VThNVHB3QVphdFpkSiIsIm5iZiI6MTY0MDE5MTQ1NywicHJmIjoiZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0lzSW5WaGRpSTZJakV1TUM0d0luMC5leUpoZFdRaU9pSmthV1E2YTJWNU9ub3hNMVl6VTI5bk1sbGhWVXRvWkVkRGJXZDRPVlZhZFZjeGJ6RlRhRVpLV1dNMlJIWkhXV1UzVGxSME5qZzVUbTlNTWxacWRrRkhZbGQxTVd0MlpsWlJZWEpWTlZZeGNGTlNjMk01WVhCSGFIWjBOMW80TW1aUmFEVkJZVFUxYmpWbWJUTmthelpLY1c1TmR6TmtaVGhZYjNWMVpsUlhablY0ZWtSV1NHdElVMFpYU3pFNWJWSlpZamgzYlRsM1ZYQm1URzFSYVhoQlYyMHlkMFZaVm1wVFowUTBSM3BqT0ZWUU9VTktNWEV5UmpobGVXbFZkV0l4T0VadVYzaGpaMk5SYUdwMWNIYzVNM0ZTVXpOWU9WZFFOV0o2YVZKaU1UaE5OblJXYnpOcFFuaGxTak52YVd0Rk0xcHJkRkp3UzFOa09XUndkVGxaWTFka1dHaDROa05DWmprMU5uVlJlR1JNTm1oT1NtazJaVXh1Wm5WNFZqWTBTR2xsVFd0a1ZXaE5NbEo1T0ZGM2VXcG1ORGhtZGxZeFdFVlZUWE40UXpsaFlXTTBTMEp3WWs0ME1rZEhkVGhTWVdSRU5UZHlObTR4WVU1ellqSk9OVGRHUTA1aWNVZ3hkVXQwZUUxT1pVZGtjblpCYVZSUVpITldNa0ZHYW1sek1tODNiamRxT0V3MWJqVmlZbmhNV1hoVk9FMVVjSGRCV21GMFdtUktJaXdpWlhod0lqb3pNamMwTkRFNU1UUXlNeXdpWm1OMElqcGJYU3dpYVhOeklqb2laR2xrT210bGVUcDZNVE5XTTFOdlp6SlpZVlZMYUdSSFEyMW5lRGxWV25WWE1XOHhVMmhHU2xsak5rUjJSMWxsTjA1VWREWTRPVTV2VERKaE5VcE9hMlI0VmpabWJYVm9WbU5SWkRkSVIycHhkRXBRYVc1WlZWQTRRMUp4Y21veVkyVm5hVTFyT1RKUlNIazJRbWRXT1hveVVGQnJWMkZZU0RkUlRsQmlRekphZEUxNWFXbGFjWGRLUkVOd05sZG9VbkZVUzJodVFtaENUbWQ1WkRkTFJuUTNjRkkyTkhCa1ZIQjZUbXRNUlZKNGFHNTNUVUZqZURKcVJGZFlOelpDVG5SS04xUTFWVXQ0TTIxcWRHWTBaak0wWjJwVGRUaHJkME5UY0V0alFuQTRWV2RwU0hkdllVSkhkREUxVkZjNVUzQlNXVkoxYUZKdk1tdEljVFZ5Y0ROTmRFSnFSa2QyVUdZeVRsTlpZbUUzTmxoSGJYcFhlVEZyZUZOelEySTVUSGhqTW5welEwdG1lSEF5ZUd0VVFqWmtPVVJDUlVwVE5sUnhXbFo1WkhKU05GWmFNVkE1ZFhJeGRGcHBlbk5qYWtWd1kzVlViV1EzV0VRemRYSjZVelpqY0RSdU1sZHdSbFZNYjNsMk5tOW5ibWxaZEVOSGFUVlVlbWxEY2pKT1FWRjNWMEZYY25CMldVMWllbVEyVmt0a2RUVmpaekZZUWxoTVZFNWhUQ0lzSW01aVppSTZNVFkwTURFNU1UTTJNeXdpY0hSaklqb2lVMVZRUlZKZlZWTkZVaUlzSW5Kell5STZJaW9pZlEuQ0k5SjlOLVhUZUxQNEM5WTktUl9TcEE1aE80dHdpNUQxNFpTR2lwUzdjNS1jTlJWTVItc285Z0JZMlQzSFNaTHFmQ2xyMEtlQVJicFk2TFBwSm1NRGQ1ODdvck1TVVRnMndqN043eUNVeksxSWhOazhQMkQ3RGVlSHNxQ1lsTVotdXpjMHBSbnFJb3dPTWl6MVFkbHZXaTZ0UHNxZkZVYnl4bEx1bXRHdjV1a1hqc1FZcmYzdko3aU5DMkJibWotMGhTV25wNTNBN01TQTllLWFXVGpLUWEwSkpXVVVhWG5XS19CNjRaa3NyTWRXdW5mVFNuSE9lR2o3MFRuSXhieVcxbFhodk5pcnhIUV90ZVlKZ2xIZTRBbldEQXdUa2dnaVotdkp0WUhsYnVwQkt4S1YtNm9OMTlXS3dUT3U3QnpPX2QyUHAtWVVyY1RSSS1KZ0F2NUpnIiwicHRjIjoiU1VQRVJfVVNFUiIsInJzYyI6IioifQ.CRLB4gBBHhnsbfUhLALiCfo6mHnHSlEUczyZsWhh9TNjv9UxdgvsSWQsehGIT4XR0jQeYZo2OhasEVaF-Gtt_qqtUQIrducKngd0qzmpfjVbicsQPKVdJjlcTwm9dqhLSEtL195El0oucLzYdqMEZMf-txEmyhCd_Q8CaNExhAnwN32v1salnO6vrAw33ZJID7ZaFmBleoGUXBHQwnkv9_m_P6Fh-UGIKjaOuNmBkGXGn-4irm-eXrne2OPZCoPjhiaf0xTONu4ROrQQYykG8CppvsSXeiylOFY11Ot0sdAlHGSlyZk1_chJ3ud17K9S-CKWK9NtqiMNcUdQGFnNQQ" +const [header, payload, signature] = oldUcan.split(".").map((x, i) => i < 2 ? JSON.parse(uint8arrays.toString(uint8arrays.fromString(x, "base64url"))) : x) + +describe("compatibility", () => { + + it("allows parsing UCANs with 'uav: 1.0.0' into 'ucv: 0.0.1'", async () => { + const ucan = await token.validate(oldUcan, { checkIsExpired: false, checkIsTooEarly: false, checkSignature: false }) + expect(ucan).toEqual({ + header: { + alg: header.alg, // "RS256", + typ: header.typ, // "JWT", + ucv: "0.0.1" // we translate uav: 1.0.0 to ucv: 0.0.1 + }, + payload: { + iss: payload.iss, // "did:key:z13V3Sog2YaUKhdGCmgx9UZuW1o1ShFJYc6DvGYe7NTt689NoL2VjvAGbWu1kvfVQarU5V1pSRsc9apGhvt7Z82fQh5Aa55n5fm3dk6JqnMw3de8XouufTWfuxzDVHkHSFWK19mRYb8wm9wUpfLmQixAWm2wEYVjSgD4Gzc8UP9CJ1q2F8eyiUub18FnWxcgcQhjupw93qRS3X9WP5bziRb18M6tVo3iBxeJ3oikE3ZktRpKSd9dpu9YcWdXhx6CBf956uQxdL6hNJi6eLnfuxV64HieMkdUhM2Ry8Qwyjf48fvV1XEUMsxC9aac4KBpbN42GGu8RadD57r6n1aNsb2N57FCNbqH1uKtxMNeGdrvAiTPdsV2AFjis2o7n7j8L5n5bbxLYxU8MTpwAZatZdJ", + aud: payload.aud, // "did:key:z13V3Sog2YaUKhdGCmgx9UZuW1o1ShFJYc6DvGYe7NTt689NoL1tkeGw4c2taPkgAuihR8trh6k86TtUi3HGfk4Hu487s3bMf8WS3Z2hSupFKb6hgWupj1HG8ayLQt1fybRu8cLgA2CJjqQbmzc4E8AJSKJx3guQXkaxstvRnQDcux1dg8UGPQKxZ7iKyAJZAnBW2YrT3j4MT1u2MqfPXoQaM5XVP2M8rPE7ABHDD9wLmiJv93QACDTy2YgfERKrnjSVi7EotM8Tw4x73ZMQrDBvQEm5fomMfUi6UJbTNeZjWCMBPbYMmtJP6PfTiifXdm3uzkTX96q1REE8LhvE6G86qDtZ8937EaGWuqi6DGT1osaQ1EgGsE7rZsbRt1K6tWy6ibKe59JekgMae5oW4Dv", + nbf: payload.nbf, // 1640191457, + exp: payload.exp, // 32744191423, + att: [{ + rsc: payload.rsc, // "*", + cap: payload.ptc, // "SUPER_USER", + }], + prf: [ + payload.prf, // "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsInVhdiI6IjEuMC4wIn0.eyJhdWQiOiJkaWQ6a2V5OnoxM1YzU29nMllhVUtoZEdDbWd4OVVadVcxbzFTaEZKWWM2RHZHWWU3TlR0Njg5Tm9MMlZqdkFHYld1MWt2ZlZRYXJVNVYxcFNSc2M5YXBHaHZ0N1o4MmZRaDVBYTU1bjVmbTNkazZKcW5NdzNkZThYb3V1ZlRXZnV4ekRWSGtIU0ZXSzE5bVJZYjh3bTl3VXBmTG1RaXhBV20yd0VZVmpTZ0Q0R3pjOFVQOUNKMXEyRjhleWlVdWIxOEZuV3hjZ2NRaGp1cHc5M3FSUzNYOVdQNWJ6aVJiMThNNnRWbzNpQnhlSjNvaWtFM1prdFJwS1NkOWRwdTlZY1dkWGh4NkNCZjk1NnVReGRMNmhOSmk2ZUxuZnV4VjY0SGllTWtkVWhNMlJ5OFF3eWpmNDhmdlYxWEVVTXN4QzlhYWM0S0JwYk40MkdHdThSYWRENTdyNm4xYU5zYjJONTdGQ05icUgxdUt0eE1OZUdkcnZBaVRQZHNWMkFGamlzMm83bjdqOEw1bjViYnhMWXhVOE1UcHdBWmF0WmRKIiwiZXhwIjozMjc0NDE5MTQyMywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6MTNWM1NvZzJZYVVLaGRHQ21neDlVWnVXMW8xU2hGSlljNkR2R1llN05UdDY4OU5vTDJhNUpOa2R4VjZmbXVoVmNRZDdIR2pxdEpQaW5ZVVA4Q1JxcmoyY2VnaU1rOTJRSHk2QmdWOXoyUFBrV2FYSDdRTlBiQzJadE15aWlacXdKRENwNldoUnFUS2huQmhCTmd5ZDdLRnQ3cFI2NHBkVHB6TmtMRVJ4aG53TUFjeDJqRFdYNzZCTnRKN1Q1VUt4M21qdGY0ZjM0Z2pTdThrd0NTcEtjQnA4VWdpSHdvYUJHdDE1VFc5U3BSWVJ1aFJvMmtIcTVycDNNdEJqRkd2UGYyTlNZYmE3NlhHbXpXeTFreFNzQ2I5THhjMnpzQ0tmeHAyeGtUQjZkOURCRUpTNlRxWlZ5ZHJSNFZaMVA5dXIxdFppenNjakVwY3VUbWQ3WEQzdXJ6UzZjcDRuMldwRlVMb3l2Nm9nbmlZdENHaTVUemlDcjJOQVF3V0FXcnB2WU1iemQ2VktkdTVjZzFYQlhMVE5hTCIsIm5iZiI6MTY0MDE5MTM2MywicHRjIjoiU1VQRVJfVVNFUiIsInJzYyI6IioifQ.CI9J9N-XTeLP4C9Y9-R_SpA5hO4twi5D14ZSGipS7c5-cNRVMR-so9gBY2T3HSZLqfClr0KeARbpY6LPpJmMDd587orMSUTg2wj7N7yCUzK1IhNk8P2D7DeeHsqCYlMZ-uzc0pRnqIowOMiz1QdlvWi6tPsqfFUbyxlLumtGv5ukXjsQYrf3vJ7iNC2Bbmj-0hSWnp53A7MSA9e-aWTjKQa0JJWUUaXnWK_B64ZksrMdWunfTSnHOeGj70TnIxbyW1lXhvNirxHQ_teYJglHe4AnWDAwTkggiZ-vJtYHlbupBKxKV-6oN19WKwTOu7BzO_d2Pp-YUrcTRI-JgAv5Jg", + ], + }, + signature // "CRLB4gBBHhnsbfUhLALiCfo6mHnHSlEUczyZsWhh9TNjv9UxdgvsSWQsehGIT4XR0jQeYZo2OhasEVaF-Gtt_qqtUQIrducKngd0qzmpfjVbicsQPKVdJjlcTwm9dqhLSEtL195El0oucLzYdqMEZMf-txEmyhCd_Q8CaNExhAnwN32v1salnO6vrAw33ZJID7ZaFmBleoGUXBHQwnkv9_m_P6Fh-UGIKjaOuNmBkGXGn-4irm-eXrne2OPZCoPjhiaf0xTONu4ROrQQYykG8CppvsSXeiylOFY11Ot0sdAlHGSlyZk1_chJ3ud17K9S-CKWK9NtqiMNcUdQGFnNQQ" + }) + }) + +}) diff --git a/tests/did.test.ts b/tests/did.test.ts index 857d153..66014c0 100644 --- a/tests/did.test.ts +++ b/tests/did.test.ts @@ -4,7 +4,8 @@ describe("publicKeyToDid", () => { it("handles RSA Keys", async () => { const pubkey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnzyis1ZjfNB0bBgKFMSvvkTtwlvBsaJq7S5wA+kzeVOVpVWwkWdVha4s38XM/pa/yr47av7+z3VTmvDRyAHcaT92whREFpLv9cj5lTeJSibyr/Mrm/YtjCZVWgaOYIhwrXwKLqPr/11inWsAkfIytvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47XUu0t8Y0e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWbV6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9MwIDAQAB" - const expectedDid = "did:key:z13V3Sog2YaUKhdGCmgx9UZuW1o1ShFJYc6DvGYe7NTt689NoL2RtpVs65Zw899YrTN9WuxdEEDm54YxWuQHQvcKfkZwa8HTgokHxGDPEmNLhvh69zUMEP4zjuARQ3T8bMUumkSLGpxNe1bfQX624ef45GhWb3S9HM3gvAJ7Qftm8iqnDQVcxwKHjmkV4hveKMTix4bTRhieVHi1oqU4QCVy4QPWpAAympuCP9dAoJFxSP6TNBLY9vPKLazsg7XcFov6UuLWsEaxJ5SomCpDx181mEgW2qTug5oQbrJwExbD9CMgXHLVDE2QgLoQMmgsrPevX57dH715NXC2uY6vo2mYCzRY4KuDRUsrkuYCkewL8q2oK1BEDVvi3Sg8pbC9QYQ5mMiHf8uxiHxTAmPedv8" + // old: const expectedDid = "did:key:z13V3Sog2YaUKhdGCmgx9UZuW1o1ShFJYc6DvGYe7NTt689NoL2RtpVs65Zw899YrTN9WuxdEEDm54YxWuQHQvcKfkZwa8HTgokHxGDPEmNLhvh69zUMEP4zjuARQ3T8bMUumkSLGpxNe1bfQX624ef45GhWb3S9HM3gvAJ7Qftm8iqnDQVcxwKHjmkV4hveKMTix4bTRhieVHi1oqU4QCVy4QPWpAAympuCP9dAoJFxSP6TNBLY9vPKLazsg7XcFov6UuLWsEaxJ5SomCpDx181mEgW2qTug5oQbrJwExbD9CMgXHLVDE2QgLoQMmgsrPevX57dH715NXC2uY6vo2mYCzRY4KuDRUsrkuYCkewL8q2oK1BEDVvi3Sg8pbC9QYQ5mMiHf8uxiHxTAmPedv8" + const expectedDid = "did:key:z4MXj1wBzi9jUstyNvmiK5WLRRL4rr9UvzPxhry1CudCLKWLyMbP1WoTwDfttBTpxDKf5hAJEjqNbeYx2EEvrJmSWHAu7TJRPTrE3QodbMfRvRNRDyYvaN1FSQus2ziS1rWXwAi5Gpc16bY3JwjyLCPJLfdRWHZhRXiay5FWEkfoSKy6aftnzAvqNkKBg2AxgzGMinR6d1WiH4w5mEXFtUeZkeo4uwtRTd8rD9BoVaHVkGwJkksDybE23CsBNXiNfbweFVRcwfTMhcQsTsYhUWDcSC6QE3zt9h4Rsrj7XRYdwYSK5bc1qFRsg5HULKBp2uZ1gcayiW2FqHFcMRjBieC4LnSMSD1AZB1WUncVRbPpVkn1UGhCU" const result = did.publicKeyToDid(pubkey, "rsa") expect(result).toEqual(expectedDid) }) @@ -27,7 +28,7 @@ describe("publicKeyToDid", () => { describe("didToPublicKey", () => { - it("handles RSA Keys", async () => { + it("handles old RSA Keys", async () => { const toDecode = "did:key:z13V3Sog2YaUKhdGCmgx9UZuW1o1ShFJYc6DvGYe7NTt689NoL2RtpVs65Zw899YrTN9WuxdEEDm54YxWuQHQvcKfkZwa8HTgokHxGDPEmNLhvh69zUMEP4zjuARQ3T8bMUumkSLGpxNe1bfQX624ef45GhWb3S9HM3gvAJ7Qftm8iqnDQVcxwKHjmkV4hveKMTix4bTRhieVHi1oqU4QCVy4QPWpAAympuCP9dAoJFxSP6TNBLY9vPKLazsg7XcFov6UuLWsEaxJ5SomCpDx181mEgW2qTug5oQbrJwExbD9CMgXHLVDE2QgLoQMmgsrPevX57dH715NXC2uY6vo2mYCzRY4KuDRUsrkuYCkewL8q2oK1BEDVvi3Sg8pbC9QYQ5mMiHf8uxiHxTAmPedv8" const expectedKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnzyis1ZjfNB0bBgKFMSvvkTtwlvBsaJq7S5wA+kzeVOVpVWwkWdVha4s38XM/pa/yr47av7+z3VTmvDRyAHcaT92whREFpLv9cj5lTeJSibyr/Mrm/YtjCZVWgaOYIhwrXwKLqPr/11inWsAkfIytvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47XUu0t8Y0e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWbV6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9MwIDAQAB" const { publicKey, type } = did.didToPublicKey(toDecode) @@ -35,6 +36,14 @@ describe("didToPublicKey", () => { expect(type).toEqual("rsa") }) + it("handles standardized RSA Keys", async () => { + const toDecode = "did:key:z4MXj1wBzi9jUstyNvmiK5WLRRL4rr9UvzPxhry1CudCLKWLyMbP1WoTwDfttBTpxDKf5hAJEjqNbeYx2EEvrJmSWHAu7TJRPTrE3QodbMfRvRNRDyYvaN1FSQus2ziS1rWXwAi5Gpc16bY3JwjyLCPJLfdRWHZhRXiay5FWEkfoSKy6aftnzAvqNkKBg2AxgzGMinR6d1WiH4w5mEXFtUeZkeo4uwtRTd8rD9BoVaHVkGwJkksDybE23CsBNXiNfbweFVRcwfTMhcQsTsYhUWDcSC6QE3zt9h4Rsrj7XRYdwYSK5bc1qFRsg5HULKBp2uZ1gcayiW2FqHFcMRjBieC4LnSMSD1AZB1WUncVRbPpVkn1UGhCU" + const expectedKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnzyis1ZjfNB0bBgKFMSvvkTtwlvBsaJq7S5wA+kzeVOVpVWwkWdVha4s38XM/pa/yr47av7+z3VTmvDRyAHcaT92whREFpLv9cj5lTeJSibyr/Mrm/YtjCZVWgaOYIhwrXwKLqPr/11inWsAkfIytvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47XUu0t8Y0e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWbV6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9MwIDAQAB" + const { publicKey, type } = did.didToPublicKey(toDecode) + expect(publicKey).toEqual(expectedKey) + expect(type).toEqual("rsa") + }) + it("handles Ed25519 Keys", async () => { const toDecode = "did:key:z6MkgYGF3thn8k1Fv4p4dWXKtsXCnLH7q9yw4QgNPULDmDKB" const expectedKey = "Hv+AVRD2WUjUFOsSNbsmrp9fokuwrUnjBcr92f0kxw4=" diff --git a/tests/rsa.test.ts b/tests/rsa.test.ts index 998f0f7..70950ce 100644 --- a/tests/rsa.test.ts +++ b/tests/rsa.test.ts @@ -1,11 +1,15 @@ +import * as fc from "fast-check" +import * as uint8arrays from "uint8arrays" import * as did from "../src/did" +import * as rsaCrypto from "../src/crypto/rsa" import RSAKeypair from "../src/keypair/rsa" + describe("rsa", () => { let keypair: RSAKeypair let signature: Uint8Array - const data = new Uint8Array([1,2,3,4,5,6,7,8,9]) + const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]) it("creates an rsa keypair", async () => { keypair = await RSAKeypair.create() @@ -29,3 +33,64 @@ describe("rsa", () => { }) }) + +describe("ASN", () => { + + describe("asn1DERLengthEncode/Decode", () => { + + it("works with simple examples", () => { + // 82 - bigger than 127 & 2 length octets + // 01 - 1 * 256^1 + + // b3 - 179 * 256^0 + // = 435 + // Example from https://en.wikipedia.org/wiki/X.690#Length_octets + expect(uint8arrays.toString(rsaCrypto.asn1DERLengthEncode(435), "hex")).toEqual("8201b3") + }) + + it("round-trips", () => { + fc.assert(fc.property(fc.nat(), n => { + expect(rsaCrypto.asn1DERLengthDecode(rsaCrypto.asn1DERLengthEncode(n))).toEqual(n) + })) + }) + + it("encodes in a simple way until 127", () => { + for (let i = 0; i < 128; i++) { + expect(`Encoded ${i}: ${uint8arrays.toString(rsaCrypto.asn1DERLengthEncode(i), "hex")}`) + .toEqual(`Encoded ${i}: ${uint8arrays.toString(new Uint8Array([i]), "hex")}`) + } + }) + }) + + describe("SPKI/PKCS1 conversion", () => { + + it("round trips with webcrypto-generated spki keys", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom(1024, 2048, 3072, 4096), + async size => { + const key = await rsaCrypto.generateKeypair(size) + if (key.publicKey == null) { + expect(key.publicKey).toBeDefined() + throw "public key is undefined" + } + const spki = await rsaCrypto.exportKey(key.publicKey) + const converted = + rsaCrypto.convertRSAPublicKeyToSubjectPublicKeyInfo( + rsaCrypto.convertSubjectPublicKeyInfoToRSAPublicKey( + spki + ) + ) + + // I find hex dumps the most readable when it comes to ASN1 + expect(uint8arrays.toString(converted, "hex")).toEqual(uint8arrays.toString(spki, "hex")) + } + ), + { + numRuns: 5, // unfortunately, generating rsa keys is quite slow. Let's try to reliably keep below the 5s timeout + examples: [[1024], [2048], [3072], [4096]], // ensure we're testing each variant at least once + } + ) + }) + + }) +}) diff --git a/yarn.lock b/yarn.lock index 0b5f008..b90695a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1402,6 +1402,13 @@ expect@^27.2.0: jest-message-util "^27.2.0" jest-regex-util "^27.0.6" +fast-check@^2.20.0: + version "2.20.0" + resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-2.20.0.tgz#0c88d8640649e981adb501ef92f90a26dc8bd628" + integrity sha512-tFNjLyPnOUg6iimVxOtoWMJOIyybCo7B8gUGm1yv43jDCQ0hlPUn0fmna/XO/n1yPxn/dxQw3+IygPSbMDiiog== + dependencies: + pure-rand "^5.0.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -2600,6 +2607,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pure-rand@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-5.0.0.tgz#87f5bdabeadbd8904e316913a5c0b8caac517b37" + integrity sha512-lD2/y78q+7HqBx2SaT6OT4UcwtvXNRfEpzYEzl0EQ+9gZq2Qi3fa0HDnYPeqQwhlHJFBUhT7AO3mLU3+8bynHA== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"