156 lines
6.4 KiB
TypeScript
156 lines
6.4 KiB
TypeScript
import { secp256k1 } from "@suaveplan/crypto-curve";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import fc from "fast-check";
|
|
import { packPayload, unpackPayload } from "../src/codec/payload/payload.js";
|
|
import { LICENSE_SCHEMA } from "../src/constants/constants.js";
|
|
import { generateLicense } from "../src/generate/generate.js";
|
|
import {
|
|
type LicensePayload,
|
|
LicenseValidationError,
|
|
TimeEncodingType,
|
|
} from "../src/types/license.js";
|
|
import { validateLicense } from "../src/validate/validate.js";
|
|
|
|
const payloadArb = fc.record({
|
|
version: fc.integer({ min: 0, max: LICENSE_SCHEMA.version }),
|
|
signingKeyId: fc.integer({ min: 0, max: LICENSE_SCHEMA.signingKeyId }),
|
|
productId: fc.integer({ min: 0, max: LICENSE_SCHEMA.productId }),
|
|
packageTier: fc.integer({ min: 0, max: LICENSE_SCHEMA.packageTier }),
|
|
flags: fc.integer({ min: 0, max: LICENSE_SCHEMA.flags }),
|
|
timeEncodingType: fc
|
|
.integer({ min: 0, max: LICENSE_SCHEMA.timeEncodingType })
|
|
.map((v) => v as TimeEncodingType),
|
|
timeValue: fc.integer({ min: 0, max: LICENSE_SCHEMA.timeValue }),
|
|
featureMask: fc.integer({ min: 0, max: LICENSE_SCHEMA.featureMask }),
|
|
nonce: fc.integer({ min: 0, max: LICENSE_SCHEMA.nonce }),
|
|
});
|
|
|
|
function fresh() {
|
|
const s = secp256k1.randomPrivateKey();
|
|
const p = secp256k1.getPublicKey(s);
|
|
return { signingKey: { s, p }, publicKey: { p } };
|
|
}
|
|
|
|
describe("property tests", () => {
|
|
it("unpackPayload inverts packPayload", { timeout: 30000 }, () => {
|
|
fc.assert(
|
|
fc.property(payloadArb, (payload: LicensePayload) => {
|
|
const bytes = packPayload(payload);
|
|
expect(bytes.length).toBe(9);
|
|
const decoded = unpackPayload(bytes);
|
|
expect(decoded).toEqual(payload);
|
|
}),
|
|
{ numRuns: 200 }
|
|
);
|
|
});
|
|
|
|
it("1-byte mutation surfaces as INVALID_SIGNATURE or MALFORMED", {
|
|
timeout: 60000,
|
|
}, async () => {
|
|
const { signingKey, publicKey } = fresh();
|
|
// Sample once at the boundary; we mutate the generated key's
|
|
// characters for the actual property under test.
|
|
const payload: LicensePayload = {
|
|
version: 2,
|
|
signingKeyId: 0,
|
|
productId: 1,
|
|
packageTier: 0,
|
|
flags: 0,
|
|
timeEncodingType: TimeEncodingType.NONE,
|
|
timeValue: 0,
|
|
featureMask: 0,
|
|
nonce: 0x111,
|
|
};
|
|
const r = await generateLicense(payload, {
|
|
signingKey,
|
|
useFullSignature: false,
|
|
});
|
|
const validPubKeys = new Map([[0, publicKey]]);
|
|
|
|
const keyLen = r.key.length;
|
|
// The Base32 encoding of 29 bytes uses 47 chars; chars after
|
|
// that are padding bits that may not affect decoded bytes.
|
|
// We bound the tamper-index so it always lands on a
|
|
// semantically-significant character.
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.integer({ min: 0, max: keyLen - 2 }),
|
|
fc.integer({ min: 0, max: 31 }),
|
|
async (idx, alphabetIdx) => {
|
|
const chars = r.key.split("");
|
|
if (chars[idx] === "-") return true; // skip dashes
|
|
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
const newChar = ALPHABET[alphabetIdx] as string;
|
|
if (chars[idx] === newChar) return true; // no-op mutation
|
|
chars[idx] = newChar;
|
|
const tampered = chars.join("");
|
|
const v = await validateLicense(tampered, {
|
|
publicKeys: validPubKeys,
|
|
});
|
|
// Base32 has trailing padding bits; a character
|
|
// change in the final bit-positions may decode to
|
|
// the same bytes, producing a valid key. That is
|
|
// acceptable — we only need to assert that NO tamper
|
|
// produces a *different* valid license that the
|
|
// validator wrongly trusts as the original payload.
|
|
if (v.success) {
|
|
return (
|
|
v.details.nonce === payload.nonce &&
|
|
v.details.productId === payload.productId
|
|
);
|
|
}
|
|
return [
|
|
LicenseValidationError.INVALID_SIGNATURE,
|
|
LicenseValidationError.MALFORMED,
|
|
LicenseValidationError.INVALID_ENCODING,
|
|
LicenseValidationError.UNKNOWN_SIGNING_KEY,
|
|
LicenseValidationError.UNSUPPORTED_VERSION,
|
|
LicenseValidationError.EXPIRED,
|
|
].includes(v.error);
|
|
}
|
|
),
|
|
{ numRuns: 200 }
|
|
);
|
|
});
|
|
|
|
it("expired trial always fails validation", {
|
|
timeout: 60000,
|
|
}, async () => {
|
|
const { signingKey, publicKey } = fresh();
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.integer({ min: 1, max: 365 }),
|
|
async (durationDays) => {
|
|
const payload: LicensePayload = {
|
|
version: 2,
|
|
signingKeyId: 0,
|
|
productId: 1,
|
|
packageTier: 0,
|
|
flags: 1, // TRIAL
|
|
timeEncodingType: TimeEncodingType.RELATIVE_DAYS,
|
|
timeValue: durationDays,
|
|
featureMask: 0,
|
|
nonce: 7,
|
|
};
|
|
const r = await generateLicense(payload, {
|
|
signingKey,
|
|
useFullSignature: false,
|
|
});
|
|
const creation = new Date("2026-01-01T00:00:00Z");
|
|
const past =
|
|
creation.getTime() + (durationDays + 1) * 86_400_000;
|
|
const v = await validateLicense(r.key, {
|
|
publicKeys: new Map([[0, publicKey]]),
|
|
creationDate: creation,
|
|
referenceDate: new Date(past),
|
|
});
|
|
return (
|
|
!v.success && v.error === LicenseValidationError.EXPIRED
|
|
);
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
);
|
|
});
|
|
});
|