feat: Implement EdDSA and ECDH-ES mechanisms
This commit is contained in:
parent
d1f5cbfb63
commit
fa0f5ebaca
|
@ -0,0 +1,166 @@
|
|||
import crypto from "crypto";
|
||||
import { AsnParser } from "@peculiar/asn1-schema";
|
||||
import { JsonParser, JsonSerializer } from "@peculiar/json-schema";
|
||||
import { Convert } from "pvtsutils";
|
||||
import * as core from "webcrypto-core";
|
||||
import { CryptoKey } from "../../keys";
|
||||
import { EdPrivateKey } from "./private_key";
|
||||
import { EdPublicKey } from "./public_key";
|
||||
|
||||
export class EdCrypto {
|
||||
|
||||
public static publicKeyUsages = ["verify"];
|
||||
public static privateKeyUsages = ["sign", "deriveKey", "deriveBits"];
|
||||
|
||||
public static async generateKey(algorithm: EcKeyGenParams, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKeyPair> {
|
||||
const privateKey = new EdPrivateKey();
|
||||
privateKey.algorithm = algorithm;
|
||||
privateKey.extractable = extractable;
|
||||
privateKey.usages = keyUsages.filter((usage) => this.privateKeyUsages.indexOf(usage) !== -1);
|
||||
|
||||
const publicKey = new EdPublicKey();
|
||||
publicKey.algorithm = algorithm;
|
||||
publicKey.extractable = true;
|
||||
publicKey.usages = keyUsages.filter((usage) => this.publicKeyUsages.indexOf(usage) !== -1);
|
||||
|
||||
const type = algorithm.namedCurve.toLowerCase() as "x448"; // "x448" | "ed448" | "x25519" | "ed25519"
|
||||
const keys = crypto.generateKeyPairSync(type, {
|
||||
publicKeyEncoding: {
|
||||
format: "der",
|
||||
type: "spki",
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
},
|
||||
});
|
||||
|
||||
privateKey.data = keys.privateKey;
|
||||
publicKey.data = keys.publicKey;
|
||||
|
||||
const res: CryptoKeyPair = {
|
||||
privateKey,
|
||||
publicKey,
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public static async sign(algorithm: Algorithm, key: EdPrivateKey, data: Uint8Array): Promise<ArrayBuffer> {
|
||||
if (!key.pem) {
|
||||
key.pem = `-----BEGIN PRIVATE KEY-----\n${key.data.toString("base64")}\n-----END PRIVATE KEY-----`;
|
||||
}
|
||||
const options = {
|
||||
key: key.pem,
|
||||
};
|
||||
const signature = crypto.sign(null, Buffer.from(data), options);
|
||||
|
||||
return core.BufferSourceConverter.toArrayBuffer(signature);
|
||||
}
|
||||
|
||||
public static async verify(algorithm: EcdsaParams, key: EdPublicKey, signature: Uint8Array, data: Uint8Array): Promise<boolean> {
|
||||
if (!key.pem) {
|
||||
key.pem = `-----BEGIN PUBLIC KEY-----\n${key.data.toString("base64")}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
const options = {
|
||||
key: key.pem,
|
||||
};
|
||||
const ok = crypto.verify(null, Buffer.from(data), options, Buffer.from(signature));
|
||||
return ok;
|
||||
}
|
||||
|
||||
public static async deriveBits(algorithm: EcdhKeyDeriveParams, baseKey: CryptoKey, length: number): Promise<ArrayBuffer> {
|
||||
const publicKey = crypto.createPublicKey({
|
||||
key: (algorithm.public as CryptoKey).data,
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
const privateKey = crypto.createPrivateKey({
|
||||
key: baseKey.data,
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
const bits = crypto.diffieHellman({
|
||||
publicKey,
|
||||
privateKey,
|
||||
});
|
||||
|
||||
return new Uint8Array(bits).buffer.slice(0, length >> 3);
|
||||
}
|
||||
|
||||
public static async exportKey(format: KeyFormat, key: CryptoKey): Promise<JsonWebKey | ArrayBuffer> {
|
||||
switch (format.toLowerCase()) {
|
||||
case "jwk":
|
||||
return JsonSerializer.toJSON(key);
|
||||
case "pkcs8":
|
||||
case "spki":
|
||||
return new Uint8Array(key.data).buffer;
|
||||
case "raw": {
|
||||
const publicKeyInfo = AsnParser.parse(key.data, core.asn1.PublicKeyInfo);
|
||||
return publicKeyInfo.publicKey;
|
||||
}
|
||||
default:
|
||||
throw new core.OperationError("format: Must be 'jwk', 'raw', pkcs8' or 'spki'");
|
||||
}
|
||||
}
|
||||
|
||||
public static async importKey(format: KeyFormat, keyData: JsonWebKey | ArrayBuffer, algorithm: EcKeyImportParams, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKey> {
|
||||
switch (format.toLowerCase()) {
|
||||
case "jwk": {
|
||||
const jwk = keyData as JsonWebKey;
|
||||
if (jwk.d) {
|
||||
const asnKey = JsonParser.fromJSON(keyData, { targetSchema: core.asn1.CurvePrivateKey });
|
||||
return this.importPrivateKey(asnKey, algorithm, extractable, keyUsages);
|
||||
} else {
|
||||
if (!jwk.x) {
|
||||
throw new TypeError("keyData: Cannot get required 'x' filed");
|
||||
}
|
||||
return this.importPublicKey(Convert.FromBase64Url(jwk.x), algorithm, extractable, keyUsages);
|
||||
}
|
||||
}
|
||||
case "raw": {
|
||||
return this.importPublicKey(keyData as ArrayBuffer, algorithm, extractable, keyUsages);
|
||||
}
|
||||
case "spki": {
|
||||
const keyInfo = AsnParser.parse(new Uint8Array(keyData as ArrayBuffer), core.asn1.PublicKeyInfo);
|
||||
return this.importPublicKey(keyInfo.publicKey, algorithm, extractable, keyUsages);
|
||||
}
|
||||
case "pkcs8": {
|
||||
const keyInfo = AsnParser.parse(new Uint8Array(keyData as ArrayBuffer), core.asn1.PrivateKeyInfo);
|
||||
const asnKey = AsnParser.parse(keyInfo.privateKey, core.asn1.CurvePrivateKey);
|
||||
return this.importPrivateKey(asnKey, algorithm, extractable, keyUsages);
|
||||
}
|
||||
default:
|
||||
throw new core.OperationError("format: Must be 'jwk', 'raw', 'pkcs8' or 'spki'");
|
||||
}
|
||||
}
|
||||
|
||||
protected static importPrivateKey(asnKey: core.asn1.CurvePrivateKey, algorithm: EcKeyImportParams, extractable: boolean, keyUsages: KeyUsage[]) {
|
||||
const key = new EdPrivateKey();
|
||||
key.fromJSON({
|
||||
crv: algorithm.namedCurve,
|
||||
d: Convert.ToBase64Url(asnKey.d),
|
||||
});
|
||||
|
||||
key.algorithm = Object.assign({}, algorithm) as EcKeyAlgorithm;
|
||||
key.extractable = extractable;
|
||||
key.usages = keyUsages;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
protected static async importPublicKey(asnKey: ArrayBuffer, algorithm: EcKeyImportParams, extractable: boolean, keyUsages: KeyUsage[]) {
|
||||
const key = new EdPublicKey();
|
||||
key.fromJSON({
|
||||
crv: algorithm.namedCurve,
|
||||
x: Convert.ToBase64Url(asnKey),
|
||||
});
|
||||
|
||||
key.algorithm = Object.assign({}, algorithm) as EcKeyAlgorithm;
|
||||
key.extractable = extractable;
|
||||
key.usages = keyUsages;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import * as core from "webcrypto-core";
|
||||
import { CryptoKey } from "../../keys";
|
||||
import { getCryptoKey, setCryptoKey } from "../storage";
|
||||
import { EdCrypto } from "./crypto";
|
||||
|
||||
export class EcdhEsProvider extends core.EcdhEsProvider {
|
||||
|
||||
public async onGenerateKey(algorithm: EcKeyGenParams, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKeyPair> {
|
||||
const keys = await EdCrypto.generateKey(
|
||||
{
|
||||
name: this.name,
|
||||
namedCurve: algorithm.namedCurve.toUpperCase(),
|
||||
},
|
||||
extractable,
|
||||
keyUsages);
|
||||
|
||||
return {
|
||||
privateKey: setCryptoKey(keys.privateKey as CryptoKey),
|
||||
publicKey: setCryptoKey(keys.publicKey as CryptoKey),
|
||||
};
|
||||
}
|
||||
|
||||
public async onDeriveBits(algorithm: EcdhKeyDeriveParams, baseKey: core.CryptoKey, length: number): Promise<ArrayBuffer> {
|
||||
const bits = await EdCrypto.deriveBits({...algorithm, public: getCryptoKey(algorithm.public)}, getCryptoKey(baseKey), length);
|
||||
return bits;
|
||||
}
|
||||
|
||||
public async onExportKey(format: KeyFormat, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey> {
|
||||
return EdCrypto.exportKey(format, getCryptoKey(key));
|
||||
}
|
||||
|
||||
public async onImportKey(format: KeyFormat, keyData: ArrayBuffer | JsonWebKey, algorithm: EcKeyImportParams, extractable: boolean, keyUsages: KeyUsage[]): Promise<core.CryptoKey> {
|
||||
const key = await EdCrypto.importKey(format, keyData, { ...algorithm, name: this.name }, extractable, keyUsages);
|
||||
return setCryptoKey(key);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import * as core from "webcrypto-core";
|
||||
import { CryptoKey } from "../../keys";
|
||||
import { getCryptoKey, setCryptoKey } from "../storage";
|
||||
import { EdCrypto } from "./crypto";
|
||||
import { EdPrivateKey } from "./private_key";
|
||||
import { EdPublicKey } from "./public_key";
|
||||
|
||||
export class EdDsaProvider extends core.EdDsaProvider {
|
||||
|
||||
public async onGenerateKey(algorithm: EcKeyGenParams, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKeyPair> {
|
||||
const keys = await EdCrypto.generateKey(
|
||||
{
|
||||
name: this.name,
|
||||
namedCurve: algorithm.namedCurve.replace(/^ed/i, "Ed"),
|
||||
},
|
||||
extractable,
|
||||
keyUsages);
|
||||
|
||||
return {
|
||||
privateKey: setCryptoKey(keys.privateKey as CryptoKey),
|
||||
publicKey: setCryptoKey(keys.publicKey as CryptoKey),
|
||||
};
|
||||
}
|
||||
|
||||
public async onSign(algorithm: EcdsaParams, key: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
return EdCrypto.sign(algorithm, getCryptoKey(key) as EdPrivateKey, new Uint8Array(data));
|
||||
}
|
||||
|
||||
public async onVerify(algorithm: EcdsaParams, key: CryptoKey, signature: ArrayBuffer, data: ArrayBuffer): Promise<boolean> {
|
||||
return EdCrypto.verify(algorithm, getCryptoKey(key) as EdPublicKey, new Uint8Array(signature), new Uint8Array(data));
|
||||
}
|
||||
|
||||
public async onExportKey(format: KeyFormat, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey> {
|
||||
return EdCrypto.exportKey(format, getCryptoKey(key));
|
||||
}
|
||||
|
||||
public async onImportKey(format: KeyFormat, keyData: ArrayBuffer | JsonWebKey, algorithm: EcKeyImportParams, extractable: boolean, keyUsages: KeyUsage[]): Promise<core.CryptoKey> {
|
||||
const key = await EdCrypto.importKey(format, keyData, { ...algorithm, name: this.name }, extractable, keyUsages);
|
||||
return setCryptoKey(key);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import * as core from "webcrypto-core";
|
||||
|
||||
const edOIDs: { [key: string]: string } = {
|
||||
// Ed448
|
||||
[core.asn1.idEd448]: "Ed448",
|
||||
"ed448": core.asn1.idEd448,
|
||||
// X448
|
||||
[core.asn1.idX448]: "X448",
|
||||
"x448": core.asn1.idX448,
|
||||
// Ed25519
|
||||
[core.asn1.idEd25519]: "Ed25519",
|
||||
"ed25519": core.asn1.idEd25519,
|
||||
// X25519
|
||||
[core.asn1.idX25519]: "X25519",
|
||||
"x25519": core.asn1.idX25519,
|
||||
};
|
||||
|
||||
export function getNamedCurveByOid(oid: string) {
|
||||
const namedCurve = edOIDs[oid];
|
||||
if (!namedCurve) {
|
||||
throw new core.OperationError(`Cannot convert OID(${oid}) to WebCrypto named curve`);
|
||||
}
|
||||
return namedCurve;
|
||||
}
|
||||
|
||||
export function getOidByNamedCurve(namedCurve: string) {
|
||||
const oid = edOIDs[namedCurve.toLowerCase()];
|
||||
if (!oid) {
|
||||
throw new core.OperationError(`Cannot convert WebCrypto named curve '${namedCurve}' to OID`);
|
||||
}
|
||||
return oid;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./eddsa";
|
||||
export * from "./ecdh_es";
|
||||
export * from "./private_key";
|
||||
export * from "./public_key";
|
|
@ -0,0 +1,44 @@
|
|||
import { AsnParser, AsnSerializer } from "@peculiar/asn1-schema";
|
||||
import { IJsonConvertible, JsonParser, JsonSerializer } from "@peculiar/json-schema";
|
||||
import * as core from "webcrypto-core";
|
||||
import { AsymmetricKey } from "../../keys";
|
||||
import { getOidByNamedCurve } from "./helper";
|
||||
|
||||
export class EdPrivateKey extends AsymmetricKey implements IJsonConvertible {
|
||||
public readonly type: "private" = "private";
|
||||
public algorithm!: EcKeyAlgorithm;
|
||||
|
||||
public getKey() {
|
||||
const keyInfo = AsnParser.parse(this.data, core.asn1.PrivateKeyInfo);
|
||||
return AsnParser.parse(keyInfo.privateKey, core.asn1.CurvePrivateKey);
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
const key = this.getKey();
|
||||
|
||||
const json: JsonWebKey = {
|
||||
kty: "OKP",
|
||||
crv: this.algorithm.namedCurve,
|
||||
key_ops: this.usages,
|
||||
ext: this.extractable,
|
||||
};
|
||||
|
||||
return Object.assign(json, JsonSerializer.toJSON(key));
|
||||
}
|
||||
|
||||
public fromJSON(json: JsonWebKey) {
|
||||
if (!json.crv) {
|
||||
throw new core.OperationError(`Cannot get named curve from JWK. Property 'crv' is required`);
|
||||
}
|
||||
|
||||
const keyInfo = new core.asn1.PrivateKeyInfo();
|
||||
keyInfo.privateKeyAlgorithm.algorithm = getOidByNamedCurve(json.crv);
|
||||
const key = JsonParser.fromJSON(json, { targetSchema: core.asn1.CurvePrivateKey });
|
||||
keyInfo.privateKey = AsnSerializer.serialize(key);
|
||||
|
||||
this.data = Buffer.from(AsnSerializer.serialize(keyInfo));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { AsnParser, AsnSerializer } from "@peculiar/asn1-schema";
|
||||
import { IJsonConvertible } from "@peculiar/json-schema";
|
||||
import { Convert } from "pvtsutils";
|
||||
import * as core from "webcrypto-core";
|
||||
import { AsymmetricKey } from "../../keys/asymmetric";
|
||||
import { getOidByNamedCurve } from "./helper";
|
||||
|
||||
export class EdPublicKey extends AsymmetricKey implements IJsonConvertible {
|
||||
|
||||
public readonly type: "public" = "public";
|
||||
public algorithm!: EcKeyAlgorithm;
|
||||
|
||||
public getKey() {
|
||||
const keyInfo = AsnParser.parse(this.data, core.asn1.PublicKeyInfo);
|
||||
return keyInfo.publicKey;
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
const key = this.getKey();
|
||||
|
||||
const json: JsonWebKey = {
|
||||
kty: "OKP",
|
||||
crv: this.algorithm.namedCurve,
|
||||
key_ops: this.usages,
|
||||
ext: this.extractable,
|
||||
};
|
||||
|
||||
return Object.assign(json, {
|
||||
x: Convert.ToBase64Url(key)
|
||||
});
|
||||
}
|
||||
|
||||
public fromJSON(json: JsonWebKey) {
|
||||
if (!json.crv) {
|
||||
throw new core.OperationError(`Cannot get named curve from JWK. Property 'crv' is required`);
|
||||
}
|
||||
if (!json.x) {
|
||||
throw new core.OperationError(`Cannot get property from JWK. Property 'x' is required`);
|
||||
}
|
||||
|
||||
const keyInfo = new core.asn1.PublicKeyInfo();
|
||||
keyInfo.publicKeyAlgorithm.algorithm = getOidByNamedCurve(json.crv);
|
||||
keyInfo.publicKey = Convert.FromBase64Url(json.x);
|
||||
|
||||
this.data = Buffer.from(AsnSerializer.serialize(keyInfo));
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ export * from "./aes";
|
|||
export * from "./des";
|
||||
export * from "./rsa";
|
||||
export * from "./ec";
|
||||
export * from "./ed";
|
||||
export * from "./sha";
|
||||
export * from "./pbkdf";
|
||||
export * from "./hmac";
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import * as process from "process";
|
||||
import * as core from "webcrypto-core";
|
||||
import {
|
||||
AesCbcProvider, AesCmacProvider, AesCtrProvider, AesEcbProvider, AesGcmProvider, AesKwProvider,
|
||||
DesCbcProvider,
|
||||
DesEde3CbcProvider, EcdhProvider,
|
||||
EcdsaProvider, HkdfProvider,
|
||||
EdDsaProvider,
|
||||
EcdhEsProvider,
|
||||
HmacProvider,
|
||||
Pbkdf2Provider,
|
||||
RsaEsProvider, RsaOaepProvider, RsaPssProvider, RsaSsaProvider,
|
||||
|
@ -58,5 +61,16 @@ export class SubtleCrypto extends core.SubtleCrypto {
|
|||
//#region HKDF
|
||||
this.providers.set(new HkdfProvider());
|
||||
//#endregion
|
||||
|
||||
const nodeMajorVersion = /^v(\d+)/.exec(process.version)?.[1];
|
||||
if (nodeMajorVersion && parseInt(nodeMajorVersion, 10) >= 14) {
|
||||
//#region EdDSA
|
||||
this.providers.set(new EdDsaProvider());
|
||||
//#endregion
|
||||
|
||||
//#region ECDH-ES
|
||||
this.providers.set(new EcdhEsProvider());
|
||||
//#endregion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,14 +47,73 @@ context("Crypto", () => {
|
|||
info: new Uint8Array([1, 2, 3, 4, 5]),
|
||||
salt: new Uint8Array([1, 2, 3, 4, 5]),
|
||||
} as globalThis.HkdfParams,
|
||||
hkdf,
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-1",
|
||||
} as globalThis.HmacImportParams,
|
||||
false,
|
||||
["sign"]);
|
||||
hkdf,
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-1",
|
||||
} as globalThis.HmacImportParams,
|
||||
false,
|
||||
["sign"]);
|
||||
assert.strictEqual((hmac.algorithm as globalThis.HmacKeyAlgorithm).length, 512);
|
||||
});
|
||||
|
||||
context("EdDSA", () => {
|
||||
|
||||
context("generateKey", () => {
|
||||
|
||||
it("Ed25519", async () => {
|
||||
const keys = await crypto.subtle.generateKey({ name: "eddsa", namedCurve: "ed25519" } as globalThis.EcKeyGenParams, false, ["sign", "verify"]) as CryptoKeyPair;
|
||||
assert.strictEqual(keys.privateKey.algorithm.name, "EdDSA");
|
||||
assert.strictEqual((keys.privateKey.algorithm as EcKeyAlgorithm).namedCurve, "Ed25519");
|
||||
});
|
||||
|
||||
it("Ed448", async () => {
|
||||
const keys = await crypto.subtle.generateKey({ name: "eddsa", namedCurve: "ed448" } as globalThis.EcKeyGenParams, true, ["sign", "verify"]) as CryptoKeyPair;
|
||||
assert.strictEqual(keys.privateKey.algorithm.name, "EdDSA");
|
||||
assert.strictEqual((keys.privateKey.algorithm as EcKeyAlgorithm).namedCurve, "Ed448");
|
||||
|
||||
const data = await crypto.subtle.exportKey("jwk", keys.privateKey);
|
||||
assert.strictEqual(data.kty, "OKP");
|
||||
assert.strictEqual(data.crv, "Ed448");
|
||||
assert.strictEqual(!!data.d, true);
|
||||
const privateKey = await crypto.subtle.importKey("jwk", data, { name: "eddsa", namedCurve: "ed448" } as EcKeyImportParams, false, ["sign"]);
|
||||
|
||||
const message = Buffer.from("message");
|
||||
const signature = await crypto.subtle.sign({ name: "EdDSA" }, privateKey, message);
|
||||
const ok = await crypto.subtle.verify({ name: "EdDSA" }, keys.publicKey, signature, message);
|
||||
assert.strictEqual(ok, true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
context("ECDH-ES", () => {
|
||||
|
||||
context("generateKey", () => {
|
||||
|
||||
it("X25519", async () => {
|
||||
const keys = await crypto.subtle.generateKey({ name: "ecdh-es", namedCurve: "x25519" } as globalThis.EcKeyGenParams, false, ["deriveBits", "deriveKey"]) as CryptoKeyPair;
|
||||
assert.strictEqual(keys.privateKey.algorithm.name, "ECDH-ES");
|
||||
assert.strictEqual((keys.privateKey.algorithm as EcKeyAlgorithm).namedCurve, "X25519");
|
||||
});
|
||||
|
||||
it("X448", async () => {
|
||||
const keys = await crypto.subtle.generateKey({ name: "ecdh-es", namedCurve: "x448" } as globalThis.EcKeyGenParams, true, ["deriveBits", "deriveKey"]) as CryptoKeyPair;
|
||||
assert.strictEqual(keys.privateKey.algorithm.name, "ECDH-ES");
|
||||
assert.strictEqual((keys.privateKey.algorithm as EcKeyAlgorithm).namedCurve, "X448");
|
||||
|
||||
const bits = await crypto.subtle.deriveBits({ name: "ECDH-ES", public: keys.publicKey } as globalThis.EcdhKeyDeriveParams, keys.privateKey, 256);
|
||||
assert.strictEqual(bits.byteLength, 32);
|
||||
|
||||
const data = await crypto.subtle.exportKey("jwk", keys.publicKey);
|
||||
assert.strictEqual(data.kty, "OKP");
|
||||
assert.strictEqual(data.crv, "X448");
|
||||
assert.strictEqual(!!data.x, true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
|
Reference in New Issue