edwards: change API. Add options.strict, context. Add edwardsToMontgomery
This commit is contained in:
parent
65c0dc6c59
commit
113d906233
@ -18,7 +18,7 @@ export type CurveType = BasicCurve<bigint> & {
|
|||||||
adjustScalarBytes?: (bytes: Uint8Array) => Uint8Array; // clears bits to get valid field elemtn
|
adjustScalarBytes?: (bytes: Uint8Array) => Uint8Array; // clears bits to get valid field elemtn
|
||||||
domain?: (data: Uint8Array, ctx: Uint8Array, phflag: boolean) => Uint8Array; // Used for hashing
|
domain?: (data: Uint8Array, ctx: Uint8Array, phflag: boolean) => Uint8Array; // Used for hashing
|
||||||
uvRatio?: (u: bigint, v: bigint) => { isValid: boolean; value: bigint }; // Ratio √(u/v)
|
uvRatio?: (u: bigint, v: bigint) => { isValid: boolean; value: bigint }; // Ratio √(u/v)
|
||||||
preHash?: FHash; // RFC 8032 pre-hashing of messages to sign() / verify()
|
prehash?: FHash; // RFC 8032 pre-hashing of messages to sign() / verify()
|
||||||
mapToCurve?: (scalar: bigint[]) => AffinePoint<bigint>; // for hash-to-curve standard
|
mapToCurve?: (scalar: bigint[]) => AffinePoint<bigint>; // for hash-to-curve standard
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -90,7 +90,15 @@ export type CurveFn = {
|
|||||||
// It is not generic twisted curve for now, but ed25519/ed448 generic implementation
|
// It is not generic twisted curve for now, but ed25519/ed448 generic implementation
|
||||||
export function twistedEdwards(curveDef: CurveType): CurveFn {
|
export function twistedEdwards(curveDef: CurveType): CurveFn {
|
||||||
const CURVE = validateOpts(curveDef) as ReturnType<typeof validateOpts>;
|
const CURVE = validateOpts(curveDef) as ReturnType<typeof validateOpts>;
|
||||||
const { Fp, n: CURVE_ORDER, preHash, hash: cHash, randomBytes, nByteLength, h: cofactor } = CURVE;
|
const {
|
||||||
|
Fp,
|
||||||
|
n: CURVE_ORDER,
|
||||||
|
prehash: preHash,
|
||||||
|
hash: cHash,
|
||||||
|
randomBytes,
|
||||||
|
nByteLength,
|
||||||
|
h: cofactor,
|
||||||
|
} = CURVE;
|
||||||
const MASK = _2n ** BigInt(nByteLength * 8);
|
const MASK = _2n ** BigInt(nByteLength * 8);
|
||||||
const modP = Fp.create; // Function overrides
|
const modP = Fp.create; // Function overrides
|
||||||
|
|
||||||
@ -423,29 +431,30 @@ export function twistedEdwards(curveDef: CurveType): CurveFn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Signs message with privateKey. RFC8032 5.1.6 */
|
/** Signs message with privateKey. RFC8032 5.1.6 */
|
||||||
function sign(msg: Hex, privKey: Hex, context?: Hex): Uint8Array {
|
function sign(msg: Hex, privKey: Hex, options: { context?: Hex } = {}): Uint8Array {
|
||||||
msg = ensureBytes('message', msg);
|
msg = ensureBytes('message', msg);
|
||||||
if (preHash) msg = preHash(msg); // for ed25519ph etc.
|
if (preHash) msg = preHash(msg); // for ed25519ph etc.
|
||||||
const { prefix, scalar, pointBytes } = getExtendedPublicKey(privKey);
|
const { prefix, scalar, pointBytes } = getExtendedPublicKey(privKey);
|
||||||
const r = hashDomainToScalar(context, prefix, msg); // r = dom2(F, C) || prefix || PH(M)
|
const r = hashDomainToScalar(options.context, prefix, msg); // r = dom2(F, C) || prefix || PH(M)
|
||||||
const R = G.multiply(r).toRawBytes(); // R = rG
|
const R = G.multiply(r).toRawBytes(); // R = rG
|
||||||
const k = hashDomainToScalar(context, R, pointBytes, msg); // R || A || PH(M)
|
const k = hashDomainToScalar(options.context, R, pointBytes, msg); // R || A || PH(M)
|
||||||
const s = modN(r + k * scalar); // S = (r + k * s) mod L
|
const s = modN(r + k * scalar); // S = (r + k * s) mod L
|
||||||
assertGE0(s); // 0 <= s < l
|
assertGE0(s); // 0 <= s < l
|
||||||
const res = ut.concatBytes(R, ut.numberToBytesLE(s, Fp.BYTES));
|
const res = ut.concatBytes(R, ut.numberToBytesLE(s, Fp.BYTES));
|
||||||
return ensureBytes('result', res, nByteLength * 2); // 64-byte signature
|
return ensureBytes('result', res, nByteLength * 2); // 64-byte signature
|
||||||
}
|
}
|
||||||
|
|
||||||
function verify(sig: Hex, msg: Hex, publicKey: Hex, context?: Hex): boolean {
|
const verifyOpts: { context?: Hex; strict?: boolean } = { strict: false };
|
||||||
|
function verify(sig: Hex, msg: Hex, publicKey: Hex, options = verifyOpts): boolean {
|
||||||
const len = Fp.BYTES; // Verifies EdDSA signature against message and public key. RFC8032 5.1.7.
|
const len = Fp.BYTES; // Verifies EdDSA signature against message and public key. RFC8032 5.1.7.
|
||||||
sig = ensureBytes('signature', sig, 2 * len); // An extended group equation is checked.
|
sig = ensureBytes('signature', sig, 2 * len); // An extended group equation is checked.
|
||||||
msg = ensureBytes('message', msg); // ZIP215 compliant, which means not fully RFC8032 compliant.
|
msg = ensureBytes('message', msg); // ZIP215 compliant, which means not fully RFC8032 compliant.
|
||||||
if (preHash) msg = preHash(msg); // for ed25519ph, etc
|
if (preHash) msg = preHash(msg); // for ed25519ph, etc
|
||||||
const A = Point.fromHex(publicKey, false); // Check for s bounds, hex validity
|
const A = Point.fromHex(publicKey, false); // Check for s bounds, hex validity
|
||||||
const R = Point.fromHex(sig.slice(0, len), false); // 0 <= R < 2^256: ZIP215 R can be >= P
|
const R = Point.fromHex(sig.slice(0, len), options.strict); // R <P (RFC8032) or <2^256 (ZIP215)
|
||||||
const s = ut.bytesToNumberLE(sig.slice(len, 2 * len));
|
const s = ut.bytesToNumberLE(sig.slice(len, 2 * len));
|
||||||
const SB = G.multiplyUnsafe(s); // 0 <= s < l is done inside
|
const SB = G.multiplyUnsafe(s); // 0 <= s < l is done inside
|
||||||
const k = hashDomainToScalar(context, R.toRawBytes(), A.toRawBytes(), msg);
|
const k = hashDomainToScalar(options.context, R.toRawBytes(), A.toRawBytes(), msg);
|
||||||
const RkA = R.add(A.multiplyUnsafe(k));
|
const RkA = R.add(A.multiplyUnsafe(k));
|
||||||
// [8][S]B = [8]R + [8][k]A'
|
// [8][S]B = [8]R + [8][k]A'
|
||||||
return RkA.subtract(SB).clearCofactor().equals(Point.ZERO);
|
return RkA.subtract(SB).clearCofactor().equals(Point.ZERO);
|
||||||
|
@ -95,7 +95,7 @@ export const ED25519_TORSION_SUBGROUP = [
|
|||||||
|
|
||||||
const Fp = Field(ED25519_P, undefined, true);
|
const Fp = Field(ED25519_P, undefined, true);
|
||||||
|
|
||||||
const ED25519_DEF = {
|
const ed25519Defaults = {
|
||||||
// Param: a
|
// Param: a
|
||||||
a: BigInt(-1),
|
a: BigInt(-1),
|
||||||
// Equal to -121665/121666 over finite field.
|
// Equal to -121665/121666 over finite field.
|
||||||
@ -120,7 +120,7 @@ const ED25519_DEF = {
|
|||||||
uvRatio,
|
uvRatio,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ed25519 = twistedEdwards(ED25519_DEF);
|
export const ed25519 = twistedEdwards(ed25519Defaults);
|
||||||
function ed25519_domain(data: Uint8Array, ctx: Uint8Array, phflag: boolean) {
|
function ed25519_domain(data: Uint8Array, ctx: Uint8Array, phflag: boolean) {
|
||||||
if (ctx.length > 255) throw new Error('Context is too big');
|
if (ctx.length > 255) throw new Error('Context is too big');
|
||||||
return concatBytes(
|
return concatBytes(
|
||||||
@ -130,11 +130,11 @@ function ed25519_domain(data: Uint8Array, ctx: Uint8Array, phflag: boolean) {
|
|||||||
data
|
data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export const ed25519ctx = twistedEdwards({ ...ED25519_DEF, domain: ed25519_domain });
|
export const ed25519ctx = twistedEdwards({ ...ed25519Defaults, domain: ed25519_domain });
|
||||||
export const ed25519ph = twistedEdwards({
|
export const ed25519ph = twistedEdwards({
|
||||||
...ED25519_DEF,
|
...ed25519Defaults,
|
||||||
domain: ed25519_domain,
|
domain: ed25519_domain,
|
||||||
preHash: sha512,
|
prehash: sha512,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const x25519 = montgomery({
|
export const x25519 = montgomery({
|
||||||
@ -153,6 +153,20 @@ export const x25519 = montgomery({
|
|||||||
randomBytes,
|
randomBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts ed25519 public key to x25519 public key. Uses formula:
|
||||||
|
* * `(u, v) = ((1+y)/(1-y), sqrt(-486664)*u/x)`
|
||||||
|
* * `(x, y) = (sqrt(-486664)*u/v, (u-1)/(u+1))`
|
||||||
|
* @example
|
||||||
|
* const aPub = ed25519.getPublicKey(utils.randomPrivateKey());
|
||||||
|
* x25519.getSharedSecret(edwardsToMontgomery(aPub), edwardsToMontgomery(someonesPub))
|
||||||
|
*/
|
||||||
|
export function edwardsToMontgomery(edwardsPub: Hex): Uint8Array {
|
||||||
|
const { y } = ed25519.ExtendedPoint.fromHex(edwardsPub);
|
||||||
|
const _1n = BigInt(1);
|
||||||
|
return Fp.toBytes(Fp.create((y - _1n) * Fp.inv(y + _1n)));
|
||||||
|
}
|
||||||
|
|
||||||
// Hash To Curve Elligator2 Map (NOTE: different from ristretto255 elligator)
|
// Hash To Curve Elligator2 Map (NOTE: different from ristretto255 elligator)
|
||||||
// NOTE: very important part is usage of FpSqrtEven for ELL2_C1_EDWARDS, since
|
// NOTE: very important part is usage of FpSqrtEven for ELL2_C1_EDWARDS, since
|
||||||
// SageMath returns different root first and everything falls apart
|
// SageMath returns different root first and everything falls apart
|
||||||
|
30
src/ed448.ts
30
src/ed448.ts
@ -120,7 +120,7 @@ const ED448_DEF = {
|
|||||||
|
|
||||||
export const ed448 = twistedEdwards(ED448_DEF);
|
export const ed448 = twistedEdwards(ED448_DEF);
|
||||||
// NOTE: there is no ed448ctx, since ed448 supports ctx by default
|
// NOTE: there is no ed448ctx, since ed448 supports ctx by default
|
||||||
export const ed448ph = twistedEdwards({ ...ED448_DEF, preHash: shake256_64 });
|
export const ed448ph = twistedEdwards({ ...ED448_DEF, prehash: shake256_64 });
|
||||||
|
|
||||||
export const x448 = montgomery({
|
export const x448 = montgomery({
|
||||||
a: BigInt(156326),
|
a: BigInt(156326),
|
||||||
@ -136,22 +136,22 @@ export const x448 = montgomery({
|
|||||||
},
|
},
|
||||||
adjustScalarBytes,
|
adjustScalarBytes,
|
||||||
randomBytes,
|
randomBytes,
|
||||||
// The 4-isogeny maps between the Montgomery curve and this Edwards
|
|
||||||
// curve are:
|
|
||||||
// (u, v) = (y^2/x^2, (2 - x^2 - y^2)*y/x^3)
|
|
||||||
// (x, y) = (4*v*(u^2 - 1)/(u^4 - 2*u^2 + 4*v^2 + 1),
|
|
||||||
// -(u^5 - 2*u^3 - 4*u*v^2 + u)/
|
|
||||||
// (u^5 - 2*u^2*v^2 - 2*u^3 - 2*v^2 + u))
|
|
||||||
// xyToU: (p: PointType) => {
|
|
||||||
// const P = ed448P;
|
|
||||||
// const { x, y } = p;
|
|
||||||
// if (x === _0n) throw new Error(`Point with x=0 doesn't have mapping`);
|
|
||||||
// const invX = invert(x * x, P); // x^2
|
|
||||||
// const u = mod(y * y * invX, P); // (y^2/x^2)
|
|
||||||
// return numberToBytesLE(u, 56);
|
|
||||||
// },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts edwards448 public key to x448 public key. Uses formula:
|
||||||
|
* * `(u, v) = ((y-1)/(y+1), sqrt(156324)*u/x)`
|
||||||
|
* * `(x, y) = (sqrt(156324)*u/v, (1+u)/(1-u))`
|
||||||
|
* @example
|
||||||
|
* const aPub = ed448.getPublicKey(utils.randomPrivateKey());
|
||||||
|
* x448.getSharedSecret(edwardsToMontgomery(aPub), edwardsToMontgomery(someonesPub))
|
||||||
|
*/
|
||||||
|
export function edwardsToMontgomery(edwardsPub: string | Uint8Array): Uint8Array {
|
||||||
|
const { y } = ed448.ExtendedPoint.fromHex(edwardsPub);
|
||||||
|
const _1n = BigInt(1);
|
||||||
|
return Fp.toBytes(Fp.create((y - _1n) * Fp.inv(y + _1n)));
|
||||||
|
}
|
||||||
|
|
||||||
// Hash To Curve Elligator2 Map
|
// Hash To Curve Elligator2 Map
|
||||||
const ELL2_C1 = (Fp.ORDER - BigInt(3)) / BigInt(4); // 1. c1 = (q - 3) / 4 # Integer arithmetic
|
const ELL2_C1 = (Fp.ORDER - BigInt(3)) / BigInt(4); // 1. c1 = (q - 3) / 4 # Integer arithmetic
|
||||||
const ELL2_J = BigInt(156326);
|
const ELL2_J = BigInt(156326);
|
||||||
|
@ -62,8 +62,14 @@ describe('RFC8032ctx', () => {
|
|||||||
const v = VECTORS_RFC8032_CTX[i];
|
const v = VECTORS_RFC8032_CTX[i];
|
||||||
should(`${i}`, () => {
|
should(`${i}`, () => {
|
||||||
deepStrictEqual(hex(ed25519ctx.getPublicKey(v.secretKey)), v.publicKey);
|
deepStrictEqual(hex(ed25519ctx.getPublicKey(v.secretKey)), v.publicKey);
|
||||||
deepStrictEqual(hex(ed25519ctx.sign(v.message, v.secretKey, v.context)), v.signature);
|
deepStrictEqual(
|
||||||
deepStrictEqual(ed25519ctx.verify(v.signature, v.message, v.publicKey, v.context), true);
|
hex(ed25519ctx.sign(v.message, v.secretKey, { context: v.context })),
|
||||||
|
v.signature
|
||||||
|
);
|
||||||
|
deepStrictEqual(
|
||||||
|
ed25519ctx.verify(v.signature, v.message, v.publicKey, { context: v.context }),
|
||||||
|
true
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -93,14 +99,7 @@ describe('RFC8032ph', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// x25519
|
// x25519
|
||||||
should('X25519 base point', () => {
|
describe('RFC7748 X25519 ECDH', () => {
|
||||||
const { y } = ed25519ph.ExtendedPoint.BASE;
|
|
||||||
const { Fp } = ed25519ph.CURVE;
|
|
||||||
const u = Fp.create((y + 1n) * Fp.inv(1n - y));
|
|
||||||
deepStrictEqual(numberToBytesLE(u, 32), x25519.GuBytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RFC7748', () => {
|
|
||||||
const rfc7748Mul = [
|
const rfc7748Mul = [
|
||||||
{
|
{
|
||||||
scalar: 'a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4',
|
scalar: 'a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4',
|
||||||
@ -127,7 +126,7 @@ describe('RFC7748', () => {
|
|||||||
];
|
];
|
||||||
for (let i = 0; i < rfc7748Iter.length; i++) {
|
for (let i = 0; i < rfc7748Iter.length; i++) {
|
||||||
const { scalar, iters } = rfc7748Iter[i];
|
const { scalar, iters } = rfc7748Iter[i];
|
||||||
should(`scalarMult iteration (${i})`, () => {
|
should(`scalarMult iteration x${iters}`, () => {
|
||||||
let k = x25519.GuBytes;
|
let k = x25519.GuBytes;
|
||||||
for (let i = 0, u = k; i < iters; i++) [k, u] = [x25519.scalarMult(k, u), k];
|
for (let i = 0, u = k; i < iters; i++) [k, u] = [x25519.scalarMult(k, u), k];
|
||||||
deepStrictEqual(hex(k), scalar);
|
deepStrictEqual(hex(k), scalar);
|
||||||
@ -145,10 +144,16 @@ describe('RFC7748', () => {
|
|||||||
deepStrictEqual(hex(x25519.scalarMult(alicePrivate, bobPublic)), shared);
|
deepStrictEqual(hex(x25519.scalarMult(alicePrivate, bobPublic)), shared);
|
||||||
deepStrictEqual(hex(x25519.scalarMult(bobPrivate, alicePublic)), shared);
|
deepStrictEqual(hex(x25519.scalarMult(bobPrivate, alicePublic)), shared);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
describe('Wycheproof', () => {
|
should('base point', () => {
|
||||||
|
const { y } = ed25519ph.ExtendedPoint.BASE;
|
||||||
|
const { Fp } = ed25519ph.CURVE;
|
||||||
|
const u = Fp.create((y + 1n) * Fp.inv(1n - y));
|
||||||
|
deepStrictEqual(numberToBytesLE(u, 32), x25519.GuBytes);
|
||||||
|
});
|
||||||
|
|
||||||
const group = x25519vectors.testGroups[0];
|
const group = x25519vectors.testGroups[0];
|
||||||
should(`X25519`, () => {
|
should('wycheproof', () => {
|
||||||
for (let i = 0; i < group.tests.length; i++) {
|
for (let i = 0; i < group.tests.length; i++) {
|
||||||
const v = group.tests[i];
|
const v = group.tests[i];
|
||||||
const comment = `(${i}, ${v.result}) ${v.comment}`;
|
const comment = `(${i}, ${v.result}) ${v.comment}`;
|
||||||
|
@ -513,8 +513,11 @@ describe('ed448', () => {
|
|||||||
const v = VECTORS_RFC8032_CTX[i];
|
const v = VECTORS_RFC8032_CTX[i];
|
||||||
should(`${i}`, () => {
|
should(`${i}`, () => {
|
||||||
deepStrictEqual(hex(ed.getPublicKey(v.secretKey)), v.publicKey);
|
deepStrictEqual(hex(ed.getPublicKey(v.secretKey)), v.publicKey);
|
||||||
deepStrictEqual(hex(ed.sign(v.message, v.secretKey, v.context)), v.signature);
|
deepStrictEqual(hex(ed.sign(v.message, v.secretKey, { context: v.context })), v.signature);
|
||||||
deepStrictEqual(ed.verify(v.signature, v.message, v.publicKey, v.context), true);
|
deepStrictEqual(
|
||||||
|
ed.verify(v.signature, v.message, v.publicKey, { context: v.context }),
|
||||||
|
true
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -559,8 +562,14 @@ describe('ed448', () => {
|
|||||||
const v = VECTORS_RFC8032_PH[i];
|
const v = VECTORS_RFC8032_PH[i];
|
||||||
should(`${i}`, () => {
|
should(`${i}`, () => {
|
||||||
deepStrictEqual(hex(ed448ph.getPublicKey(v.secretKey)), v.publicKey);
|
deepStrictEqual(hex(ed448ph.getPublicKey(v.secretKey)), v.publicKey);
|
||||||
deepStrictEqual(hex(ed448ph.sign(v.message, v.secretKey, v.context)), v.signature);
|
deepStrictEqual(
|
||||||
deepStrictEqual(ed448ph.verify(v.signature, v.message, v.publicKey, v.context), true);
|
hex(ed448ph.sign(v.message, v.secretKey, { context: v.context })),
|
||||||
|
v.signature
|
||||||
|
);
|
||||||
|
deepStrictEqual(
|
||||||
|
ed448ph.verify(v.signature, v.message, v.publicKey, { context: v.context }),
|
||||||
|
true
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -661,34 +670,32 @@ describe('ed448', () => {
|
|||||||
deepStrictEqual(hex(x448.scalarMult(bobPrivate, alicePublic)), shared);
|
deepStrictEqual(hex(x448.scalarMult(bobPrivate, alicePublic)), shared);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('wycheproof', () => {
|
should('wycheproof', () => {
|
||||||
const group = x448vectors.testGroups[0];
|
const group = x448vectors.testGroups[0];
|
||||||
should(`X448`, () => {
|
for (let i = 0; i < group.tests.length; i++) {
|
||||||
for (let i = 0; i < group.tests.length; i++) {
|
const v = group.tests[i];
|
||||||
const v = group.tests[i];
|
const index = `(${i}, ${v.result}) ${v.comment}`;
|
||||||
const index = `(${i}, ${v.result}) ${v.comment}`;
|
if (v.result === 'valid' || v.result === 'acceptable') {
|
||||||
if (v.result === 'valid' || v.result === 'acceptable') {
|
try {
|
||||||
try {
|
const shared = hex(x448.scalarMult(v.private, v.public));
|
||||||
const shared = hex(x448.scalarMult(v.private, v.public));
|
deepStrictEqual(shared, v.shared, index);
|
||||||
deepStrictEqual(shared, v.shared, index);
|
} catch (e) {
|
||||||
} catch (e) {
|
// We are more strict
|
||||||
// We are more strict
|
if (e.message.includes('Expected valid scalar')) return;
|
||||||
if (e.message.includes('Expected valid scalar')) return;
|
if (e.message.includes('Invalid private or public key received')) return;
|
||||||
if (e.message.includes('Invalid private or public key received')) return;
|
if (e.message.includes('Expected 56 bytes')) return;
|
||||||
if (e.message.includes('Expected 56 bytes')) return;
|
throw e;
|
||||||
throw e;
|
}
|
||||||
}
|
} else if (v.result === 'invalid') {
|
||||||
} else if (v.result === 'invalid') {
|
let failed = false;
|
||||||
let failed = false;
|
try {
|
||||||
try {
|
x448.scalarMult(v.private, v.public);
|
||||||
x448.scalarMult(v.private, v.public);
|
} catch (error) {
|
||||||
} catch (error) {
|
failed = true;
|
||||||
failed = true;
|
}
|
||||||
}
|
deepStrictEqual(failed, true, index);
|
||||||
deepStrictEqual(failed, true, index);
|
} else throw new Error('unknown test result');
|
||||||
} else throw new Error('unknown test result');
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
should('have proper base point', () => {
|
should('have proper base point', () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user