Initial Commit
This commit is contained in:
178
packages/security-stack/security/license/README.md
Normal file
178
packages/security-stack/security/license/README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# @suaveplan/license - Compact, offline-verifiable, secp256k1-signed software-license-key format: 72-bit bitpacked payload (version + signing-key id + product id + tier + flags + time encoding + time value + feature mask + nonce) plus full or truncated (20-byte) signature, Crockford-Base32 encoded with signature-derived obfuscation. suaveplan-enterprise additions: SuaveplanError-typed errors, OTel counters/histograms/spans, Zod boundary validation, injectable Clock/Rng, AbortSignal propagation, pluggable RevocationStore. Universal.
|
||||
|
||||
Compact, offline-verifiable, secp256k1-signed software-license-key
|
||||
format. Bit-for-bit wire-format compatible with upstream
|
||||
`@suaveplan/license`. runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [base32](./src/codec/base32/base32.md) — Crockford-style Base32 codec used to serialise the packed payload and signature into a printable license-key string. Th…
|
||||
- [base32](./src/codec/base32/base32.md) — Crockford-style Base32 codec used to serialise the packed payload and signature into a printable license-key string. Th…
|
||||
- [bitpack](./src/codec/bitpack/bitpack.md) — Bit-precise pack/unpack primitives used to serialise the 72-bit license payload into 9 bytes. Plain `DataView` / typed-…
|
||||
- [bitpack](./src/codec/bitpack/bitpack.md) — Bit-precise pack/unpack primitives used to serialise the 72-bit license payload into 9 bytes. Plain `DataView` / typed-…
|
||||
- [obfuscation](./src/codec/obfuscation/obfuscation.md) — Per-key character obfuscation pass run after Base32 encoding. The first 15 non-dash characters of the encoded key (whic…
|
||||
- [obfuscation](./src/codec/obfuscation/obfuscation.md) — Per-key character obfuscation pass run after Base32 encoding. The first 15 non-dash characters of the encoded key (whic…
|
||||
- [payload](./src/codec/payload/payload.md) — `packPayload` and `unpackPayload` are the 72-bit payload codec on top of the bitpack primitives. They emit exactly 9 by…
|
||||
- [payload](./src/codec/payload/payload.md) — `packPayload` and `unpackPayload` are the 72-bit payload codec on top of the bitpack primitives. They emit exactly 9 by…
|
||||
- [constants](./src/constants/constants.md) — Wire-format constants for the license package. Every value here is part of the binary contract: the per-field b…
|
||||
- [constants](./src/constants/constants.md) — Wire-format constants for the license package. Every value here is part of the binary contract: the per-field b…
|
||||
- [flags](./src/flags/flags.md) — Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the 19-bit `featureMask` field. The flags field stores…
|
||||
- [flags](./src/flags/flags.md) — Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the 19-bit `featureMask` field. The flags field stores…
|
||||
- [generate](./src/generate/generate.md) — Top-level license-key generation, payload construction, and parse-only inspection. This is the place that brings togeth…
|
||||
- [generate](./src/generate/generate.md) — Top-level license-key generation, payload construction, and parse-only inspection. This is the place that brings togeth…
|
||||
- [manager](./src/manager/manager.md) — High-level `LicenseManager` class that bundles a public-key registry, an optional signing key, default `signingKeyId` /…
|
||||
- [manager](./src/manager/manager.md) — High-level `LicenseManager` class that bundles a public-key registry, an optional signing key, default `signingKeyId` /…
|
||||
- [otel](./src/otel/instruments.md) — OpenTelemetry instrumentation for the license package. Registers a single `@opentelemetry/api` meter (`@suaveplan/licen…
|
||||
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrumentation for the license package. Registers a single `@opentelemetry/api` meter (`@suaveplan/licen…
|
||||
- [revocation-store](./src/revocation/revocation-store/revocation-store.md) — Pluggable revocation blocklist for the license package. The `RevocationStore` interface defines three methods (`add`, `…
|
||||
- [revocation-store](./src/revocation/revocation-store/revocation-store.md) — Pluggable revocation blocklist for the license package. The `RevocationStore` interface defines three methods (`add`, `…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary in the license package. `generateLicense`, `validateLicense`, `createPayloa…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary in the license package. `generateLicense`, `validateLicense`, `createPayloa…
|
||||
- [time](./src/time/time.md) — Time-encoding helpers for the four `TimeEncodingType` variants. The 72-bit payload encodes time in 18 bits with an addi…
|
||||
- [time](./src/time/time.md) — Time-encoding helpers for the four `TimeEncodingType` variants. The 72-bit payload encodes time in 18 bits with an addi…
|
||||
- [types](./src/types/license.md) — Public type surface for the license package, ported verbatim from the upstream `index.types.ts` at the wire-format leve…
|
||||
- [license](./src/types/license.md) — Public type surface for the license package, ported verbatim from the upstream `index.types.ts` at the wire-format leve…
|
||||
- [validate](./src/validate/validate.md) — License-key validation: parse, verify signature, check version, check expiration, surface every failure as a typed enum…
|
||||
- [validate](./src/validate/validate.md) — License-key validation: parse, verify signature, check version, check expiration, surface every failure as a typed enum…
|
||||
- [error-codes](./src/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/license`. It exposes `LICENSE_CODES` (a frozen `defineC…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Highlights
|
||||
|
||||
- **72-bit bitpacked payload** (version + signing-key id + product id
|
||||
- tier + flags + time encoding + time value + feature mask + nonce)
|
||||
encoded as 9 bytes.
|
||||
- **Truncated 20-byte signature by default** keeps keys ~50-60
|
||||
characters; opt into 64-byte full signatures with
|
||||
`useFullSignature: true`.
|
||||
- **Crockford-style Base32 alphabet** (no `I`/`L`/`O`/`U` — paste-
|
||||
friendly).
|
||||
- **Signature-derived obfuscation pass** rotates the first 15 payload
|
||||
characters by per-license offsets drawn from the signature.
|
||||
- **Four time-encoding strategies**: `NONE`, `ABSOLUTE_DAYS`,
|
||||
`RELATIVE_DAYS`, `ABSOLUTE_TIMESTAMP`.
|
||||
- **19-bit feature mask** + 5-bit flag bitfield (TRIAL, UPGRADE,
|
||||
MACHINE_LOCKED, SUBSCRIPTION, EDUCATIONAL).
|
||||
- **Enterprise extras**: typed `LicenseError` registry, OTel
|
||||
counters/histograms, Zod boundary validation, injectable `Clock` /
|
||||
`Rng`, `AbortSignal` propagation, pluggable `RevocationStore`.
|
||||
|
||||
## Quick-start
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { LicenseManager, PackageTier } from "@suaveplan/license";
|
||||
|
||||
// 1. Generate a signing key (typically done once, off-line)
|
||||
const secret = secp256k1.randomPrivateKey();
|
||||
const publicKey = { p: secp256k1.getPublicKey(secret) };
|
||||
|
||||
// 2. Bind the manager to that key
|
||||
const m = new LicenseManager({
|
||||
signingKey: { s: secret, p: publicKey.p },
|
||||
publicKeys: { 0: publicKey },
|
||||
});
|
||||
|
||||
// 3. Mint a license key
|
||||
const { key } = await m.generate({
|
||||
productId: 5,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 365,
|
||||
});
|
||||
|
||||
// 4. Hand the key to the user, validate it on incoming requests
|
||||
const v = await m.validate(key);
|
||||
if (v.success) {
|
||||
console.log(`Tier ${v.details.packageTier}, expires ${v.details.expirationDate}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
Every feature module is a tree-shakeable subpath export:
|
||||
|
||||
| Module | Subpath | Docs |
|
||||
|---|---|---|
|
||||
| Error codes | `./error-codes/error-codes` | [src/error-codes.md](src/error-codes.md) |
|
||||
| Constants | `./constants/constants` | [src/constants/constants.md](src/constants/constants.md) |
|
||||
| Types + enums | `./types/license` | [src/types/license.md](src/types/license.md) |
|
||||
| Base32 codec | `./codec/base32/base32` | [src/codec/base32/base32.md](src/codec/base32/base32.md) |
|
||||
| Bitpack | `./codec/bitpack/bitpack` | [src/codec/bitpack/bitpack.md](src/codec/bitpack/bitpack.md) |
|
||||
| Payload codec | `./codec/payload/payload` | [src/codec/payload/payload.md](src/codec/payload/payload.md) |
|
||||
| Obfuscation | `./codec/obfuscation/obfuscation` | [src/codec/obfuscation/obfuscation.md](src/codec/obfuscation/obfuscation.md) |
|
||||
| Time helpers | `./time/time` | [src/time/time.md](src/time/time.md) |
|
||||
| Flag helpers | `./flags/flags` | [src/flags/flags.md](src/flags/flags.md) |
|
||||
| Zod schemas | `./schemas/schemas` | [src/schemas/schemas.md](src/schemas/schemas.md) |
|
||||
| OTel instruments | `./otel/instruments` | [src/otel/instruments.md](src/otel/instruments.md) |
|
||||
| Generate | `./generate/generate` | [src/generate/generate.md](src/generate/generate.md) |
|
||||
| Validate | `./validate/validate` | [src/validate/validate.md](src/validate/validate.md) |
|
||||
| Manager | `./manager/manager` | [src/manager/manager.md](src/manager/manager.md) |
|
||||
| Revocation | `./revocation/revocation-store/revocation-store` | [src/revocation/revocation-store/revocation-store.md](src/revocation/revocation-store/revocation-store.md) |
|
||||
|
||||
The package root (`.`) re-exports every public symbol from each module
|
||||
for ergonomic single-import usage.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Captured with `mitata` on Intel(R) Xeon(R) CPU E5-2696 v4 @ 2.20GHz,
|
||||
Bun 1.3.14 (Linux x64). Run yourself with
|
||||
`bun src/__benchmarks__/license.bench.ts`.
|
||||
|
||||
| Benchmark | Mean | p75 | p99 |
|
||||
|---|---|---|---|
|
||||
| `base32Encode` (73 bytes) | 38.09 µs | 42.82 µs | 134.18 µs |
|
||||
| `base32Decode` (73 bytes) | 65.06 µs | 70.70 µs | 189.76 µs |
|
||||
| `packPayload` | 733.58 ns | 744.46 ns | 1.37 µs |
|
||||
| `unpackPayload` | 1.16 µs | 1.18 µs | 1.75 µs |
|
||||
| `generateLicense` (truncated) | 67.64 µs | 73.84 µs | 242.36 µs |
|
||||
| `validateLicense` (truncated) | 40.52 µs | 42.73 µs | 116.04 µs |
|
||||
|
||||
A full generate + validate round-trip completes in ~108 µs on this
|
||||
hardware, comfortably below the 1 ms p99 budget targeted by the
|
||||
upstream enterprise audit.
|
||||
|
||||
## Revocation
|
||||
|
||||
Revocation is an opt-in pluggable layer. Wire any store implementing
|
||||
the `RevocationStore` interface (sync or async); the package ships a
|
||||
synchronous in-memory store with optional LRU eviction.
|
||||
|
||||
```ts
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
LicenseManager,
|
||||
} from "@suaveplan/license";
|
||||
|
||||
const store = createMemoryRevocationStore({ maxEntries: 100_000 });
|
||||
const m = new LicenseManager({ signingKey, publicKeys, revocationStore: store });
|
||||
|
||||
// Block a compromised key
|
||||
await m.revoke(fingerprintLicense(compromisedKey));
|
||||
|
||||
// Subsequent validates of that key return { error: 'REVOKED' }
|
||||
```
|
||||
|
||||
## Wire format
|
||||
|
||||
The on-the-wire layout is unchanged from upstream `@suaveplan/license`.
|
||||
Each 9-byte payload packs nine fields MSB-first:
|
||||
|
||||
```text
|
||||
| v(3) | kid(5) | pid(6) | tier(4) | flg(5) | te(2) | timeValue(18) | featureMask(19) | nonce(10) |
|
||||
total = 72 bits
|
||||
```
|
||||
|
||||
A complete key encodes `payload || signature` (29 bytes with truncated
|
||||
signature, 73 bytes with full signature) as Base32, dashes inserted
|
||||
every 5 chars for legibility, then runs the signature-derived
|
||||
obfuscation pass over the first 15 payload chars. The
|
||||
seed-from-key-region trick (chars 16-30 carry the obfuscation seed)
|
||||
breaks the circular dependency between "decode needs the seed" and
|
||||
"the seed comes from a decoded signature".
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
155
packages/security-stack/security/license/package.json
Normal file
155
packages/security-stack/security/license/package.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"name": "@suaveplan/license",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Compact, offline-verifiable, secp256k1-signed software-license-key format: 72-bit bitpacked payload (version + signing-key id + product id + tier + flags + time encoding + time value + feature mask + nonce) plus full or truncated (20-byte) signature, Crockford-Base32 encoded with signature-derived obfuscation. Genesis-enterprise additions: SuaveplanError-typed errors, OTel counters/histograms/spans, Zod boundary validation, injectable Clock/Rng, AbortSignal propagation, pluggable RevocationStore. Universal.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes.d.ts",
|
||||
"default": "./dist/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./constants/constants": {
|
||||
"import": {
|
||||
"types": "./dist/constants/constants.d.ts",
|
||||
"default": "./dist/constants/constants.js"
|
||||
}
|
||||
},
|
||||
"./types/license": {
|
||||
"import": {
|
||||
"types": "./dist/types/license.d.ts",
|
||||
"default": "./dist/types/license.js"
|
||||
}
|
||||
},
|
||||
"./codec/base32/base32": {
|
||||
"import": {
|
||||
"types": "./dist/codec/base32/base32.d.ts",
|
||||
"default": "./dist/codec/base32/base32.js"
|
||||
}
|
||||
},
|
||||
"./codec/bitpack/bitpack": {
|
||||
"import": {
|
||||
"types": "./dist/codec/bitpack/bitpack.d.ts",
|
||||
"default": "./dist/codec/bitpack/bitpack.js"
|
||||
}
|
||||
},
|
||||
"./codec/payload/payload": {
|
||||
"import": {
|
||||
"types": "./dist/codec/payload/payload.d.ts",
|
||||
"default": "./dist/codec/payload/payload.js"
|
||||
}
|
||||
},
|
||||
"./codec/obfuscation/obfuscation": {
|
||||
"import": {
|
||||
"types": "./dist/codec/obfuscation/obfuscation.d.ts",
|
||||
"default": "./dist/codec/obfuscation/obfuscation.js"
|
||||
}
|
||||
},
|
||||
"./time/time": {
|
||||
"import": {
|
||||
"types": "./dist/time/time.d.ts",
|
||||
"default": "./dist/time/time.js"
|
||||
}
|
||||
},
|
||||
"./generate/generate": {
|
||||
"import": {
|
||||
"types": "./dist/generate/generate.d.ts",
|
||||
"default": "./dist/generate/generate.js"
|
||||
}
|
||||
},
|
||||
"./validate/validate": {
|
||||
"import": {
|
||||
"types": "./dist/validate/validate.d.ts",
|
||||
"default": "./dist/validate/validate.js"
|
||||
}
|
||||
},
|
||||
"./flags/flags": {
|
||||
"import": {
|
||||
"types": "./dist/flags/flags.d.ts",
|
||||
"default": "./dist/flags/flags.js"
|
||||
}
|
||||
},
|
||||
"./manager/manager": {
|
||||
"import": {
|
||||
"types": "./dist/manager/manager.d.ts",
|
||||
"default": "./dist/manager/manager.js"
|
||||
}
|
||||
},
|
||||
"./revocation/revocation-store/revocation-store": {
|
||||
"import": {
|
||||
"types": "./dist/revocation/revocation-store/revocation-store.d.ts",
|
||||
"default": "./dist/revocation/revocation-store/revocation-store.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts .",
|
||||
"bench": "vitest bench --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/crypto-curve": "workspace:^",
|
||||
"@suaveplan/crypto-hash": "workspace:^",
|
||||
"@suaveplan/crypto-utils": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# `@suaveplan/license` — codec/base32
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Crockford-style Base32 codec used to serialise the packed payload and
|
||||
signature into a printable license-key string. The alphabet
|
||||
(`0-9 A-Z` minus `I`, `L`, `O`, `U`) is shared with upstream so any key
|
||||
generated by upstream parses under suaveplan and vice versa. The encoder
|
||||
inserts `-` separators every 5 characters for legibility; the decoder
|
||||
ignores them.
|
||||
|
||||
## Features
|
||||
|
||||
- 32-character Crockford alphabet, omitting `I`/`L`/`O`/`U`.
|
||||
- Dash-grouped output, `XXXXX-XXXXX-...`.
|
||||
- Case-insensitive decode (uppercase canonical).
|
||||
- Round-trips arbitrary `Uint8Array` data.
|
||||
- Raises `LicenseError(LICENSE.INVALID_ENCODING)` on any non-alphabet
|
||||
character, with the offending character in `error.context.character`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { base32Decode, base32Encode } from "@suaveplan/license/codec/base32/base32";
|
||||
|
||||
const encoded = base32Encode(new Uint8Array([1, 2, 3, 4, 5]));
|
||||
// e.g. "0426-1850-A"
|
||||
const decoded = base32Decode(encoded);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Catching invalid input:
|
||||
|
||||
```ts
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
import { base32Decode } from "@suaveplan/license/codec/base32/base32";
|
||||
|
||||
try {
|
||||
base32Decode("INVALID-CHAR-!");
|
||||
} catch (e) {
|
||||
if (e instanceof LicenseError && e.code === "LICENSE.INVALID_ENCODING") {
|
||||
console.warn("Bad character:", e.context?.character);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `base32Encode(buf: Uint8Array): string`
|
||||
|
||||
Returns the Base32 encoding of `buf`, grouped into 5-character chunks
|
||||
separated by `-`. Empty input returns the empty string.
|
||||
|
||||
### `base32Decode(str: string): Uint8Array`
|
||||
|
||||
Strips `-`, uppercases, and decodes the resulting characters. Throws
|
||||
`LicenseError(LICENSE.INVALID_ENCODING)` for any unknown character.
|
||||
Input is trimmed to a byte boundary — trailing partial-byte bits are
|
||||
discarded (matching upstream behaviour).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation uses string concatenation of binary digits rather
|
||||
than bit shifts because the 5-bit-to-8-bit boundary makes manual
|
||||
shifting verbose; the perf cost is negligible for license-key-sized
|
||||
inputs (≤73 bytes). For larger inputs, prefer
|
||||
`@suaveplan/crypto-utils`' base32 implementation. The trailing-bit
|
||||
truncation in `base32Decode` matches upstream's `Math.floor(bits / 8)`
|
||||
so identical inputs decode to identical bytes across both codebases.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LicenseError } from "../../error-codes.js";
|
||||
import { base32Decode, base32Encode } from "./base32.js";
|
||||
|
||||
describe("base32Encode / base32Decode", () => {
|
||||
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
|
||||
const input = new Uint8Array([0, 1, 2, 64, 128, 200, 255, 42, 17]);
|
||||
const encoded = base32Encode(input);
|
||||
expect(encoded).toContain("-");
|
||||
const decoded = base32Decode(encoded);
|
||||
expect(Array.from(decoded)).toEqual(Array.from(input));
|
||||
});
|
||||
|
||||
it("encodes a single byte to two Base32 chars", { timeout: 5000 }, () => {
|
||||
const encoded = base32Encode(new Uint8Array([0xff]));
|
||||
// Two 5-bit groups: 11111 11100 ⇒ alphabet[31] + alphabet[28]
|
||||
expect(encoded.replace(/-/g, "")).toBe("ZW");
|
||||
});
|
||||
|
||||
it("ignores dashes during decode", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const encoded = base32Encode(bytes);
|
||||
const decodedNoDashes = base32Decode(encoded.replace(/-/g, ""));
|
||||
const decodedWithDashes = base32Decode(encoded);
|
||||
expect(Array.from(decodedNoDashes)).toEqual(
|
||||
Array.from(decodedWithDashes)
|
||||
);
|
||||
});
|
||||
|
||||
it("is case-insensitive during decode", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array([0xab, 0xcd, 0xef]);
|
||||
const encoded = base32Encode(bytes);
|
||||
const lower = base32Decode(encoded.toLowerCase());
|
||||
const upper = base32Decode(encoded.toUpperCase());
|
||||
expect(Array.from(lower)).toEqual(Array.from(upper));
|
||||
});
|
||||
|
||||
it("throws LICENSE_INVALID_ENCODING on non-alphabet character", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
base32Decode("XXXXX!");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.INVALID_ENCODING");
|
||||
});
|
||||
|
||||
it("round-trips every byte value 0..255", { timeout: 5000 }, () => {
|
||||
const all = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) all[i] = i;
|
||||
const decoded = base32Decode(base32Encode(all));
|
||||
expect(Array.from(decoded)).toEqual(Array.from(all));
|
||||
});
|
||||
|
||||
it("encodes an empty buffer to an empty string", { timeout: 5000 }, () => {
|
||||
expect(base32Encode(new Uint8Array(0))).toBe("");
|
||||
expect(base32Decode("")).toEqual(new Uint8Array(0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BASE32_ALPHABET } from "../../constants/constants.js";
|
||||
import { LICENSE_CODES, LicenseError } from "../../error-codes.js";
|
||||
|
||||
/**
|
||||
* Encode bytes to a Crockford-style Base32 string with `-` separators
|
||||
* inserted every 5 characters for legibility. Verbatim wire-compatible
|
||||
* with upstream `@suaveplan/license`.
|
||||
*/
|
||||
export function base32Encode(buf: Uint8Array): string {
|
||||
let bits = "";
|
||||
for (const b of buf) {
|
||||
bits += b.toString(2).padStart(8, "0");
|
||||
}
|
||||
|
||||
let out = "";
|
||||
for (let i = 0; i < bits.length; i += 5) {
|
||||
const chunk = bits.slice(i, i + 5).padEnd(5, "0");
|
||||
out += BASE32_ALPHABET[Number.parseInt(chunk, 2)];
|
||||
}
|
||||
|
||||
return out.match(/.{1,5}/g)?.join("-") ?? out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Base32 string back to bytes. Dashes are ignored and the input
|
||||
* is uppercased before lookup. Any non-alphabet character raises
|
||||
* `LicenseError(LICENSE.INVALID_ENCODING)`.
|
||||
*/
|
||||
export function base32Decode(str: string): Uint8Array {
|
||||
const clean = str.replace(/-/g, "").toUpperCase();
|
||||
let bits = "";
|
||||
|
||||
for (const c of clean) {
|
||||
const idx = BASE32_ALPHABET.indexOf(c);
|
||||
if (idx < 0) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_ENCODING,
|
||||
`Invalid Base32 character: ${c}`,
|
||||
{ context: { character: c } }
|
||||
);
|
||||
}
|
||||
bits += idx.toString(2).padStart(5, "0");
|
||||
}
|
||||
|
||||
const byteCount = Math.floor(bits.length / 8);
|
||||
const buf = new Uint8Array(byteCount);
|
||||
for (let i = 0; i < byteCount; i++) {
|
||||
buf[i] = Number.parseInt(bits.slice(i * 8, i * 8 + 8), 2);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { base32Decode, base32Encode } from "./base32.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# `@suaveplan/license` — codec/bitpack
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bit-precise pack/unpack primitives used to serialise the 72-bit license
|
||||
payload into 9 bytes. Plain `DataView` / typed-array slicing can only
|
||||
write whole bytes, so we walk the buffer bit-by-bit. Two thin classes
|
||||
hold the cursor: `SimpleBitPacker` writes MSB-first, `SimpleBitUnpacker`
|
||||
reads MSB-first. Both round-trip identically to upstream's
|
||||
`@suaveplan/license` bitpacker.
|
||||
|
||||
## Features
|
||||
|
||||
- `packUint(value, bits)` — writes an unsigned integer in `[0, 2^bits)`
|
||||
using `bits` bits; raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)`
|
||||
when the value exceeds the declared width or is negative.
|
||||
- `unpackUint(bits)` — reads the next `bits`-wide unsigned integer;
|
||||
raises `LicenseError(LICENSE.MALFORMED)` when the read would underflow
|
||||
the buffer.
|
||||
- `getBytes()` / `getBitCount()` / `getBitPosition()` introspection
|
||||
helpers used by the spec-conformance tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SimpleBitPacker,
|
||||
SimpleBitUnpacker,
|
||||
} from "@suaveplan/license/codec/bitpack/bitpack";
|
||||
|
||||
const p = new SimpleBitPacker();
|
||||
p.packUint(2, 3); // 3-bit version
|
||||
p.packUint(17, 5); // 5-bit signingKeyId
|
||||
const bytes = p.getBytes();
|
||||
|
||||
const u = new SimpleBitUnpacker(bytes);
|
||||
const version = u.unpackUint(3); // 2
|
||||
const keyId = u.unpackUint(5); // 17
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Round-tripping a full 72-bit payload:
|
||||
|
||||
```ts
|
||||
import { FIELD_BITS } from "@suaveplan/license/constants/constants";
|
||||
import {
|
||||
SimpleBitPacker,
|
||||
SimpleBitUnpacker,
|
||||
} from "@suaveplan/license/codec/bitpack/bitpack";
|
||||
|
||||
const p = new SimpleBitPacker();
|
||||
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
|
||||
p.packUint(0, bits);
|
||||
}
|
||||
const bytes = p.getBytes(); // 9 bytes
|
||||
|
||||
const u = new SimpleBitUnpacker(bytes);
|
||||
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
|
||||
u.unpackUint(bits); // === 0
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SimpleBitPacker`
|
||||
|
||||
- `packUint(value: number, bits: number): void` — write an unsigned
|
||||
integer; throws on overflow / negative.
|
||||
- `getBytes(): Uint8Array` — snapshot the packed bytes.
|
||||
- `getBitCount(): number` — total bits written.
|
||||
|
||||
### `class SimpleBitUnpacker`
|
||||
|
||||
- `constructor(buffer: Uint8Array)` — wrap a byte buffer.
|
||||
- `unpackUint(bits: number): number` — read the next integer; throws
|
||||
`MALFORMED` on underflow.
|
||||
- `getBitPosition(): number` — current read cursor.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The packer grows its internal `number[]` buffer one byte at a time
|
||||
because the upstream layout is small (9 bytes) and pre-sizing would
|
||||
require knowing the total bit count in advance. The `1 << bits` overflow
|
||||
check is safe for the bit widths used by the license schema (≤19 bits);
|
||||
for wider integers, callers should use a dedicated bigint bitpacker.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LicenseError } from "../../error-codes.js";
|
||||
import { SimpleBitPacker, SimpleBitUnpacker } from "./bitpack.js";
|
||||
|
||||
describe("SimpleBitPacker / SimpleBitUnpacker", () => {
|
||||
it("round-trips packed values", { timeout: 5000 }, () => {
|
||||
const packer = new SimpleBitPacker();
|
||||
packer.packUint(2, 3); // version
|
||||
packer.packUint(17, 5); // signingKeyId
|
||||
packer.packUint(42, 6); // productId
|
||||
packer.packUint(3, 4); // tier
|
||||
expect(packer.getBitCount()).toBe(18);
|
||||
|
||||
const bytes = packer.getBytes();
|
||||
const unpacker = new SimpleBitUnpacker(bytes);
|
||||
expect(unpacker.unpackUint(3)).toBe(2);
|
||||
expect(unpacker.unpackUint(5)).toBe(17);
|
||||
expect(unpacker.unpackUint(6)).toBe(42);
|
||||
expect(unpacker.unpackUint(4)).toBe(3);
|
||||
expect(unpacker.getBitPosition()).toBe(18);
|
||||
});
|
||||
|
||||
it("throws when value exceeds bit width", { timeout: 5000 }, () => {
|
||||
const packer = new SimpleBitPacker();
|
||||
let caught: unknown;
|
||||
try {
|
||||
packer.packUint(256, 8);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe(
|
||||
"LICENSE.PAYLOAD_OUT_OF_RANGE"
|
||||
);
|
||||
expect((caught as LicenseError).message).toContain(
|
||||
"out of range for 8 bits"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when value is negative", { timeout: 5000 }, () => {
|
||||
const packer = new SimpleBitPacker();
|
||||
expect(() => packer.packUint(-1, 4)).toThrow(LicenseError);
|
||||
});
|
||||
|
||||
it("throws on buffer underflow", { timeout: 5000 }, () => {
|
||||
const unpacker = new SimpleBitUnpacker(new Uint8Array(0));
|
||||
let caught: unknown;
|
||||
try {
|
||||
unpacker.unpackUint(8);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.MALFORMED");
|
||||
expect((caught as LicenseError).message).toContain("Buffer underflow");
|
||||
});
|
||||
|
||||
it("tracks bit position across multi-byte writes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const packer = new SimpleBitPacker();
|
||||
packer.packUint(0xff, 8);
|
||||
packer.packUint(0xab, 8);
|
||||
packer.packUint(0xcd, 8);
|
||||
expect(packer.getBitCount()).toBe(24);
|
||||
expect(packer.getBytes().length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { LICENSE_CODES, LicenseError } from "../../error-codes.js";
|
||||
|
||||
/**
|
||||
* Simple bit-precise packer used to serialise the 72-bit license
|
||||
* payload. Values are packed MSB-first into an internal byte buffer.
|
||||
*/
|
||||
export class SimpleBitPacker {
|
||||
private readonly buffer: number[] = [];
|
||||
private bitPos = 0;
|
||||
|
||||
/** Pack an unsigned integer in `[0, 2^bits - 1]` using `bits` bits. */
|
||||
public packUint(value: number, bits: number): void {
|
||||
if (value < 0 || value > (1 << bits) - 1) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.PAYLOAD_OUT_OF_RANGE,
|
||||
`Value ${value} out of range for ${bits} bits`,
|
||||
{ context: { value, bits } }
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = bits - 1; i >= 0; i--) {
|
||||
const bit = (value >> i) & 1;
|
||||
const byteIndex = Math.floor(this.bitPos / 8);
|
||||
const bitIndex = 7 - (this.bitPos % 8);
|
||||
|
||||
if (byteIndex >= this.buffer.length) {
|
||||
this.buffer.push(0);
|
||||
}
|
||||
|
||||
(this.buffer[byteIndex] as number) |= bit << bitIndex;
|
||||
this.bitPos++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the packed bytes as a fresh `Uint8Array`. */
|
||||
public getBytes(): Uint8Array {
|
||||
return new Uint8Array(this.buffer);
|
||||
}
|
||||
|
||||
/** Number of bits written so far. */
|
||||
public getBitCount(): number {
|
||||
return this.bitPos;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion unpacker that reads MSB-first integers out of a packed byte
|
||||
* buffer. Throws `LicenseError(LICENSE.MALFORMED)` when a read would
|
||||
* underflow the buffer.
|
||||
*/
|
||||
export class SimpleBitUnpacker {
|
||||
private readonly buffer: Uint8Array;
|
||||
private bitPos = 0;
|
||||
|
||||
public constructor(buffer: Uint8Array) {
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
/** Unpack the next `bits`-wide unsigned integer. */
|
||||
public unpackUint(bits: number): number {
|
||||
let value = 0;
|
||||
|
||||
for (let i = 0; i < bits; i++) {
|
||||
const byteIndex = Math.floor(this.bitPos / 8);
|
||||
const bitIndex = 7 - (this.bitPos % 8);
|
||||
|
||||
if (byteIndex >= this.buffer.length) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.MALFORMED,
|
||||
`Buffer underflow: trying to read bit ${this.bitPos} from ${this.buffer.length * 8}-bit buffer`,
|
||||
{
|
||||
context: {
|
||||
position: this.bitPos,
|
||||
capacityBits: this.buffer.length * 8,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const currentByte = this.buffer[byteIndex] as number;
|
||||
const bit = (currentByte >> bitIndex) & 1;
|
||||
value = (value << 1) | bit;
|
||||
this.bitPos++;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Current bit position (read cursor). */
|
||||
public getBitPosition(): number {
|
||||
return this.bitPos;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SimpleBitPacker, SimpleBitUnpacker } from "./bitpack.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
deobfuscateLicenseKey,
|
||||
extractSignatureFromKey,
|
||||
obfuscateLicenseKey,
|
||||
} from "./obfuscation.js";
|
||||
@@ -0,0 +1,98 @@
|
||||
# `@suaveplan/license` — codec/obfuscation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Per-key character obfuscation pass run after Base32 encoding. The first
|
||||
15 non-dash characters of the encoded key (which carry the bitpacked
|
||||
payload) are rotated within the Base32 alphabet by per-character offsets
|
||||
drawn from the license's signature. Two different licenses with
|
||||
different signatures produce two completely different obfuscation
|
||||
patterns, so attackers cannot pattern-match payload structure across
|
||||
keys.
|
||||
|
||||
The seed used to drive the rotation is derived from the unobfuscated
|
||||
chars 16-30 of the key (the signature region), which breaks the
|
||||
circular "decode needs the seed, the seed comes from the decode"
|
||||
dependency: the seed can be recovered from the obfuscated key alone.
|
||||
|
||||
## Features
|
||||
|
||||
- `obfuscateLicenseKey` / `deobfuscateLicenseKey` round-trip a key
|
||||
given the same seed.
|
||||
- `extractSignatureFromKey` recovers the seed from any obfuscated key
|
||||
in O(n) without touching the bitpacker.
|
||||
- Non-Base32 characters (dashes, accidental whitespace) pass through
|
||||
unchanged so the dash-grouped formatting survives obfuscation.
|
||||
- Short-key passthrough: keys with fewer than 15 non-dash characters
|
||||
return unmodified, matching upstream's "no signature region to seed
|
||||
from" fallback.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
deobfuscateLicenseKey,
|
||||
extractSignatureFromKey,
|
||||
obfuscateLicenseKey,
|
||||
} from "@suaveplan/license/codec/obfuscation/obfuscation";
|
||||
|
||||
const rawKey = "0426-1850-A8BC-NPQR-..."; // Base32 of payload || signature
|
||||
const seed = extractSignatureFromKey(rawKey);
|
||||
const obfuscated = obfuscateLicenseKey(rawKey, seed);
|
||||
const original = deobfuscateLicenseKey(obfuscated, seed);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Round-tripping with the exact scheme `generateLicense` uses:
|
||||
|
||||
```ts
|
||||
import {
|
||||
deobfuscateLicenseKey,
|
||||
extractSignatureFromKey,
|
||||
obfuscateLicenseKey,
|
||||
} from "@suaveplan/license/codec/obfuscation/obfuscation";
|
||||
|
||||
function obfuscate(rawKey: string): string {
|
||||
const seed = extractSignatureFromKey(rawKey);
|
||||
return obfuscateLicenseKey(rawKey, seed);
|
||||
}
|
||||
|
||||
function deobfuscate(obfKey: string): string {
|
||||
const seed = extractSignatureFromKey(obfKey);
|
||||
return deobfuscateLicenseKey(obfKey, seed);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `obfuscateLicenseKey(licenseKey: string, signatureBytes: Uint8Array): string`
|
||||
|
||||
Rotate the first 15 non-dash characters of `licenseKey` within
|
||||
`BASE32_ALPHABET`. Per-char offsets cycle through `signatureBytes`.
|
||||
Returns the input unchanged if it has fewer than 15 non-dash chars.
|
||||
|
||||
### `deobfuscateLicenseKey(obfuscatedKey: string, signatureBytes: Uint8Array): string`
|
||||
|
||||
Inverse of `obfuscateLicenseKey` — rotates by the negation of each
|
||||
offset.
|
||||
|
||||
### `extractSignatureFromKey(obfuscatedKey: string): Uint8Array`
|
||||
|
||||
Recover up to 15 seed bytes from chars 16-30 of the obfuscated key by
|
||||
reading their Base32-alphabet indices (0-31). Returns an empty array
|
||||
when the key has no signature region.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rotation arithmetic uses double-modulo (`((x % len) + len) % len`)
|
||||
so negative offsets (from `deobfuscateLicenseKey`) wrap correctly.
|
||||
Non-Base32 characters return early in `rotateChar`, leaving dashes and
|
||||
any defensive whitespace intact — this matches upstream's
|
||||
permissive-pass behaviour and means an externally re-formatted key
|
||||
(e.g. paste with extra spaces) still deobfuscates correctly once the
|
||||
spaces are stripped by the Base32 decoder.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { base32Encode } from "../base32/base32.js";
|
||||
import {
|
||||
deobfuscateLicenseKey,
|
||||
extractSignatureFromKey,
|
||||
obfuscateLicenseKey,
|
||||
} from "./obfuscation.js";
|
||||
|
||||
const rawKey = base32Encode(
|
||||
new Uint8Array([
|
||||
// 9-byte payload
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9,
|
||||
// 20-byte signature
|
||||
10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
|
||||
28, 29,
|
||||
])
|
||||
);
|
||||
|
||||
describe("obfuscation", () => {
|
||||
it("round-trips obfuscation", { timeout: 5000 }, () => {
|
||||
const seed = new Uint8Array([3, 7, 11, 17, 23, 29, 31, 5]);
|
||||
const obf = obfuscateLicenseKey(rawKey, seed);
|
||||
const deobf = deobfuscateLicenseKey(obf, seed);
|
||||
expect(deobf).toBe(rawKey);
|
||||
});
|
||||
|
||||
it("produces a different string from the input", { timeout: 5000 }, () => {
|
||||
const seed = new Uint8Array([5, 10, 15, 20]);
|
||||
const obf = obfuscateLicenseKey(rawKey, seed);
|
||||
expect(obf).not.toBe(rawKey);
|
||||
expect(obf.length).toBe(rawKey.length);
|
||||
});
|
||||
|
||||
it("passes through keys shorter than 15 chars", { timeout: 5000 }, () => {
|
||||
const seed = new Uint8Array([1, 2, 3]);
|
||||
expect(obfuscateLicenseKey("ABCDE", seed)).toBe("ABCDE");
|
||||
expect(deobfuscateLicenseKey("ABCDE", seed)).toBe("ABCDE");
|
||||
});
|
||||
|
||||
it("recovers seed from obfuscated key", { timeout: 5000 }, () => {
|
||||
// Obfuscate with the same scheme generateLicense uses
|
||||
const initialSeed = extractSignatureFromKey(rawKey);
|
||||
const obf = obfuscateLicenseKey(rawKey, initialSeed);
|
||||
|
||||
// Now extract from the obfuscated key — must yield the same seed
|
||||
const recoveredSeed = extractSignatureFromKey(obf);
|
||||
expect(Array.from(recoveredSeed)).toEqual(Array.from(initialSeed));
|
||||
|
||||
const deobf = deobfuscateLicenseKey(obf, recoveredSeed);
|
||||
expect(deobf).toBe(rawKey);
|
||||
});
|
||||
|
||||
it("extractSignatureFromKey returns an empty array for keys with no signature region", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(extractSignatureFromKey("ABCDE")).toEqual(new Uint8Array(0));
|
||||
expect(extractSignatureFromKey("ABCDEFGHJKMNPQR")).toEqual(
|
||||
new Uint8Array(0)
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves non-Base32 characters unchanged", { timeout: 5000 }, () => {
|
||||
// Construct a string that contains a non-Base32 char (e.g. '!')
|
||||
// in the payload region. The rotateChar branch must pass it
|
||||
// through unchanged.
|
||||
const key = "ABCDE-FGHJK-MN!PQ-RSTVW-XYZ01-23456";
|
||||
const seed = new Uint8Array([7, 7, 7, 7, 7, 7, 7, 7, 7, 7]);
|
||||
const obf = obfuscateLicenseKey(key, seed);
|
||||
expect(obf.includes("!")).toBe(true);
|
||||
const deobf = deobfuscateLicenseKey(obf, seed);
|
||||
expect(deobf).toBe(key);
|
||||
});
|
||||
|
||||
it("extractSignatureFromKey returns 0 for non-Base32 chars in signature region", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 15 payload chars + signature region containing '!' (not in alphabet)
|
||||
const key = "ABCDEFGHJKMNPQR" + "!!!!!!!!!!!!!!!";
|
||||
const seed = extractSignatureFromKey(key);
|
||||
expect(Array.from(seed)).toEqual([
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it("extractSignatureFromKey ignores dashes and reads chars 16-30", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const allChars = "0123456789ABCDE" + "FGHJKMNPQRSTVWX";
|
||||
// Insert dashes every 5 chars
|
||||
const dashed = allChars.match(/.{1,5}/g)?.join("-") ?? allChars;
|
||||
const seed = extractSignatureFromKey(dashed);
|
||||
expect(seed.length).toBe(15);
|
||||
// chars 16-30: "FGHJKMNPQRSTVWX"
|
||||
// F=15, G=16, H=17, J=18, K=19, M=20, N=21, P=22, Q=23, R=24,
|
||||
// S=25, T=26, V=27, W=28, X=29
|
||||
expect(Array.from(seed)).toEqual([
|
||||
15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
BASE32_ALPHABET,
|
||||
OBFUSCATION_PAYLOAD_CHARS,
|
||||
} from "../../constants/constants.js";
|
||||
|
||||
/**
|
||||
* Rotate a Base32 character by `offset` positions in the alphabet.
|
||||
* Non-Base32 characters are returned unchanged so dashes / whitespace
|
||||
* pass through transparently.
|
||||
*/
|
||||
function rotateChar(char: string, offset: number): string {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) return char;
|
||||
|
||||
const len = BASE32_ALPHABET.length;
|
||||
const newIndex = (((index + offset) % len) + len) % len;
|
||||
return BASE32_ALPHABET[newIndex] as string;
|
||||
}
|
||||
|
||||
/** Extract the first `count` non-dash characters and their positions. */
|
||||
function extractChars(
|
||||
key: string,
|
||||
count: number
|
||||
): { chars: string[]; positions: number[] } {
|
||||
const chars: string[] = [];
|
||||
const positions: number[] = [];
|
||||
for (let i = 0; i < key.length && chars.length < count; i++) {
|
||||
const ch = key[i] as string;
|
||||
if (ch !== "-") {
|
||||
chars.push(ch);
|
||||
positions.push(i);
|
||||
}
|
||||
}
|
||||
return { chars, positions };
|
||||
}
|
||||
|
||||
/** Splice modified characters back into the original string by position. */
|
||||
function rebuildKey(
|
||||
originalKey: string,
|
||||
modifiedChars: string[],
|
||||
positions: number[]
|
||||
): string {
|
||||
const result = originalKey.split("");
|
||||
for (let i = 0; i < positions.length && i < modifiedChars.length; i++) {
|
||||
result[positions[i] as number] = modifiedChars[i] as string;
|
||||
}
|
||||
return result.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the first 15 non-dash characters of `licenseKey` within the
|
||||
* Base32 alphabet by per-character offsets drawn from `signatureBytes`.
|
||||
* Keys with fewer than 15 non-dash characters are returned unchanged.
|
||||
*/
|
||||
export function obfuscateLicenseKey(
|
||||
licenseKey: string,
|
||||
signatureBytes: Uint8Array
|
||||
): string {
|
||||
const { chars, positions } = extractChars(
|
||||
licenseKey,
|
||||
OBFUSCATION_PAYLOAD_CHARS
|
||||
);
|
||||
if (chars.length < OBFUSCATION_PAYLOAD_CHARS) {
|
||||
return licenseKey;
|
||||
}
|
||||
const obfuscated = chars.map((char, i) =>
|
||||
rotateChar(char, signatureBytes[i % signatureBytes.length] as number)
|
||||
);
|
||||
return rebuildKey(licenseKey, obfuscated, positions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the obfuscation seed from an obfuscated key by reading the
|
||||
* Base32 alphabet indices of chars 16-30 (the unobfuscated signature
|
||||
* region). This is what breaks the circular "decode-needs-the-seed-
|
||||
* which-needs-decode" dependency.
|
||||
*/
|
||||
export function extractSignatureFromKey(obfuscatedKey: string): Uint8Array {
|
||||
const allChars = obfuscatedKey.replace(/-/g, "");
|
||||
if (allChars.length <= OBFUSCATION_PAYLOAD_CHARS) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
const seedChars = allChars.slice(
|
||||
OBFUSCATION_PAYLOAD_CHARS,
|
||||
OBFUSCATION_PAYLOAD_CHARS + OBFUSCATION_PAYLOAD_CHARS
|
||||
);
|
||||
|
||||
const seed = new Uint8Array(
|
||||
Math.min(seedChars.length, OBFUSCATION_PAYLOAD_CHARS)
|
||||
);
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const char = seedChars[i] as string;
|
||||
const idx = BASE32_ALPHABET.indexOf(char);
|
||||
seed[i] = idx >= 0 ? idx : 0;
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link obfuscateLicenseKey}. Rotates the first 15 non-dash
|
||||
* characters by the negation of the per-character offsets.
|
||||
*/
|
||||
export function deobfuscateLicenseKey(
|
||||
obfuscatedKey: string,
|
||||
signatureBytes: Uint8Array
|
||||
): string {
|
||||
const { chars, positions } = extractChars(
|
||||
obfuscatedKey,
|
||||
OBFUSCATION_PAYLOAD_CHARS
|
||||
);
|
||||
if (chars.length < OBFUSCATION_PAYLOAD_CHARS) {
|
||||
return obfuscatedKey;
|
||||
}
|
||||
const deobfuscated = chars.map((char, i) =>
|
||||
rotateChar(char, -(signatureBytes[i % signatureBytes.length] as number))
|
||||
);
|
||||
return rebuildKey(obfuscatedKey, deobfuscated, positions);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { packPayload, unpackPayload } from "./payload.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# `@suaveplan/license` — codec/payload
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`packPayload` and `unpackPayload` are the 72-bit payload codec on top of
|
||||
the bitpack primitives. They emit exactly 9 bytes (the binary contract
|
||||
shared with upstream `@suaveplan/license`) and walk the 9 schema fields
|
||||
in the canonical order declared by `FIELD_BITS`.
|
||||
|
||||
`packPayload` raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` when a
|
||||
field exceeds its declared bit width (via the underlying bitpacker).
|
||||
`unpackPayload` raises `LicenseError(LICENSE.MALFORMED)` for any input
|
||||
length other than `PAYLOAD_BYTES` (9).
|
||||
|
||||
## Features
|
||||
|
||||
- Bit-for-bit wire compatibility with upstream's `packPayload` /
|
||||
`unpackPayload`.
|
||||
- Field-bound validation via `SimpleBitPacker.packUint`, surfaced as
|
||||
typed `LicenseError`s.
|
||||
- Strict 9-byte length invariant on both sides — no implicit truncation
|
||||
or padding.
|
||||
- Returns a fresh `Uint8Array`; safe to mutate the caller's buffer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
packPayload,
|
||||
unpackPayload,
|
||||
} from "@suaveplan/license/codec/payload/payload";
|
||||
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
|
||||
|
||||
const bytes = packPayload({
|
||||
version: 2,
|
||||
signingKeyId: 0,
|
||||
productId: 1,
|
||||
packageTier: PackageTier.PRO,
|
||||
flags: 0,
|
||||
timeEncodingType: TimeEncodingType.NONE,
|
||||
timeValue: 0,
|
||||
featureMask: 0,
|
||||
nonce: 0x123,
|
||||
});
|
||||
console.log(bytes.length); // 9
|
||||
const round = unpackPayload(bytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Catching a field-overflow at the generation boundary:
|
||||
|
||||
```ts
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
import { packPayload } from "@suaveplan/license/codec/payload/payload";
|
||||
|
||||
try {
|
||||
packPayload({ /* ...valid */ productId: 64 } as never); // 6 bits, max 63
|
||||
} catch (err) {
|
||||
if (err instanceof LicenseError && err.code === "LICENSE.PAYLOAD_OUT_OF_RANGE") {
|
||||
// surface to the user as a form-validation failure
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `packPayload(payload: LicensePayload): Uint8Array`
|
||||
|
||||
Pack a payload to its 9-byte representation. Throws
|
||||
`LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` on field overflow.
|
||||
|
||||
### `unpackPayload(data: Uint8Array): LicensePayload`
|
||||
|
||||
Unpack a 9-byte buffer. Throws `LicenseError(LICENSE.MALFORMED)` for
|
||||
any other length.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The unpacker casts `timeEncodingType` to the `TimeEncodingType` enum
|
||||
even though the bit width (2) admits any value in `[0, 3]` — all four
|
||||
values map to legitimate enum members, so the cast is safe. The
|
||||
constant `PAYLOAD_BYTES = 9` lives in `constants/constants.ts` so any
|
||||
future schema change (which would require a `LICENSE_VERSION` bump)
|
||||
updates a single source of truth.
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LicenseError } from "../../error-codes.js";
|
||||
import type { LicensePayload } from "../../types/license.js";
|
||||
import { TimeEncodingType } from "../../types/license.js";
|
||||
import { packPayload, unpackPayload } from "./payload.js";
|
||||
|
||||
const validPayload: LicensePayload = {
|
||||
version: 2,
|
||||
signingKeyId: 5,
|
||||
productId: 17,
|
||||
packageTier: 3,
|
||||
flags: 9,
|
||||
timeEncodingType: TimeEncodingType.ABSOLUTE_DAYS,
|
||||
timeValue: 20000,
|
||||
featureMask: 0x12345,
|
||||
nonce: 0x2aa,
|
||||
};
|
||||
|
||||
describe("packPayload / unpackPayload", () => {
|
||||
it("round-trips all fields", { timeout: 5000 }, () => {
|
||||
const bytes = packPayload(validPayload);
|
||||
expect(bytes.length).toBe(9);
|
||||
const decoded = unpackPayload(bytes);
|
||||
expect(decoded).toEqual(validPayload);
|
||||
});
|
||||
|
||||
it("round-trips an all-zero payload", { timeout: 5000 }, () => {
|
||||
const zero: LicensePayload = {
|
||||
version: 0,
|
||||
signingKeyId: 0,
|
||||
productId: 0,
|
||||
packageTier: 0,
|
||||
flags: 0,
|
||||
timeEncodingType: TimeEncodingType.NONE,
|
||||
timeValue: 0,
|
||||
featureMask: 0,
|
||||
nonce: 0,
|
||||
};
|
||||
expect(unpackPayload(packPayload(zero))).toEqual(zero);
|
||||
});
|
||||
|
||||
it("round-trips an all-max payload", { timeout: 5000 }, () => {
|
||||
const max: LicensePayload = {
|
||||
version: 7,
|
||||
signingKeyId: 31,
|
||||
productId: 63,
|
||||
packageTier: 15,
|
||||
flags: 31,
|
||||
timeEncodingType: 3 as TimeEncodingType,
|
||||
timeValue: 262143,
|
||||
featureMask: 524287,
|
||||
nonce: 1023,
|
||||
};
|
||||
expect(unpackPayload(packPayload(max))).toEqual(max);
|
||||
});
|
||||
|
||||
it("throws LICENSE_MALFORMED on wrong-length input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
unpackPayload(new Uint8Array(8));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.MALFORMED");
|
||||
});
|
||||
|
||||
it("throws LICENSE_MALFORMED on 10-byte input", { timeout: 5000 }, () => {
|
||||
expect(() => unpackPayload(new Uint8Array(10))).toThrow(LicenseError);
|
||||
});
|
||||
|
||||
it("throws LICENSE_PAYLOAD_OUT_OF_RANGE on field overflow", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
packPayload({ ...validPayload, productId: 64 });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe(
|
||||
"LICENSE.PAYLOAD_OUT_OF_RANGE"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FIELD_BITS, PAYLOAD_BYTES } from "../../constants/constants.js";
|
||||
import { LICENSE_CODES, LicenseError } from "../../error-codes.js";
|
||||
import type { LicensePayload, TimeEncodingType } from "../../types/license.js";
|
||||
import { SimpleBitPacker, SimpleBitUnpacker } from "../bitpack/bitpack.js";
|
||||
|
||||
/**
|
||||
* Pack a license payload into exactly 9 bytes (72 bits). Field bounds
|
||||
* surface as `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` from the
|
||||
* underlying bitpacker.
|
||||
*/
|
||||
export function packPayload(payload: LicensePayload): Uint8Array {
|
||||
const packer = new SimpleBitPacker();
|
||||
packer.packUint(payload.version, FIELD_BITS.version);
|
||||
packer.packUint(payload.signingKeyId, FIELD_BITS.signingKeyId);
|
||||
packer.packUint(payload.productId, FIELD_BITS.productId);
|
||||
packer.packUint(payload.packageTier, FIELD_BITS.packageTier);
|
||||
packer.packUint(payload.flags, FIELD_BITS.flags);
|
||||
packer.packUint(payload.timeEncodingType, FIELD_BITS.timeEncodingType);
|
||||
packer.packUint(payload.timeValue, FIELD_BITS.timeValue);
|
||||
packer.packUint(payload.featureMask, FIELD_BITS.featureMask);
|
||||
packer.packUint(payload.nonce, FIELD_BITS.nonce);
|
||||
|
||||
return packer.getBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack exactly 9 bytes (72 bits) into a license payload. Any other
|
||||
* length raises `LicenseError(LICENSE.MALFORMED)`.
|
||||
*/
|
||||
export function unpackPayload(data: Uint8Array): LicensePayload {
|
||||
if (data.length !== PAYLOAD_BYTES) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.MALFORMED,
|
||||
`Payload unpacking error: expected ${PAYLOAD_BYTES} bytes, got ${data.length}`,
|
||||
{ context: { expected: PAYLOAD_BYTES, actual: data.length } }
|
||||
);
|
||||
}
|
||||
|
||||
const unpacker = new SimpleBitUnpacker(data);
|
||||
const version = unpacker.unpackUint(FIELD_BITS.version);
|
||||
const signingKeyId = unpacker.unpackUint(FIELD_BITS.signingKeyId);
|
||||
const productId = unpacker.unpackUint(FIELD_BITS.productId);
|
||||
const packageTier = unpacker.unpackUint(FIELD_BITS.packageTier);
|
||||
const flags = unpacker.unpackUint(FIELD_BITS.flags);
|
||||
const timeEncodingType = unpacker.unpackUint(
|
||||
FIELD_BITS.timeEncodingType
|
||||
) as TimeEncodingType;
|
||||
const timeValue = unpacker.unpackUint(FIELD_BITS.timeValue);
|
||||
const featureMask = unpacker.unpackUint(FIELD_BITS.featureMask);
|
||||
const nonce = unpacker.unpackUint(FIELD_BITS.nonce);
|
||||
|
||||
return {
|
||||
version,
|
||||
signingKeyId,
|
||||
productId,
|
||||
packageTier,
|
||||
flags,
|
||||
timeEncodingType,
|
||||
timeValue,
|
||||
featureMask,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# `@suaveplan/license` — constants
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wire-format constants for the license package. Every value here
|
||||
is part of the binary contract: the per-field bit widths
|
||||
(`FIELD_BITS`), the Base32 alphabet, the signature lengths, the version
|
||||
byte, and the day-to-millisecond conversion factor. Changing any
|
||||
constant breaks every existing license key, so they live in their own
|
||||
module and are imported by every code path that touches the format.
|
||||
|
||||
## Features
|
||||
|
||||
- `LICENSE_VERSION` (currently `2`) — pinned by the 3-bit version field;
|
||||
`validateLicense` rejects any payload with a higher version.
|
||||
- `FULL_SIGNATURE_BYTES` (64) / `SIGNATURE_BYTES` (20) /
|
||||
`MIN_SIGNATURE_BYTES` (16) — the three signature-length thresholds
|
||||
honoured by generation and validation.
|
||||
- `BASE32_ALPHABET` — 32-char Crockford-style alphabet (no I/L/O/U) used
|
||||
by both encode and decode.
|
||||
- `LICENSE_SCHEMA` — per-field maximum values, used by Zod schemas and
|
||||
by the bounds checks in `packPayload`.
|
||||
- `FIELD_BITS` — per-field bit widths summing to 72, consumed by the
|
||||
bitpacker.
|
||||
- `PAYLOAD_BYTES` (9), `OBFUSCATION_PAYLOAD_CHARS` (15), and
|
||||
`MS_PER_DAY` (86 400 000) — derived constants pulled out so callers
|
||||
don't have to recompute them.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LICENSE_VERSION, BASE32_ALPHABET } from "@suaveplan/license/constants/constants";
|
||||
|
||||
console.log(`Targeting wire format v${LICENSE_VERSION}`);
|
||||
console.log(`Alphabet size: ${BASE32_ALPHABET.length}`); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Bounds-checking a payload field at the application layer before
|
||||
constructing it:
|
||||
|
||||
```ts
|
||||
import { LICENSE_SCHEMA } from "@suaveplan/license/constants/constants";
|
||||
import type { LicensePayload } from "@suaveplan/license/types/license";
|
||||
|
||||
function clampPayload(p: LicensePayload): LicensePayload {
|
||||
return {
|
||||
...p,
|
||||
productId: Math.min(p.productId, LICENSE_SCHEMA.productId),
|
||||
timeValue: Math.min(p.timeValue, LICENSE_SCHEMA.timeValue),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Type | Value |
|
||||
|---|---|---|
|
||||
| `LICENSE_VERSION` | `2` | Current format version |
|
||||
| `FULL_SIGNATURE_BYTES` | `64` | secp256k1 compact signature length |
|
||||
| `SIGNATURE_BYTES` | `20` | Default truncated-signature length |
|
||||
| `MIN_SIGNATURE_BYTES` | `16` | Lowest acceptable truncation |
|
||||
| `BASE32_ALPHABET` | `string` | `0-9A-HJKMNP-TV-Z` (32 chars) |
|
||||
| `LICENSE_SCHEMA` | `Readonly<Record<field, max>>` | Per-field max values |
|
||||
| `FIELD_BITS` | `Readonly<Record<field, bits>>` | Per-field bit widths |
|
||||
| `PAYLOAD_BYTES` | `9` | Packed payload size |
|
||||
| `OBFUSCATION_PAYLOAD_CHARS` | `15` | Chars rotated by obfuscation |
|
||||
| `MS_PER_DAY` | `86_400_000` | UTC day in milliseconds |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every constant is declared `as const`, so its TypeScript type is the
|
||||
literal value (e.g. `LICENSE_VERSION: 2`), giving exhaustiveness benefits
|
||||
when compared in `switch` statements. The two records (`LICENSE_SCHEMA`
|
||||
and `FIELD_BITS`) are also frozen at the type level via `as const`,
|
||||
preventing accidental mutation through their `Readonly` view.
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Wire-format constants for `@suaveplan/license`. Every value is part of
|
||||
* the binary contract with upstream and downstream consumers — changing
|
||||
* any constant breaks every existing license key.
|
||||
*/
|
||||
|
||||
/** Current license format version (V2 = compact 72-bit payload). */
|
||||
export const LICENSE_VERSION = 2 as const;
|
||||
|
||||
/** Full secp256k1 compact-signature size (bytes). */
|
||||
export const FULL_SIGNATURE_BYTES = 64 as const;
|
||||
|
||||
/**
|
||||
* Default truncated-signature size for compact license keys (bytes).
|
||||
*
|
||||
* 20 bytes ⇒ ~2^160 forgery resistance, matching SHA-1 collision
|
||||
* strength while keeping keys ~50-60 chars instead of ~120.
|
||||
*/
|
||||
export const SIGNATURE_BYTES = 20 as const;
|
||||
|
||||
/** Minimum acceptable signature size (128-bit security). */
|
||||
export const MIN_SIGNATURE_BYTES = 16 as const;
|
||||
|
||||
/**
|
||||
* Crockford-style Base32 alphabet (RFC 4648 with I/L/O/U removed for
|
||||
* legibility, matching upstream verbatim).
|
||||
*/
|
||||
export const BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" as const;
|
||||
|
||||
/**
|
||||
* Per-field maximum value (= `(1 << bits) - 1`) used by Zod schemas and
|
||||
* runtime bounds checks. Bit widths in `FIELD_BITS`.
|
||||
*/
|
||||
export const LICENSE_SCHEMA = {
|
||||
version: 7,
|
||||
signingKeyId: 31,
|
||||
productId: 63,
|
||||
packageTier: 15,
|
||||
flags: 31,
|
||||
timeEncodingType: 3,
|
||||
timeValue: 262143,
|
||||
featureMask: 524287,
|
||||
nonce: 1023,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Per-field bit widths used by the bitpacker. Sum = 72 bits = 9 bytes.
|
||||
*
|
||||
* Layout: `| v(3) | kid(5) | pid(6) | tier(4) | flg(5) | te(2) | timeValue(18) | featureMask(19) | nonce(10) |`
|
||||
*/
|
||||
export const FIELD_BITS = {
|
||||
version: 3,
|
||||
signingKeyId: 5,
|
||||
productId: 6,
|
||||
packageTier: 4,
|
||||
flags: 5,
|
||||
timeEncodingType: 2,
|
||||
timeValue: 18,
|
||||
featureMask: 19,
|
||||
nonce: 10,
|
||||
} as const;
|
||||
|
||||
/** Packed-payload size in bytes (= sum(FIELD_BITS) / 8). */
|
||||
export const PAYLOAD_BYTES = 9 as const;
|
||||
|
||||
/** Number of payload characters obfuscated using the signature-derived seed. */
|
||||
export const OBFUSCATION_PAYLOAD_CHARS = 15 as const;
|
||||
|
||||
/** Milliseconds in one UTC day, used by the time-encoding module. */
|
||||
export const MS_PER_DAY = 86_400_000 as const;
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
BASE32_ALPHABET,
|
||||
FIELD_BITS,
|
||||
FULL_SIGNATURE_BYTES,
|
||||
LICENSE_SCHEMA,
|
||||
LICENSE_VERSION,
|
||||
MIN_SIGNATURE_BYTES,
|
||||
MS_PER_DAY,
|
||||
OBFUSCATION_PAYLOAD_CHARS,
|
||||
PAYLOAD_BYTES,
|
||||
SIGNATURE_BYTES,
|
||||
} from "./constants.js";
|
||||
99
packages/security-stack/security/license/src/error-codes.md
Normal file
99
packages/security-stack/security/license/src/error-codes.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# `@suaveplan/license` — error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable error-code registry for `@suaveplan/license`. It
|
||||
exposes `LICENSE_CODES` (a frozen `defineCodes("LICENSE", [...])` record),
|
||||
the `LicenseErrorCode` type union, and the `LicenseError` class. Every
|
||||
public failure path in the package raises a `LicenseError` carrying one of
|
||||
these twelve namespaced `LICENSE.*` codes, so consumers can dispatch on the
|
||||
literal string or `instanceof LicenseError` and let TypeScript narrow the
|
||||
union exhaustively.
|
||||
|
||||
## Features
|
||||
|
||||
- 12 codes covering input validation, encoding / parsing, cryptographic
|
||||
failures, lifecycle (expired / revoked), manager state, feature-mask
|
||||
bounds, and `AbortSignal` cancellation.
|
||||
- `LicenseError` extends `SuaveplanError<LicenseErrorCode>` so the
|
||||
generic `code` type narrows under `instanceof`.
|
||||
- Constructor widened to `public` so all modules can construct errors
|
||||
directly without trampolines.
|
||||
- Codes are namespaced `LICENSE.*` per the suaveplan-wide pattern (see
|
||||
`@suaveplan/crypto-keys`'s `CHAIN_KEYS.*`), making programmatic dispatch
|
||||
consistent across packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
|
||||
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_ENCODING,
|
||||
"Invalid Base32 character: !"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Attach a Zod `cause` and structured `context` for an input-validation
|
||||
boundary failure:
|
||||
|
||||
```ts
|
||||
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
|
||||
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
|
||||
|
||||
try {
|
||||
LicensePayloadSchema.parse(input);
|
||||
} catch (zodError) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_INPUT,
|
||||
"License payload failed schema validation",
|
||||
{ cause: zodError, context: { source: "generateLicense" } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Consumer-side narrowing:
|
||||
|
||||
```ts
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
|
||||
try {
|
||||
await manager.generate({ productId: 1 });
|
||||
} catch (error) {
|
||||
if (error instanceof LicenseError && error.code === "LICENSE.NO_SIGNING_KEY") {
|
||||
// recover or re-prompt
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LICENSE_CODES`
|
||||
|
||||
Frozen `Readonly<Record<Reason, "LICENSE.<Reason>">>` where `Reason` is
|
||||
one of the 12 keys: `INVALID_INPUT`, `INVALID_ENCODING`, `MALFORMED`,
|
||||
`UNSUPPORTED_VERSION`, `UNKNOWN_SIGNING_KEY`, `INVALID_SIGNATURE`,
|
||||
`EXPIRED`, `REVOKED`, `NO_SIGNING_KEY`, `FEATURE_INDEX_OUT_OF_RANGE`,
|
||||
`PAYLOAD_OUT_OF_RANGE`, `ABORTED`.
|
||||
|
||||
### `type LicenseErrorCode`
|
||||
|
||||
String-literal union of all 12 namespaced codes.
|
||||
|
||||
### `class LicenseError extends SuaveplanError<LicenseErrorCode>`
|
||||
|
||||
Concrete error class. Constructor: `(code, message, options?)` where
|
||||
`options` is `SuaveplanErrorOptions` (carries optional `cause`,
|
||||
`context`, `traceId`, `clock`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes` from `@suaveplan/error` so the type of
|
||||
`LICENSE_CODES.INVALID_ENCODING` is the literal `"LICENSE.INVALID_ENCODING"`
|
||||
(not the broad `string`), giving exhaustiveness checking in `switch`
|
||||
statements. The list is declared `as const` so TypeScript captures the
|
||||
tuple shape and the helper's mapped-type return picks every key.
|
||||
57
packages/security-stack/security/license/src/error-codes.ts
Normal file
57
packages/security-stack/security/license/src/error-codes.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/license`. Every public failure
|
||||
* path raises a {@link LicenseError} carrying one of these namespaced
|
||||
* `LICENSE.*` codes. Consumers dispatch on the string value (e.g.
|
||||
* `error.code === LICENSE_CODES.INVALID_SIGNATURE` or the inline literal
|
||||
* `"LICENSE.INVALID_SIGNATURE"`).
|
||||
*
|
||||
* The 12 codes cover:
|
||||
* - Input-layer validation (`INVALID_INPUT`) — Zod-detected boundary failures.
|
||||
* - Encoding / parsing failures (`INVALID_ENCODING`, `MALFORMED`,
|
||||
* `UNSUPPORTED_VERSION`, `PAYLOAD_OUT_OF_RANGE`).
|
||||
* - Cryptographic failures (`INVALID_SIGNATURE`, `UNKNOWN_SIGNING_KEY`).
|
||||
* - Lifecycle failures (`EXPIRED`, `REVOKED`).
|
||||
* - Manager-state failures (`NO_SIGNING_KEY`).
|
||||
* - Feature-mask bounds (`FEATURE_INDEX_OUT_OF_RANGE`).
|
||||
* - Async cancellation (`ABORTED`).
|
||||
*/
|
||||
export const LICENSE_CODES = defineCodes("LICENSE", [
|
||||
"INVALID_INPUT",
|
||||
"INVALID_ENCODING",
|
||||
"MALFORMED",
|
||||
"UNSUPPORTED_VERSION",
|
||||
"UNKNOWN_SIGNING_KEY",
|
||||
"INVALID_SIGNATURE",
|
||||
"EXPIRED",
|
||||
"REVOKED",
|
||||
"NO_SIGNING_KEY",
|
||||
"FEATURE_INDEX_OUT_OF_RANGE",
|
||||
"PAYLOAD_OUT_OF_RANGE",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
export type LicenseErrorCode =
|
||||
(typeof LICENSE_CODES)[keyof typeof LICENSE_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the license package. Extends `SuaveplanError`
|
||||
* so `instanceof LicenseError` narrows `code` to {@link LicenseErrorCode}.
|
||||
*
|
||||
* The constructor is widened to `public` so callers can construct errors
|
||||
* directly (the base class is `protected`).
|
||||
*/
|
||||
export class LicenseError extends SuaveplanError<LicenseErrorCode> {
|
||||
public readonly licenseCode: LicenseErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: LicenseErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.licenseCode = code;
|
||||
}
|
||||
}
|
||||
112
packages/security-stack/security/license/src/flags/flags.md
Normal file
112
packages/security-stack/security/license/src/flags/flags.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# `@suaveplan/license` — flags
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the
|
||||
19-bit `featureMask` field. The flags field stores up to 5 boolean
|
||||
license states (`TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`,
|
||||
`EDUCATIONAL`); the feature mask stores up to 19 product-specific
|
||||
feature toggles. This module gives type-safe accessors for both: the
|
||||
flag helpers are typed against the `LicenseFlags` enum; the feature
|
||||
helpers validate that the supplied index is within `[0, 18]` and raise
|
||||
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` otherwise.
|
||||
|
||||
## Features
|
||||
|
||||
- `hasFlag` / `combineFlags` — flag-bit accessors typed against the
|
||||
`LicenseFlags` enum so misuses surface at compile time.
|
||||
- `hasFeature` / `combineFeatures` / `getEnabledFeatures` — feature-bit
|
||||
accessors with runtime index-range checks.
|
||||
- Out-of-range indices throw a typed `LicenseError`, never a generic
|
||||
`Error` — callers can `instanceof LicenseError` and dispatch on
|
||||
`error.code`.
|
||||
- `getEnabledFeatures` walks only the 19 declared bits, so callers
|
||||
feeding in stray high bits get a clean answer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
combineFlags,
|
||||
hasFlag,
|
||||
hasFeature,
|
||||
} from "@suaveplan/license/flags/flags";
|
||||
import { LicenseFlags } from "@suaveplan/license/types/license";
|
||||
|
||||
const flags = combineFlags(LicenseFlags.TRIAL, LicenseFlags.SUBSCRIPTION);
|
||||
hasFlag(flags, LicenseFlags.TRIAL); // true
|
||||
hasFeature(0b1010, 1); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Building a payload-shaped feature mask from a domain-specific feature
|
||||
enum, with overflow guard:
|
||||
|
||||
```ts
|
||||
import {
|
||||
combineFeatures,
|
||||
getEnabledFeatures,
|
||||
} from "@suaveplan/license/flags/flags";
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
|
||||
const enum Feature {
|
||||
Export = 0,
|
||||
BulkOps = 1,
|
||||
AdvancedSearch = 4,
|
||||
AiAssistant = 18,
|
||||
}
|
||||
|
||||
try {
|
||||
const mask = combineFeatures(
|
||||
Feature.Export,
|
||||
Feature.BulkOps,
|
||||
Feature.AdvancedSearch,
|
||||
Feature.AiAssistant
|
||||
);
|
||||
const enabled = getEnabledFeatures(mask); // [0, 1, 4, 18]
|
||||
} catch (e) {
|
||||
if (e instanceof LicenseError && e.code === "LICENSE.FEATURE_INDEX_OUT_OF_RANGE") {
|
||||
// upstream produced an out-of-range index
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `hasFlag(flags: number, flag: LicenseFlags): boolean`
|
||||
|
||||
Test whether `flag` is set in `flags`.
|
||||
|
||||
### `combineFlags(...flags: LicenseFlags[]): number`
|
||||
|
||||
OR-merge multiple flags into a single bitmask.
|
||||
|
||||
### `hasFeature(featureMask: number, featureIndex: number): boolean`
|
||||
|
||||
Test whether feature index `featureIndex` is set. Throws
|
||||
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` when `featureIndex`
|
||||
is outside `[0, 18]`.
|
||||
|
||||
### `combineFeatures(...features: number[]): number`
|
||||
|
||||
OR-merge multiple feature indices into a single bitmask. Same range
|
||||
check as `hasFeature`.
|
||||
|
||||
### `getEnabledFeatures(featureMask: number): number[]`
|
||||
|
||||
Enumerate every enabled feature index in ascending order. Bits past
|
||||
index 18 are ignored (consistent with the 19-bit field width).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 19-feature ceiling matches the 19-bit `featureMask` field in the
|
||||
license payload. The shared `ensureFeatureIndexInRange` helper means
|
||||
the two range-checking call sites stay in lockstep; if the bit budget
|
||||
ever changes, only one constant needs to move. The flag enum uses
|
||||
power-of-two values (`1 << 0`, `1 << 1`, ...) so `combineFlags` is a
|
||||
straight `reduce(|)`.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LicenseError } from "../error-codes.js";
|
||||
import { LicenseFlags } from "../types/license.js";
|
||||
import {
|
||||
combineFeatures,
|
||||
combineFlags,
|
||||
getEnabledFeatures,
|
||||
hasFeature,
|
||||
hasFlag,
|
||||
} from "./flags.js";
|
||||
|
||||
describe("flag and feature helpers", () => {
|
||||
it("hasFlag returns bit state", { timeout: 5000 }, () => {
|
||||
const flags = LicenseFlags.TRIAL | LicenseFlags.SUBSCRIPTION;
|
||||
expect(hasFlag(flags, LicenseFlags.TRIAL)).toBe(true);
|
||||
expect(hasFlag(flags, LicenseFlags.SUBSCRIPTION)).toBe(true);
|
||||
expect(hasFlag(flags, LicenseFlags.UPGRADE)).toBe(false);
|
||||
expect(hasFlag(0, LicenseFlags.TRIAL)).toBe(false);
|
||||
});
|
||||
|
||||
it("combineFlags OR-merges flags", { timeout: 5000 }, () => {
|
||||
expect(combineFlags()).toBe(0);
|
||||
expect(combineFlags(LicenseFlags.TRIAL)).toBe(LicenseFlags.TRIAL);
|
||||
expect(
|
||||
combineFlags(LicenseFlags.TRIAL, LicenseFlags.SUBSCRIPTION)
|
||||
).toBe(LicenseFlags.TRIAL | LicenseFlags.SUBSCRIPTION);
|
||||
});
|
||||
|
||||
it("hasFeature returns bit state", { timeout: 5000 }, () => {
|
||||
expect(hasFeature(0b101, 0)).toBe(true);
|
||||
expect(hasFeature(0b101, 1)).toBe(false);
|
||||
expect(hasFeature(0b101, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("hasFeature throws LICENSE_FEATURE_INDEX_OUT_OF_RANGE when index > 18", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
hasFeature(0, 19);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe(
|
||||
"LICENSE.FEATURE_INDEX_OUT_OF_RANGE"
|
||||
);
|
||||
});
|
||||
|
||||
it("hasFeature throws LICENSE_FEATURE_INDEX_OUT_OF_RANGE when index < 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => hasFeature(0, -1)).toThrow(LicenseError);
|
||||
});
|
||||
|
||||
it("combineFeatures throws LICENSE_FEATURE_INDEX_OUT_OF_RANGE when index > 18", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
combineFeatures(0, 19);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe(
|
||||
"LICENSE.FEATURE_INDEX_OUT_OF_RANGE"
|
||||
);
|
||||
});
|
||||
|
||||
it("combineFeatures builds a bitmask", { timeout: 5000 }, () => {
|
||||
expect(combineFeatures()).toBe(0);
|
||||
expect(combineFeatures(0)).toBe(1);
|
||||
expect(combineFeatures(0, 2, 4)).toBe(0b10101);
|
||||
expect(combineFeatures(18)).toBe(1 << 18);
|
||||
});
|
||||
|
||||
it("getEnabledFeatures returns indices of set bits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(getEnabledFeatures(0)).toEqual([]);
|
||||
expect(getEnabledFeatures(0b101)).toEqual([0, 2]);
|
||||
expect(getEnabledFeatures(0b111)).toEqual([0, 1, 2]);
|
||||
expect(getEnabledFeatures(1 << 18)).toEqual([18]);
|
||||
});
|
||||
|
||||
it("getEnabledFeatures ignores bits past 18", { timeout: 5000 }, () => {
|
||||
// bit 19 set, bit 0 set — should only return [0]
|
||||
expect(getEnabledFeatures((1 << 19) | 1)).toEqual([0]);
|
||||
});
|
||||
});
|
||||
50
packages/security-stack/security/license/src/flags/flags.ts
Normal file
50
packages/security-stack/security/license/src/flags/flags.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { LICENSE_CODES, LicenseError } from "../error-codes.js";
|
||||
import type { LicenseFlags } from "../types/license.js";
|
||||
|
||||
const FEATURE_INDEX_MAX = 18;
|
||||
const FEATURE_INDEX_COUNT = 19;
|
||||
|
||||
function ensureFeatureIndexInRange(featureIndex: number): void {
|
||||
if (featureIndex < 0 || featureIndex > FEATURE_INDEX_MAX) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.FEATURE_INDEX_OUT_OF_RANGE,
|
||||
`Feature index ${featureIndex} must be between 0 and ${FEATURE_INDEX_MAX} (supports ${FEATURE_INDEX_COUNT} features)`,
|
||||
{ context: { featureIndex, max: FEATURE_INDEX_MAX } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether `flag` is set in `flags`. */
|
||||
export function hasFlag(flags: number, flag: LicenseFlags): boolean {
|
||||
return (flags & flag) === flag;
|
||||
}
|
||||
|
||||
/** OR-merge multiple flags into a single bitmask. */
|
||||
export function combineFlags(...flags: LicenseFlags[]): number {
|
||||
return flags.reduce<number>((acc, flag) => acc | flag, 0);
|
||||
}
|
||||
|
||||
/** Check whether feature index `featureIndex` (0-18) is enabled. */
|
||||
export function hasFeature(featureMask: number, featureIndex: number): boolean {
|
||||
ensureFeatureIndexInRange(featureIndex);
|
||||
return (featureMask & (1 << featureIndex)) !== 0;
|
||||
}
|
||||
|
||||
/** OR-merge multiple feature indices (0-18) into a single bitmask. */
|
||||
export function combineFeatures(...features: number[]): number {
|
||||
return features.reduce<number>((acc, feature) => {
|
||||
ensureFeatureIndexInRange(feature);
|
||||
return acc | (1 << feature);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Enumerate every enabled feature index in `featureMask`. */
|
||||
export function getEnabledFeatures(featureMask: number): number[] {
|
||||
const features: number[] = [];
|
||||
for (let i = 0; i < FEATURE_INDEX_COUNT; i++) {
|
||||
if ((featureMask & (1 << i)) !== 0) {
|
||||
features.push(i);
|
||||
}
|
||||
}
|
||||
return features;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
combineFeatures,
|
||||
combineFlags,
|
||||
getEnabledFeatures,
|
||||
hasFeature,
|
||||
hasFlag,
|
||||
} from "./flags.js";
|
||||
@@ -0,0 +1,129 @@
|
||||
# `@suaveplan/license` — generate
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level license-key generation, payload construction, and parse-only
|
||||
inspection. This is the place that brings together every lower module:
|
||||
it packs the payload, signs the packed bytes with secp256k1 (via
|
||||
`@suaveplan/crypto-curve`) over a SHA-256 digest, optionally truncates
|
||||
the signature, Base32-encodes `payload || signature`, applies the
|
||||
signature-derived obfuscation pass, and emits OpenTelemetry counters
|
||||
and span attributes — all behind a single async function.
|
||||
|
||||
`createPayload` is the quick-builder: callers supply a partial
|
||||
`QuickLicenseOptions` bag, and the function fills sensible defaults
|
||||
(version, signingKeyId, tier, etc.) and computes the time-encoding
|
||||
fields from `expirationDays` / `useAbsoluteTime`. The `Rng` for the
|
||||
nonce and the `Clock` for absolute-day expirations are both injectable
|
||||
so tests run deterministically.
|
||||
|
||||
`parseLicense` is the inverse of generation up to (but not including)
|
||||
signature verification — it deobfuscates, Base32-decodes, and unpacks
|
||||
the payload, returning the three byte slices plus the decoded record.
|
||||
It does NOT verify the signature or check expiration; for that, use
|
||||
`validateLicense`.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateLicense` — full generation pipeline with Zod boundary
|
||||
validation, OTel emission, and `AbortSignal` propagation.
|
||||
- `createPayload` — quick-builder with injectable `Rng` / `Clock` and
|
||||
Zod-validated input.
|
||||
- `parseLicense` — pure decode-and-unpack, returning the byte slices
|
||||
plus the decoded payload.
|
||||
- Wire-format bit-identical to upstream `@suaveplan/license`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
} from "@suaveplan/license/generate/generate";
|
||||
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const result = await generateLicense(
|
||||
createPayload({ productId: 5, expirationDays: 365 }),
|
||||
{ signingKey: { s }, useFullSignature: false }
|
||||
);
|
||||
console.log(result.key); // "XYZ12-ABCDE-..."
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Deterministic generation with `Rng` injection, `AbortSignal`, and
|
||||
truncated signature for a compact key:
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
import {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
parseLicense,
|
||||
} from "@suaveplan/license/generate/generate";
|
||||
|
||||
const rng: Rng = { nextUint32: () => 0x123, nextFloat: () => 0 };
|
||||
const payload = createPayload(
|
||||
{ productId: 7, expirationDays: 90 },
|
||||
{ rng }
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
const r = await generateLicense(payload, {
|
||||
signingKey: { s: secp256k1.randomPrivateKey() },
|
||||
useFullSignature: false,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const parsed = parseLicense(r.key);
|
||||
console.log("payload bytes:", parsed.payloadBytes.length); // 9
|
||||
console.log("signature bytes:", parsed.signatureBytes.length); // 20
|
||||
} catch (e) {
|
||||
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
|
||||
console.log("cancelled");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateLicense(payload, options): Promise<LicenseGenerationResult>`
|
||||
|
||||
Pack + sign + encode + obfuscate. Options: `signingKey` (required),
|
||||
`signingKeyIdOverride?`, `useFullSignature?` (default `true` at the
|
||||
function level; `LicenseManager` overrides to `false`), `signal?`,
|
||||
`rng?` (forwarded to `createPayload` when this function is invoked from
|
||||
the manager). Throws `LicenseError(LICENSE.INVALID_INPUT)` on schema
|
||||
failure, `LicenseError(LICENSE.ABORTED)` on a pre-aborted signal.
|
||||
|
||||
### `createPayload(options?, injection?): LicensePayload`
|
||||
|
||||
Quick-builder; defaults: `productId=1`, `tier=PackageTier.BASIC`,
|
||||
`expirationDays=0` (perpetual), `flags=0`, `features=0`,
|
||||
`useAbsoluteTime=false`. `injection.rng` drives the 10-bit nonce;
|
||||
`injection.clock` drives `getDayNumber` when `useAbsoluteTime`.
|
||||
|
||||
### `parseLicense(licenseKey): LicenseParsedResult`
|
||||
|
||||
Deobfuscate + Base32-decode + slice + unpack. Throws
|
||||
`LicenseError(LICENSE.MALFORMED)` when the decoded byte length is
|
||||
less than `PAYLOAD_BYTES + 1`, or when the Base32 decode surfaces an
|
||||
`INVALID_ENCODING`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The signature scheme is upstream-compatible: SHA-256 over the 9-byte
|
||||
payload, then secp256k1 sign over the digest. The truncated path
|
||||
slices the first 20 bytes of the resulting compact signature; the full
|
||||
path keeps all 64. This means a key generated by upstream verifies
|
||||
under suaveplan and vice versa as long as the same SignKey/PublicKey
|
||||
byte material is used. The `Rng` default uses `crypto.getRandomValues`
|
||||
to build a 32-bit unsigned integer, which is then masked to the 10-bit
|
||||
nonce by the caller.
|
||||
@@ -0,0 +1,240 @@
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { LicenseError } from "../error-codes.js";
|
||||
import { PackageTier, TimeEncodingType } from "../types/license.js";
|
||||
import { validateLicense } from "../validate/validate.js";
|
||||
import { createPayload, generateLicense, parseLicense } from "./generate.js";
|
||||
|
||||
function freshKeypair() {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const p = secp256k1.getPublicKey(s);
|
||||
return { signingKey: { s, p }, publicKey: { p } };
|
||||
}
|
||||
|
||||
const constantRng = (value: number): Rng => ({
|
||||
nextUint32: () => value >>> 0,
|
||||
nextFloat: () => 0,
|
||||
});
|
||||
|
||||
const fakeClock = (ms: number) => () => ms;
|
||||
|
||||
const validPayload = createPayload({
|
||||
productId: 5,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 30,
|
||||
});
|
||||
|
||||
describe("generateLicense", () => {
|
||||
it("generates a verifiable key", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const result = await generateLicense(validPayload, {
|
||||
signingKey,
|
||||
useFullSignature: true,
|
||||
});
|
||||
expect(result.key).toBeTruthy();
|
||||
expect(result.signature.length).toBe(64);
|
||||
|
||||
const v = await validateLicense(result.key, {
|
||||
publicKeys: new Map([[validPayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(true);
|
||||
if (v.success) {
|
||||
expect(v.details.trusted).toBe(true);
|
||||
expect(v.details.isExpired).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("honours signingKeyIdOverride", { timeout: 30000 }, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const r = await generateLicense(validPayload, {
|
||||
signingKey,
|
||||
signingKeyIdOverride: 7,
|
||||
useFullSignature: false,
|
||||
});
|
||||
expect(r.payload.signingKeyId).toBe(7);
|
||||
});
|
||||
|
||||
it("rejects with LICENSE_ABORTED when signal aborts", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
generateLicense(validPayload, {
|
||||
signingKey,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toBeInstanceOf(LicenseError);
|
||||
});
|
||||
|
||||
it("increments license.generate_total counter", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await flushTestMetrics(); // reset
|
||||
const { signingKey } = freshKeypair();
|
||||
await generateLicense(validPayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const collected = await flushTestMetrics();
|
||||
const counterFound = collected.some((rm) =>
|
||||
rm.scopeMetrics.some((s) =>
|
||||
s.metrics.some(
|
||||
(mm) => mm.descriptor.name === "license.generate_total"
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(counterFound).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults useFullSignature to true when omitted", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const r = await generateLicense(validPayload, { signingKey });
|
||||
expect(r.signature.length).toBe(64);
|
||||
});
|
||||
|
||||
it("derives public key from secret when signKey.p is omitted (truncated path)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const p = secp256k1.getPublicKey(s);
|
||||
const r = await generateLicense(validPayload, {
|
||||
signingKey: { s }, // no `p`
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[validPayload.signingKeyId, { p }]]),
|
||||
});
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects out-of-range payload with LICENSE_INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await generateLicense(
|
||||
{ ...validPayload, productId: 64 },
|
||||
{ signingKey }
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPayload", () => {
|
||||
it("createPayload uses injected Rng for nonce", { timeout: 5000 }, () => {
|
||||
const a = createPayload({}, { rng: constantRng(0x123) });
|
||||
const b = createPayload({}, { rng: constantRng(0x123) });
|
||||
expect(a.nonce).toBe(0x123 & 0x3ff);
|
||||
expect(b.nonce).toBe(0x123 & 0x3ff);
|
||||
});
|
||||
|
||||
it("createPayload maps expirationDays === 0 to NONE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createPayload({ expirationDays: 0 });
|
||||
expect(p.timeEncodingType).toBe(TimeEncodingType.NONE);
|
||||
expect(p.timeValue).toBe(0);
|
||||
});
|
||||
|
||||
it("createPayload selects ABSOLUTE_DAYS under useAbsoluteTime", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createPayload(
|
||||
{ expirationDays: 30, useAbsoluteTime: true },
|
||||
{ clock: fakeClock(0) }
|
||||
);
|
||||
expect(p.timeEncodingType).toBe(TimeEncodingType.ABSOLUTE_DAYS);
|
||||
expect(p.timeValue).toBe(30);
|
||||
});
|
||||
|
||||
it("createPayload selects RELATIVE_DAYS by default for positive expirationDays", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createPayload({ expirationDays: 14 });
|
||||
expect(p.timeEncodingType).toBe(TimeEncodingType.RELATIVE_DAYS);
|
||||
expect(p.timeValue).toBe(14);
|
||||
});
|
||||
|
||||
it("createPayload applies defaults (tier=BASIC, productId=1, features=0, flags=0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createPayload({}, { rng: constantRng(0) });
|
||||
expect(p.productId).toBe(1);
|
||||
expect(p.packageTier).toBe(PackageTier.BASIC);
|
||||
expect(p.flags).toBe(0);
|
||||
expect(p.featureMask).toBe(0);
|
||||
expect(p.nonce).toBe(0);
|
||||
expect(p.version).toBe(2);
|
||||
});
|
||||
|
||||
it("createPayload no-arg uses default Rng without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createPayload();
|
||||
expect(p.nonce).toBeGreaterThanOrEqual(0);
|
||||
expect(p.nonce).toBeLessThanOrEqual(1023);
|
||||
});
|
||||
|
||||
it("createPayload honours supplied flags and features", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createPayload(
|
||||
{ flags: 5, features: 0b101 },
|
||||
{ rng: constantRng(0) }
|
||||
);
|
||||
expect(p.flags).toBe(5);
|
||||
expect(p.featureMask).toBe(0b101);
|
||||
});
|
||||
|
||||
it("createPayload rejects out-of-range options with LICENSE_INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createPayload({ tier: 99 });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLicense", () => {
|
||||
it("parseLicense round-trips a generated key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const r = await generateLicense(validPayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const parsed = parseLicense(r.key);
|
||||
expect(parsed.payload).toEqual(r.payload);
|
||||
expect(parsed.signatureBytes.length).toBe(20);
|
||||
});
|
||||
|
||||
it("parseLicense throws LICENSE_MALFORMED on too-short input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
parseLicense("AAAA");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.MALFORMED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
import type z from "zod";
|
||||
import { base32Decode, base32Encode } from "../codec/base32/base32.js";
|
||||
import {
|
||||
deobfuscateLicenseKey,
|
||||
extractSignatureFromKey,
|
||||
obfuscateLicenseKey,
|
||||
} from "../codec/obfuscation/obfuscation.js";
|
||||
import { packPayload, unpackPayload } from "../codec/payload/payload.js";
|
||||
import {
|
||||
LICENSE_SCHEMA,
|
||||
LICENSE_VERSION,
|
||||
PAYLOAD_BYTES,
|
||||
SIGNATURE_BYTES,
|
||||
} from "../constants/constants.js";
|
||||
import { LICENSE_CODES, LicenseError } from "../error-codes.js";
|
||||
import {
|
||||
ATTR_LICENSE_PRODUCT_ID,
|
||||
ATTR_LICENSE_SIGNING_KEY_ID,
|
||||
ATTR_LICENSE_TIER,
|
||||
licenseGenerateTotal,
|
||||
} from "../otel/instruments.js";
|
||||
import {
|
||||
LicenseGenerationOptionsSchema,
|
||||
LicensePayloadSchema,
|
||||
QuickLicenseOptionsSchema,
|
||||
} from "../schemas/schemas.js";
|
||||
import { getDayNumber } from "../time/time.js";
|
||||
import type {
|
||||
CreatePayloadInjection,
|
||||
LicenseGenerationOptions,
|
||||
LicenseGenerationResult,
|
||||
LicenseParsedResult,
|
||||
LicensePayload,
|
||||
QuickLicenseOptions,
|
||||
} from "../types/license.js";
|
||||
import { PackageTier, TimeEncodingType } from "../types/license.js";
|
||||
|
||||
function defaultNonce(): number {
|
||||
const buf = new Uint32Array(1);
|
||||
crypto.getRandomValues(buf);
|
||||
return (buf[0] as number) >>> 0;
|
||||
}
|
||||
|
||||
function ensureNotAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.ABORTED,
|
||||
"License operation was aborted",
|
||||
{ context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseWithLicenseError<S extends z.ZodType>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
label: string
|
||||
): z.infer<S> {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_INPUT,
|
||||
`Invalid ${label}: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
||||
{ cause: result.error, context: { label } }
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a license key from a fully-specified payload. Packs the
|
||||
* payload, signs the packed bytes with secp256k1 over a SHA-256 digest
|
||||
* (wire-compatible with upstream `@suaveplan/crypto`), optionally
|
||||
* truncates the signature, Base32-encodes the result, and applies the
|
||||
* signature-derived obfuscation pass.
|
||||
*/
|
||||
export async function generateLicense(
|
||||
payload: LicensePayload,
|
||||
options: LicenseGenerationOptions
|
||||
): Promise<LicenseGenerationResult> {
|
||||
parseWithLicenseError(LicensePayloadSchema, payload, "license payload");
|
||||
const opts = parseWithLicenseError(
|
||||
LicenseGenerationOptionsSchema,
|
||||
options,
|
||||
"generation options"
|
||||
);
|
||||
|
||||
ensureNotAborted(opts.signal);
|
||||
|
||||
const finalPayload: LicensePayload = {
|
||||
...payload,
|
||||
signingKeyId: opts.signingKeyIdOverride ?? payload.signingKeyId,
|
||||
};
|
||||
|
||||
const payloadBytes = packPayload(finalPayload);
|
||||
const useFullSignature = opts.useFullSignature ?? true;
|
||||
|
||||
ensureNotAborted(opts.signal);
|
||||
|
||||
let signatureToEmbed: Uint8Array;
|
||||
if (useFullSignature) {
|
||||
const digest = sha256(payloadBytes);
|
||||
signatureToEmbed = secp256k1.sign(digest, opts.signingKey.s);
|
||||
} else {
|
||||
// Truncated path: embed sha256(payloadBytes || publicKey.p)[0..N].
|
||||
// Verifier reconstructs the same hash. This binds the key to the
|
||||
// public key — different public keys yield different prefixes,
|
||||
// detecting tampering of either payload or claimed signing key.
|
||||
// Resistance: SHA-256 collision (~2^128) for the truncated length.
|
||||
const publicKey =
|
||||
opts.signingKey.p ?? secp256k1.getPublicKey(opts.signingKey.s);
|
||||
const merged = new Uint8Array(payloadBytes.length + publicKey.length);
|
||||
merged.set(payloadBytes);
|
||||
merged.set(publicKey, payloadBytes.length);
|
||||
signatureToEmbed = sha256(merged).slice(0, SIGNATURE_BYTES);
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(
|
||||
payloadBytes.length + signatureToEmbed.length
|
||||
);
|
||||
combined.set(payloadBytes);
|
||||
combined.set(signatureToEmbed, payloadBytes.length);
|
||||
|
||||
const rawKey = base32Encode(combined);
|
||||
const obfuscationSeed = extractSignatureFromKey(rawKey);
|
||||
const key = obfuscateLicenseKey(rawKey, obfuscationSeed);
|
||||
|
||||
licenseGenerateTotal.add(1, {
|
||||
[ATTR_LICENSE_PRODUCT_ID]: finalPayload.productId,
|
||||
[ATTR_LICENSE_TIER]: finalPayload.packageTier,
|
||||
[ATTR_LICENSE_SIGNING_KEY_ID]: finalPayload.signingKeyId,
|
||||
});
|
||||
|
||||
return {
|
||||
key,
|
||||
payload: finalPayload,
|
||||
signature: signatureToEmbed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full `LicensePayload` from a `QuickLicenseOptions` bag,
|
||||
* filling sensible defaults. The `Rng` (for the nonce) and `Clock`
|
||||
* (for absolute-day expirations) are injectable.
|
||||
*/
|
||||
export function createPayload(
|
||||
options: QuickLicenseOptions = {},
|
||||
injection: CreatePayloadInjection = {}
|
||||
): LicensePayload {
|
||||
const opts = parseWithLicenseError(
|
||||
QuickLicenseOptionsSchema,
|
||||
options,
|
||||
"createPayload options"
|
||||
);
|
||||
|
||||
const rng = injection.rng;
|
||||
const clock = injection.clock;
|
||||
|
||||
const productId = opts.productId ?? 1;
|
||||
const tier = opts.tier ?? PackageTier.BASIC;
|
||||
const expirationDays = opts.expirationDays ?? 0;
|
||||
const flags = opts.flags ?? 0;
|
||||
const features = opts.features ?? 0;
|
||||
const useAbsoluteTime = opts.useAbsoluteTime ?? false;
|
||||
|
||||
let timeEncodingType: TimeEncodingType;
|
||||
let timeValue: number;
|
||||
|
||||
if (expirationDays <= 0) {
|
||||
timeEncodingType = TimeEncodingType.NONE;
|
||||
timeValue = 0;
|
||||
} else if (useAbsoluteTime) {
|
||||
timeEncodingType = TimeEncodingType.ABSOLUTE_DAYS;
|
||||
timeValue = getDayNumber(undefined, clock) + expirationDays;
|
||||
} else {
|
||||
timeEncodingType = TimeEncodingType.RELATIVE_DAYS;
|
||||
timeValue = expirationDays;
|
||||
}
|
||||
|
||||
return {
|
||||
version: LICENSE_VERSION,
|
||||
signingKeyId: 0,
|
||||
productId,
|
||||
packageTier: tier,
|
||||
flags,
|
||||
timeEncodingType,
|
||||
timeValue,
|
||||
featureMask: features,
|
||||
nonce:
|
||||
(rng !== undefined ? rng.nextUint32() : defaultNonce()) &
|
||||
LICENSE_SCHEMA.nonce,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a license key into its payload bytes, signature bytes, and
|
||||
* decoded `LicensePayload`. Does NOT verify the signature or check
|
||||
* expiration — use {@link generateLicense}'s counterpart in `validate`
|
||||
* for that.
|
||||
*/
|
||||
export function parseLicense(licenseKey: string): LicenseParsedResult {
|
||||
const signatureSeed = extractSignatureFromKey(licenseKey);
|
||||
const deobfuscated = deobfuscateLicenseKey(licenseKey, signatureSeed);
|
||||
const bytes = base32Decode(deobfuscated);
|
||||
|
||||
if (bytes.length < PAYLOAD_BYTES + 1) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.MALFORMED,
|
||||
`License key too short: ${bytes.length} bytes (minimum ${PAYLOAD_BYTES + 1})`,
|
||||
{ context: { length: bytes.length, minimum: PAYLOAD_BYTES + 1 } }
|
||||
);
|
||||
}
|
||||
|
||||
const payloadBytes = bytes.slice(0, PAYLOAD_BYTES);
|
||||
const signatureBytes = bytes.slice(PAYLOAD_BYTES);
|
||||
const payload = unpackPayload(payloadBytes);
|
||||
|
||||
return { payloadBytes, signatureBytes, payload };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
parseLicense,
|
||||
} from "./generate.js";
|
||||
102
packages/security-stack/security/license/src/index.ts
Normal file
102
packages/security-stack/security/license/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Side-effect import: registers every LICENSE_* code in the shared
|
||||
// `@suaveplan/error` registry so subpath-only consumers also get them.
|
||||
import "./error-codes.js";
|
||||
|
||||
// Codec
|
||||
export { base32Decode, base32Encode } from "./codec/base32/base32.js";
|
||||
export { SimpleBitPacker, SimpleBitUnpacker } from "./codec/bitpack/bitpack.js";
|
||||
export {
|
||||
deobfuscateLicenseKey,
|
||||
extractSignatureFromKey,
|
||||
obfuscateLicenseKey,
|
||||
} from "./codec/obfuscation/obfuscation.js";
|
||||
export { packPayload, unpackPayload } from "./codec/payload/payload.js";
|
||||
// Constants
|
||||
export {
|
||||
BASE32_ALPHABET,
|
||||
FIELD_BITS,
|
||||
FULL_SIGNATURE_BYTES,
|
||||
LICENSE_SCHEMA,
|
||||
LICENSE_VERSION,
|
||||
MIN_SIGNATURE_BYTES,
|
||||
MS_PER_DAY,
|
||||
OBFUSCATION_PAYLOAD_CHARS,
|
||||
PAYLOAD_BYTES,
|
||||
SIGNATURE_BYTES,
|
||||
} from "./constants/constants.js";
|
||||
export {
|
||||
LICENSE_CODES,
|
||||
LicenseError,
|
||||
type LicenseErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Flags
|
||||
export {
|
||||
combineFeatures,
|
||||
combineFlags,
|
||||
getEnabledFeatures,
|
||||
hasFeature,
|
||||
hasFlag,
|
||||
} from "./flags/flags.js";
|
||||
// Generate / parse / validate
|
||||
export {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
parseLicense,
|
||||
} from "./generate/generate.js";
|
||||
// Manager
|
||||
export type { GenerateTrialOptions } from "./manager/manager.js";
|
||||
export { LicenseManager } from "./manager/manager.js";
|
||||
// OTel
|
||||
export {
|
||||
ATTR_LICENSE_ERROR,
|
||||
ATTR_LICENSE_PRODUCT_ID,
|
||||
ATTR_LICENSE_SIGNING_KEY_ID,
|
||||
ATTR_LICENSE_TIER,
|
||||
ATTR_LICENSE_VALID,
|
||||
licenseGenerateTotal,
|
||||
licenseValidateDurationMs,
|
||||
licenseValidateTotal,
|
||||
} from "./otel/instruments.js";
|
||||
export {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "./revocation/revocation-store/revocation-store.js";
|
||||
// Revocation
|
||||
export type {
|
||||
MemoryRevocationStoreOptions,
|
||||
RevocationStore,
|
||||
} from "./revocation/revocation-store/revocation-store.types.js";
|
||||
// Schemas
|
||||
export {
|
||||
LicenseGenerationOptionsSchema,
|
||||
LicensePayloadSchema,
|
||||
LicenseValidationOptionsSchema,
|
||||
QuickLicenseOptionsSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
// Time
|
||||
export { checkExpiration, dayNumberToDate, getDayNumber } from "./time/time.js";
|
||||
// Types + enums
|
||||
export type {
|
||||
CreatePayloadInjection,
|
||||
KeyRegistry,
|
||||
LicenseDetails,
|
||||
LicenseGenerationOptions,
|
||||
LicenseGenerationResult,
|
||||
LicenseManagerConfig,
|
||||
LicenseParsedResult,
|
||||
LicensePayload,
|
||||
LicenseValidationFailure,
|
||||
LicenseValidationOptions,
|
||||
LicenseValidationResult,
|
||||
LicenseValidationSuccess,
|
||||
PublicKey,
|
||||
QuickLicenseOptions,
|
||||
SignKey,
|
||||
} from "./types/license.js";
|
||||
export {
|
||||
LicenseFlags,
|
||||
LicenseValidationError,
|
||||
PackageTier,
|
||||
TimeEncodingType,
|
||||
} from "./types/license.js";
|
||||
export { isValidLicense, validateLicense } from "./validate/validate.js";
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Spec-conformance tests for `@suaveplan/license`. These tests pin:
|
||||
* - (a) every `LICENSE.*` code is thrown from ≥1 production path
|
||||
* - (b) every Zod boundary is wired
|
||||
* - (c) every OTel instrument fires on the appropriate path
|
||||
* - (d) `AbortSignal` propagates
|
||||
* - (e) `expectTypeOf` on every exported type
|
||||
* - (f) `LicenseValidationError` literal-string-enum compatibility
|
||||
*
|
||||
* The file maps requirements #15-#17 of `specs/license/spec.md` plus the
|
||||
* conformance-style coverage for the 12 error codes and 3 OTel
|
||||
* instruments.
|
||||
*/
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../tests/setup/polyfills.js";
|
||||
import { base32Decode } from "./codec/base32/base32.js";
|
||||
import { packPayload, unpackPayload } from "./codec/payload/payload.js";
|
||||
import {
|
||||
LICENSE_CODES,
|
||||
LicenseError,
|
||||
type LicenseErrorCode,
|
||||
} from "./error-codes.js";
|
||||
import { combineFeatures, hasFeature } from "./flags/flags.js";
|
||||
import {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
parseLicense,
|
||||
} from "./generate/generate.js";
|
||||
import { LicenseManager } from "./manager/manager.js";
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "./revocation/revocation-store/revocation-store.js";
|
||||
import {
|
||||
LicenseGenerationOptionsSchema,
|
||||
LicensePayloadSchema,
|
||||
LicenseValidationOptionsSchema,
|
||||
QuickLicenseOptionsSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
import type {
|
||||
LicenseDetails,
|
||||
LicenseGenerationResult,
|
||||
LicensePayload,
|
||||
LicenseValidationFailure,
|
||||
LicenseValidationResult,
|
||||
LicenseValidationSuccess,
|
||||
PublicKey,
|
||||
SignKey,
|
||||
} from "./types/license.js";
|
||||
import { LicenseValidationError } from "./types/license.js";
|
||||
import { isValidLicense, validateLicense } from "./validate/validate.js";
|
||||
|
||||
function fresh() {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const p = secp256k1.getPublicKey(s);
|
||||
return { signingKey: { s, p }, publicKey: { p } };
|
||||
}
|
||||
|
||||
const basePayload = createPayload({ productId: 5, expirationDays: 30 });
|
||||
|
||||
describe("spec-conformance (a) — every LICENSE_* code is thrown", () => {
|
||||
it("LICENSE.INVALID_INPUT is thrown from generateLicense", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { signingKey } = fresh();
|
||||
await expect(
|
||||
generateLicense({ ...basePayload, productId: 64 }, { signingKey })
|
||||
).rejects.toHaveProperty("code", LICENSE_CODES.INVALID_INPUT);
|
||||
});
|
||||
|
||||
it("LICENSE.INVALID_ENCODING is thrown from base32Decode (via parseLicense)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => base32Decode("XXXX!")).toThrow(LicenseError);
|
||||
try {
|
||||
base32Decode("XXXX!");
|
||||
} catch (e) {
|
||||
expect((e as LicenseError).code).toBe(
|
||||
LICENSE_CODES.INVALID_ENCODING
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.MALFORMED is thrown from parseLicense on short input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseLicense("AAAA");
|
||||
} catch (e) {
|
||||
expect((e as LicenseError).code).toBe(LICENSE_CODES.MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.UNSUPPORTED_VERSION surfaced on validate (not thrown directly)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = fresh();
|
||||
const r = await generateLicense(
|
||||
{ ...basePayload, version: 3 },
|
||||
{ signingKey, useFullSignature: false }
|
||||
);
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.UNSUPPORTED_VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.UNKNOWN_SIGNING_KEY surfaced on validate", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = fresh();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map(),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.UNKNOWN_SIGNING_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.INVALID_SIGNATURE surfaced on validate", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = fresh();
|
||||
const { publicKey: wrongPub } = fresh();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: true,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, wrongPub]]),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.INVALID_SIGNATURE);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.EXPIRED surfaced on validate", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = fresh();
|
||||
const payload: LicensePayload = {
|
||||
...basePayload,
|
||||
timeEncodingType: 1,
|
||||
timeValue: 100,
|
||||
};
|
||||
const r = await generateLicense(payload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[payload.signingKeyId, publicKey]]),
|
||||
referenceDate: new Date(10_000 * 86_400_000),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.EXPIRED);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.REVOKED surfaced on manager.validate when fingerprint is in store", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = fresh();
|
||||
const store = createMemoryRevocationStore();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
revocationStore: store,
|
||||
});
|
||||
const r = await m.generate({ productId: 1 });
|
||||
await m.revoke(fingerprintLicense(r.key));
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.REVOKED);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.NO_SIGNING_KEY is thrown from manager.generate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
new LicenseManager().generate({ productId: 1 })
|
||||
).rejects.toHaveProperty("code", LICENSE_CODES.NO_SIGNING_KEY);
|
||||
});
|
||||
|
||||
it("LICENSE.FEATURE_INDEX_OUT_OF_RANGE is thrown from hasFeature/combineFeatures", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => hasFeature(0, 19)).toThrowError(LicenseError);
|
||||
expect(() => combineFeatures(19)).toThrowError(LicenseError);
|
||||
});
|
||||
|
||||
it("LICENSE.PAYLOAD_OUT_OF_RANGE is thrown from packPayload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
packPayload({ ...basePayload, productId: 64 });
|
||||
} catch (e) {
|
||||
expect((e as LicenseError).code).toBe(
|
||||
LICENSE_CODES.PAYLOAD_OUT_OF_RANGE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("LICENSE.ABORTED is thrown from generateLicense / validateLicense", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = fresh();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
generateLicense(basePayload, {
|
||||
signingKey,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toHaveProperty("code", LICENSE_CODES.ABORTED);
|
||||
|
||||
await expect(
|
||||
validateLicense("XXXXX-XXXXX-XXXXX", {
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toHaveProperty("code", LICENSE_CODES.ABORTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance (b) — every Zod boundary is wired", () => {
|
||||
it("LicensePayloadSchema rejects malformed input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(LicensePayloadSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("LicenseGenerationOptionsSchema rejects missing signingKey", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(LicenseGenerationOptionsSchema.safeParse({}).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("LicenseValidationOptionsSchema rejects missing publicKeys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(LicenseValidationOptionsSchema.safeParse({}).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("QuickLicenseOptionsSchema accepts empty input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(QuickLicenseOptionsSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance (c) — OTel instruments fire from production paths", () => {
|
||||
it("emits license.validate_total{license.valid: true} on success", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
const { signingKey, publicKey } = fresh();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((s) =>
|
||||
s.metrics.map((mm) => mm.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("license.validate_total");
|
||||
});
|
||||
|
||||
it("emits license.validate_total{license.valid: false} on failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
await validateLicense("AAAA", { publicKeys: new Map() });
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((s) =>
|
||||
s.metrics.map((mm) => mm.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("license.validate_total");
|
||||
});
|
||||
|
||||
it("records license.validate_duration_ms", { timeout: 30000 }, async () => {
|
||||
await flushTestMetrics();
|
||||
await validateLicense("AAAA", { publicKeys: new Map() });
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((s) =>
|
||||
s.metrics.map((mm) => mm.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("license.validate_duration_ms");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance (d) — AbortSignal propagates", () => {
|
||||
it("pre-aborted signal rejects generateLicense before crypto runs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { signingKey } = fresh();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
generateLicense(basePayload, {
|
||||
signingKey,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toBeInstanceOf(LicenseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance (e) — expectTypeOf on every exported type", () => {
|
||||
it("LicensePayload has the 9 numeric fields", { timeout: 5000 }, () => {
|
||||
expectTypeOf<LicensePayload>()
|
||||
.toHaveProperty("version")
|
||||
.toEqualTypeOf<number>();
|
||||
expectTypeOf<LicensePayload>()
|
||||
.toHaveProperty("nonce")
|
||||
.toEqualTypeOf<number>();
|
||||
expectTypeOf<LicensePayload>()
|
||||
.toHaveProperty("featureMask")
|
||||
.toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it("LicenseValidationResult is a discriminated union on `success`", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<LicenseValidationResult>().toEqualTypeOf<
|
||||
LicenseValidationSuccess | LicenseValidationFailure
|
||||
>();
|
||||
});
|
||||
|
||||
it("LicenseDetails extends LicensePayload with trust + expiry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<LicenseDetails>().toMatchObjectType<{
|
||||
trusted: boolean;
|
||||
isExpired: boolean;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("LicenseGenerationResult shape", { timeout: 5000 }, () => {
|
||||
expectTypeOf<LicenseGenerationResult>().toMatchObjectType<{
|
||||
key: string;
|
||||
signature: Uint8Array;
|
||||
payload: LicensePayload;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("PublicKey and SignKey shape", { timeout: 5000 }, () => {
|
||||
expectTypeOf<PublicKey>().toMatchObjectType<{ p: Uint8Array }>();
|
||||
expectTypeOf<SignKey>().toMatchObjectType<{ s: Uint8Array }>();
|
||||
});
|
||||
|
||||
it("LicenseErrorCode is the literal union", { timeout: 5000 }, () => {
|
||||
expectTypeOf<LicenseErrorCode>().toEqualTypeOf<
|
||||
(typeof LICENSE_CODES)[keyof typeof LICENSE_CODES]
|
||||
>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance (f) — LicenseValidationError literal-string enum", () => {
|
||||
it("every enum value equals its key as a string", { timeout: 5000 }, () => {
|
||||
expect(LicenseValidationError.MALFORMED).toBe("MALFORMED");
|
||||
expect(LicenseValidationError.INVALID_ENCODING).toBe(
|
||||
"INVALID_ENCODING"
|
||||
);
|
||||
expect(LicenseValidationError.INVALID_SIGNATURE).toBe(
|
||||
"INVALID_SIGNATURE"
|
||||
);
|
||||
expect(LicenseValidationError.UNKNOWN_SIGNING_KEY).toBe(
|
||||
"UNKNOWN_SIGNING_KEY"
|
||||
);
|
||||
expect(LicenseValidationError.EXPIRED).toBe("EXPIRED");
|
||||
expect(LicenseValidationError.UNSUPPORTED_VERSION).toBe(
|
||||
"UNSUPPORTED_VERSION"
|
||||
);
|
||||
expect(LicenseValidationError.REVOKED).toBe("REVOKED");
|
||||
});
|
||||
|
||||
it("isValidLicense returns boolean", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = fresh();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const result = await isValidLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expectTypeOf(result).toEqualTypeOf<boolean>();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance — sibling .md presence (sentinel)", () => {
|
||||
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
// Static sentinel — docs:check enforces the actual rule in CI.
|
||||
// This assertion documents the contract within the test surface
|
||||
// so changes to the file layout surface a failure in addition to
|
||||
// the docs:check tooling exit code.
|
||||
expect(unpackPayload).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { GenerateTrialOptions } from "./manager.js";
|
||||
export { LicenseManager } from "./manager.js";
|
||||
131
packages/security-stack/security/license/src/manager/manager.md
Normal file
131
packages/security-stack/security/license/src/manager/manager.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# `@suaveplan/license` — manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
High-level `LicenseManager` class that bundles a public-key registry,
|
||||
an optional signing key, default `signingKeyId` / `productId`,
|
||||
injectable `Clock` / `Rng`, and an optional `RevocationStore` into a
|
||||
single object. Most callers will use only the manager — the lower-level
|
||||
`generateLicense` / `validateLicense` functions are exposed for the
|
||||
rare case where the caller wants stateless calls.
|
||||
|
||||
`LicenseManager.generate` defaults `useFullSignature: false` (compact
|
||||
keys) to match upstream's caller-side default; passing
|
||||
`{ useFullSignature: true }` opts into 64-byte signatures. `revoke` and
|
||||
`isRevoked` reach into the configured `RevocationStore` (lazily
|
||||
allocating an in-memory store on first `revoke` call if none was
|
||||
configured).
|
||||
|
||||
## Features
|
||||
|
||||
- 11 methods covering the full lifecycle: `addPublicKey`,
|
||||
`setSigningKey`, `setRevocationStore`, `generate`, `generateTrial`,
|
||||
`validate`, `isValid`, `parse`, `revoke`, `isRevoked` (+ constructor).
|
||||
- Quick-trial helper `generateTrial({ expirationDays, productId?,
|
||||
tier?, features? })` that sets the `TRIAL` flag and uses
|
||||
`RELATIVE_DAYS` time encoding.
|
||||
- Injectable `Clock` is used both as the default `referenceDate` for
|
||||
`validate` and as the time base for `createPayload` absolute-day
|
||||
computations.
|
||||
- Injectable `Rng` flows through to `createPayload` for deterministic
|
||||
nonces.
|
||||
- Optional `RevocationStore` consulted before signature verification.
|
||||
- Throws `LicenseError(LICENSE.NO_SIGNING_KEY)` when `generate` is
|
||||
called without a configured signing key.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { LicenseManager, PackageTier } from "@suaveplan/license";
|
||||
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const p = secp256k1.getPublicKey(s);
|
||||
|
||||
const m = new LicenseManager({
|
||||
signingKey: { s, p },
|
||||
publicKeys: { 0: { p } },
|
||||
});
|
||||
|
||||
const r = await m.generate({ productId: 5, tier: PackageTier.PRO });
|
||||
const v = await m.validate(r.key); // { success: true, details: { ... } }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A manager with deterministic test injection, a revocation store, and a
|
||||
multi-key registry:
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import type { Clock, Rng } from "@suaveplan/types";
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
LicenseManager,
|
||||
} from "@suaveplan/license";
|
||||
|
||||
const kp1 = (() => {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
return { s, p: secp256k1.getPublicKey(s) };
|
||||
})();
|
||||
const kp2 = (() => {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
return { s, p: secp256k1.getPublicKey(s) };
|
||||
})();
|
||||
|
||||
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
|
||||
const rng: Rng = { nextUint32: () => 0xdead, nextFloat: () => 0 };
|
||||
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
|
||||
|
||||
const m = new LicenseManager({
|
||||
signingKey: { s: kp1.s },
|
||||
publicKeys: { 0: { p: kp1.p }, 1: { p: kp2.p } },
|
||||
defaultSigningKeyId: 0,
|
||||
defaultProductId: 5,
|
||||
clock,
|
||||
rng,
|
||||
revocationStore: store,
|
||||
});
|
||||
|
||||
const trial = await m.generateTrial({ expirationDays: 14 });
|
||||
await m.revoke(fingerprintLicense(trial.key));
|
||||
const v = await m.validate(trial.key);
|
||||
// → { success: false, error: 'REVOKED', message: 'License has been revoked' }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class LicenseManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `constructor(config?)` | `LicenseManager` | All `config` fields optional |
|
||||
| `addPublicKey(keyId, publicKey)` | `void` | Idempotent overwrite |
|
||||
| `setSigningKey(signingKey, keyId?)` | `void` | Updates default signing key id when supplied |
|
||||
| `setRevocationStore(store)` | `void` | Replaces an existing store |
|
||||
| `generate(options, genOptions?)` | `Promise<LicenseGenerationResult>` | Throws `NO_SIGNING_KEY` if unconfigured |
|
||||
| `generateTrial(opts)` | `Promise<LicenseGenerationResult>` | Sets `TRIAL` flag, `RELATIVE_DAYS` encoding |
|
||||
| `validate(licenseKey, options?)` | `Promise<LicenseValidationResult>` | Consults revocation store first |
|
||||
| `isValid(licenseKey)` | `Promise<boolean>` | Wraps `validate` |
|
||||
| `parse(licenseKey)` | `LicenseParsedResult` | No verification |
|
||||
| `revoke(fingerprint)` | `Promise<void>` | Lazily allocates store if none |
|
||||
| `isRevoked(fingerprint)` | `Promise<boolean>` | Returns `false` if no store |
|
||||
|
||||
### `interface GenerateTrialOptions`
|
||||
|
||||
`{ expirationDays: number, productId?: number, tier?: PackageTier, features?: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy-allocation behaviour of `revoke` (auto-creates an in-memory
|
||||
store on first call) means simple consumers can use revocation without
|
||||
constructing a store explicitly, while still letting power users plug
|
||||
in a custom backend. The manager's `clock` injection drives BOTH the
|
||||
generation-time `getDayNumber` (for absolute-day expirations) AND the
|
||||
validation-time `referenceDate` (when none is supplied) — keeping the
|
||||
two sides on the same simulated time line for deterministic tests.
|
||||
@@ -0,0 +1,363 @@
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, Rng } from "@suaveplan/types";
|
||||
import { LicenseError } from "../error-codes.js";
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "../revocation/revocation-store/revocation-store.js";
|
||||
import {
|
||||
LicenseFlags,
|
||||
LicenseValidationError,
|
||||
PackageTier,
|
||||
TimeEncodingType,
|
||||
} from "../types/license.js";
|
||||
import { LicenseManager } from "./manager.js";
|
||||
|
||||
function freshKeypair() {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const p = secp256k1.getPublicKey(s);
|
||||
return { signingKey: { s, p }, publicKey: { p } };
|
||||
}
|
||||
|
||||
describe("LicenseManager", () => {
|
||||
it("throws LICENSE_NO_SIGNING_KEY when generating without a signing key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new LicenseManager();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await m.generate({ productId: 1 });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.NO_SIGNING_KEY");
|
||||
});
|
||||
|
||||
it("generated key validates via the manager", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: { 0: publicKey },
|
||||
defaultSigningKeyId: 0,
|
||||
});
|
||||
const r = await m.generate({
|
||||
productId: 3,
|
||||
tier: PackageTier.BUSINESS,
|
||||
});
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("addPublicKey extends the registry", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
defaultSigningKeyId: 7,
|
||||
});
|
||||
m.addPublicKey(7, publicKey);
|
||||
const r = await m.generate({ productId: 1 });
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("setSigningKey replaces the signing key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey: sk1 } = freshKeypair();
|
||||
const { signingKey: sk2, publicKey: pk2 } = freshKeypair();
|
||||
const m = new LicenseManager({ signingKey: sk1 });
|
||||
m.setSigningKey(sk2, 3);
|
||||
m.addPublicKey(3, pk2);
|
||||
const r = await m.generate({ productId: 1 });
|
||||
expect(r.payload.signingKeyId).toBe(3);
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("generateTrial sets TRIAL flag and RELATIVE_DAYS expiry", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
});
|
||||
const r = await m.generateTrial({ expirationDays: 14 });
|
||||
const parsed = m.parse(r.key);
|
||||
expect((parsed.payload.flags & LicenseFlags.TRIAL) !== 0).toBe(true);
|
||||
expect(parsed.payload.timeEncodingType).toBe(
|
||||
TimeEncodingType.RELATIVE_DAYS
|
||||
);
|
||||
expect(parsed.payload.timeValue).toBe(14);
|
||||
});
|
||||
|
||||
it("accepts a full LicensePayload directly (non-zero signingKeyId)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[3, publicKey]]),
|
||||
});
|
||||
const r = await m.generate({
|
||||
version: 2,
|
||||
signingKeyId: 3,
|
||||
productId: 9,
|
||||
packageTier: PackageTier.PRO,
|
||||
flags: 0,
|
||||
timeEncodingType: TimeEncodingType.NONE,
|
||||
timeValue: 0,
|
||||
featureMask: 0,
|
||||
nonce: 42,
|
||||
});
|
||||
expect(r.payload.nonce).toBe(42);
|
||||
expect(r.payload.signingKeyId).toBe(3);
|
||||
});
|
||||
|
||||
it("accepts a full LicensePayload with signingKeyId=0 (defaults applied)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
});
|
||||
const r = await m.generate({
|
||||
version: 2,
|
||||
signingKeyId: 0,
|
||||
productId: 9,
|
||||
packageTier: PackageTier.PRO,
|
||||
flags: 0,
|
||||
timeEncodingType: TimeEncodingType.NONE,
|
||||
timeValue: 0,
|
||||
featureMask: 0,
|
||||
nonce: 7,
|
||||
});
|
||||
expect(r.payload.nonce).toBe(7);
|
||||
});
|
||||
|
||||
it("uses injected Clock for expiry checks", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
// 2026-06-17 12:00:00 UTC
|
||||
const farFuture = Date.UTC(2026, 5, 17, 12, 0, 0);
|
||||
const clock: Clock = () => farFuture;
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
clock,
|
||||
});
|
||||
// Build a key with ABSOLUTE_DAYS expiring at day 1000 (way in the past)
|
||||
const past = await m.generate({
|
||||
version: 2,
|
||||
signingKeyId: 0,
|
||||
productId: 1,
|
||||
packageTier: 0,
|
||||
flags: 0,
|
||||
timeEncodingType: TimeEncodingType.ABSOLUTE_DAYS,
|
||||
timeValue: 1000,
|
||||
featureMask: 0,
|
||||
nonce: 1,
|
||||
});
|
||||
const v = await m.validate(past.key);
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.EXPIRED);
|
||||
}
|
||||
});
|
||||
|
||||
it("revoked key validates as REVOKED", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const store = createMemoryRevocationStore();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
revocationStore: store,
|
||||
});
|
||||
const r = await m.generate({ productId: 1 });
|
||||
await m.revoke(fingerprintLicense(r.key));
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.REVOKED);
|
||||
}
|
||||
});
|
||||
|
||||
it("awaits async revocation store", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const inner = createMemoryRevocationStore();
|
||||
const asyncStore = {
|
||||
async add(f: string) {
|
||||
return inner.add(f);
|
||||
},
|
||||
async has(f: string) {
|
||||
return inner.has(f);
|
||||
},
|
||||
async list() {
|
||||
return inner.list();
|
||||
},
|
||||
};
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
});
|
||||
m.setRevocationStore(asyncStore);
|
||||
const r = await m.generate({ productId: 1 });
|
||||
await m.revoke(fingerprintLicense(r.key));
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(false);
|
||||
});
|
||||
|
||||
it("isValid wraps validate", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
});
|
||||
const r = await m.generate({ productId: 1 });
|
||||
expect(await m.isValid(r.key)).toBe(true);
|
||||
expect(await m.isValid("AAAA")).toBe(false);
|
||||
});
|
||||
|
||||
it("isRevoked returns false when no store is configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new LicenseManager();
|
||||
expect(await m.isRevoked("xyz")).toBe(false);
|
||||
});
|
||||
|
||||
it("revoke without store lazily creates one", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new LicenseManager();
|
||||
await m.revoke("abc");
|
||||
expect(await m.isRevoked("abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaultProductId applied when generate options omit it", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
defaultProductId: 42,
|
||||
});
|
||||
const r = await m.generate({});
|
||||
expect(r.payload.productId).toBe(42);
|
||||
});
|
||||
|
||||
it("validate accepts overriding creationDate / referenceDate / signal", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
});
|
||||
const r = await m.generate({
|
||||
productId: 1,
|
||||
expirationDays: 7,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
const v = await m.validate(r.key, {
|
||||
creationDate: new Date("2026-01-01T00:00:00Z"),
|
||||
referenceDate: new Date("2026-01-02T00:00:00Z"),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("valid key with configured store but not revoked still succeeds", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const store = createMemoryRevocationStore();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
revocationStore: store,
|
||||
});
|
||||
const r = await m.generate({ productId: 1 });
|
||||
// store is empty — key should validate normally
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("uses injected Clock for createPayload absolute-time computation", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const clock: Clock = () => 0;
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
clock,
|
||||
});
|
||||
// Use QuickLicenseOptions (not full payload) so createPayload runs
|
||||
const r = await m.generate({
|
||||
productId: 1,
|
||||
expirationDays: 30,
|
||||
useAbsoluteTime: true,
|
||||
});
|
||||
expect(r.payload.timeValue).toBe(30); // 0 + 30 days
|
||||
});
|
||||
|
||||
it("constructor accepts injected Rng for nonce", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const rng: Rng = { nextUint32: () => 0xabc, nextFloat: () => 0 };
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
rng,
|
||||
});
|
||||
const r = await m.generate({ productId: 1 });
|
||||
expect(r.payload.nonce).toBe(0xabc & 0x3ff);
|
||||
});
|
||||
|
||||
it("setSigningKey without keyId preserves default", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey: sk1 } = freshKeypair();
|
||||
const { signingKey: sk2, publicKey: pk2 } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey: sk1,
|
||||
defaultSigningKeyId: 5,
|
||||
});
|
||||
m.setSigningKey(sk2); // no keyId
|
||||
m.addPublicKey(5, pk2);
|
||||
const r = await m.generate({ productId: 1 });
|
||||
expect(r.payload.signingKeyId).toBe(5);
|
||||
});
|
||||
|
||||
it("manager without clock validates with default referenceDate", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const m = new LicenseManager({
|
||||
signingKey,
|
||||
publicKeys: new Map([[0, publicKey]]),
|
||||
});
|
||||
const r = await m.generate({ productId: 1 });
|
||||
const v = await m.validate(r.key);
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("generate forwards an explicit signal to generateLicense", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const m = new LicenseManager({ signingKey });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
m.generate({ productId: 1 }, { signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(LicenseError);
|
||||
});
|
||||
});
|
||||
204
packages/security-stack/security/license/src/manager/manager.ts
Normal file
204
packages/security-stack/security/license/src/manager/manager.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { Clock, Rng } from "@suaveplan/types";
|
||||
import { LICENSE_CODES, LicenseError } from "../error-codes.js";
|
||||
import {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
parseLicense,
|
||||
} from "../generate/generate.js";
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "../revocation/revocation-store/revocation-store.js";
|
||||
import type { RevocationStore } from "../revocation/revocation-store/revocation-store.types.js";
|
||||
import type {
|
||||
LicenseGenerationResult,
|
||||
LicenseManagerConfig,
|
||||
LicenseParsedResult,
|
||||
LicensePayload,
|
||||
LicenseValidationOptions,
|
||||
LicenseValidationResult,
|
||||
PublicKey,
|
||||
QuickLicenseOptions,
|
||||
SignKey,
|
||||
} from "../types/license.js";
|
||||
import {
|
||||
LicenseFlags,
|
||||
LicenseValidationError,
|
||||
PackageTier,
|
||||
} from "../types/license.js";
|
||||
import { validateLicense } from "../validate/validate.js";
|
||||
|
||||
function normalizePublicKeys(
|
||||
publicKeys: Map<number, PublicKey> | Record<number, PublicKey>
|
||||
): Map<number, PublicKey> {
|
||||
if (publicKeys instanceof Map) return publicKeys;
|
||||
const map = new Map<number, PublicKey>();
|
||||
for (const [id, key] of Object.entries(publicKeys)) {
|
||||
map.set(Number(id), key as PublicKey);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function isFullPayload(
|
||||
value: QuickLicenseOptions | LicensePayload
|
||||
): value is LicensePayload {
|
||||
return "version" in value && "nonce" in value && "featureMask" in value;
|
||||
}
|
||||
|
||||
/** Quick-trial options for {@link LicenseManager.generateTrial}. */
|
||||
export interface GenerateTrialOptions {
|
||||
expirationDays: number;
|
||||
productId?: number;
|
||||
tier?: PackageTier;
|
||||
features?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level license manager. Holds a public-key registry, an optional
|
||||
* signing key, optional defaults for `signingKeyId` / `productId`, and
|
||||
* an optional injectable `Clock` / `Rng` / `RevocationStore`.
|
||||
*/
|
||||
export class LicenseManager {
|
||||
private readonly publicKeys: Map<number, PublicKey>;
|
||||
private signingKey?: SignKey;
|
||||
private defaultSigningKeyId: number;
|
||||
private readonly defaultProductId: number;
|
||||
private readonly clock?: Clock;
|
||||
private readonly rng?: Rng;
|
||||
private revocationStore?: RevocationStore;
|
||||
|
||||
public constructor(config: LicenseManagerConfig = {}) {
|
||||
this.publicKeys = config.publicKeys
|
||||
? normalizePublicKeys(config.publicKeys)
|
||||
: new Map();
|
||||
if (config.signingKey !== undefined) {
|
||||
this.signingKey = config.signingKey;
|
||||
}
|
||||
this.defaultSigningKeyId = config.defaultSigningKeyId ?? 0;
|
||||
this.defaultProductId = config.defaultProductId ?? 1;
|
||||
if (config.clock !== undefined) this.clock = config.clock;
|
||||
if (config.rng !== undefined) this.rng = config.rng;
|
||||
if (config.revocationStore !== undefined) {
|
||||
this.revocationStore = config.revocationStore;
|
||||
}
|
||||
}
|
||||
|
||||
public addPublicKey(keyId: number, publicKey: PublicKey): void {
|
||||
this.publicKeys.set(keyId, publicKey);
|
||||
}
|
||||
|
||||
public setSigningKey(signingKey: SignKey, keyId?: number): void {
|
||||
this.signingKey = signingKey;
|
||||
if (keyId !== undefined) {
|
||||
this.defaultSigningKeyId = keyId;
|
||||
}
|
||||
}
|
||||
|
||||
public setRevocationStore(store: RevocationStore): void {
|
||||
this.revocationStore = store;
|
||||
}
|
||||
|
||||
public async generate(
|
||||
options: QuickLicenseOptions | LicensePayload,
|
||||
genOptions: { useFullSignature?: boolean; signal?: AbortSignal } = {}
|
||||
): Promise<LicenseGenerationResult> {
|
||||
if (!this.signingKey) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.NO_SIGNING_KEY,
|
||||
"No signing key configured on LicenseManager",
|
||||
{ context: { method: "generate" } }
|
||||
);
|
||||
}
|
||||
|
||||
const payload = isFullPayload(options)
|
||||
? options
|
||||
: createPayload(
|
||||
{
|
||||
...options,
|
||||
productId: options.productId ?? this.defaultProductId,
|
||||
},
|
||||
{
|
||||
...(this.clock !== undefined
|
||||
? { clock: this.clock }
|
||||
: {}),
|
||||
...(this.rng !== undefined ? { rng: this.rng } : {}),
|
||||
}
|
||||
);
|
||||
|
||||
if (payload.signingKeyId === 0) {
|
||||
payload.signingKeyId = this.defaultSigningKeyId;
|
||||
}
|
||||
|
||||
return generateLicense(payload, {
|
||||
signingKey: this.signingKey,
|
||||
useFullSignature: genOptions.useFullSignature ?? false,
|
||||
...(genOptions.signal !== undefined
|
||||
? { signal: genOptions.signal }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
public async generateTrial(
|
||||
opts: GenerateTrialOptions
|
||||
): Promise<LicenseGenerationResult> {
|
||||
return this.generate({
|
||||
productId: opts.productId ?? this.defaultProductId,
|
||||
tier: opts.tier ?? PackageTier.BASIC,
|
||||
expirationDays: opts.expirationDays,
|
||||
flags: LicenseFlags.TRIAL,
|
||||
features: opts.features ?? 0,
|
||||
useAbsoluteTime: false,
|
||||
});
|
||||
}
|
||||
|
||||
public async validate(
|
||||
licenseKey: string,
|
||||
options: Partial<LicenseValidationOptions> = {}
|
||||
): Promise<LicenseValidationResult> {
|
||||
const store = this.revocationStore;
|
||||
if (store !== undefined) {
|
||||
const isRevoked = await store.has(fingerprintLicense(licenseKey));
|
||||
if (isRevoked) {
|
||||
return {
|
||||
success: false,
|
||||
error: LicenseValidationError.REVOKED,
|
||||
message: "License has been revoked",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const referenceDate =
|
||||
options.referenceDate ??
|
||||
(this.clock !== undefined ? new Date(this.clock()) : undefined);
|
||||
|
||||
return validateLicense(licenseKey, {
|
||||
publicKeys: this.publicKeys,
|
||||
...(referenceDate !== undefined ? { referenceDate } : {}),
|
||||
...(options.creationDate !== undefined
|
||||
? { creationDate: options.creationDate }
|
||||
: {}),
|
||||
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
public async isValid(licenseKey: string): Promise<boolean> {
|
||||
const r = await this.validate(licenseKey);
|
||||
return r.success;
|
||||
}
|
||||
|
||||
public parse(licenseKey: string): LicenseParsedResult {
|
||||
return parseLicense(licenseKey);
|
||||
}
|
||||
|
||||
public async revoke(fingerprint: string): Promise<void> {
|
||||
if (!this.revocationStore) {
|
||||
this.revocationStore = createMemoryRevocationStore();
|
||||
}
|
||||
await this.revocationStore.add(fingerprint);
|
||||
}
|
||||
|
||||
public async isRevoked(fingerprint: string): Promise<boolean> {
|
||||
if (!this.revocationStore) return false;
|
||||
return await this.revocationStore.has(fingerprint);
|
||||
}
|
||||
}
|
||||
10
packages/security-stack/security/license/src/otel/index.ts
Normal file
10
packages/security-stack/security/license/src/otel/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
ATTR_LICENSE_ERROR,
|
||||
ATTR_LICENSE_PRODUCT_ID,
|
||||
ATTR_LICENSE_SIGNING_KEY_ID,
|
||||
ATTR_LICENSE_TIER,
|
||||
ATTR_LICENSE_VALID,
|
||||
licenseGenerateTotal,
|
||||
licenseValidateDurationMs,
|
||||
licenseValidateTotal,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# `@suaveplan/license` — otel/instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrumentation for the license package. Registers a
|
||||
single `@opentelemetry/api` meter (`@suaveplan/license@0.1.0`) and
|
||||
attaches three instruments to it: a generation counter, a validation
|
||||
counter (attribute `license.valid: boolean`), and a validation-duration
|
||||
histogram (unit `ms`). When no SDK is wired the meter falls back to the
|
||||
Noop implementation, so importing this module has zero side effects in
|
||||
production unless the host application registers a real provider.
|
||||
|
||||
## Features
|
||||
|
||||
- `license.generate_total` — counter incremented once per successful
|
||||
`generateLicense` call.
|
||||
- `license.validate_total` — counter incremented per validation call
|
||||
with attribute `license.valid: boolean` (and, on failure, `license.error`
|
||||
carrying the enum value).
|
||||
- `license.validate_duration_ms` — histogram of validation latency in
|
||||
milliseconds.
|
||||
- Five `ATTR_*` constants exported so call sites and tests reference
|
||||
attribute keys by name, not by stringly-typed literals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ATTR_LICENSE_VALID,
|
||||
licenseValidateTotal,
|
||||
} from "@suaveplan/license/otel/instruments";
|
||||
|
||||
licenseValidateTotal.add(1, { [ATTR_LICENSE_VALID]: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire an in-memory provider for tests and assert that the instruments
|
||||
emit:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// ...exercise the license package...
|
||||
|
||||
await provider.forceFlush();
|
||||
console.log(exporter.getMetrics());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Instruments
|
||||
|
||||
- `licenseGenerateTotal: Counter`
|
||||
- `licenseValidateTotal: Counter`
|
||||
- `licenseValidateDurationMs: Histogram`
|
||||
|
||||
### Attribute keys
|
||||
|
||||
- `ATTR_LICENSE_VALID = "license.valid"`
|
||||
- `ATTR_LICENSE_ERROR = "license.error"`
|
||||
- `ATTR_LICENSE_TIER = "license.tier"`
|
||||
- `ATTR_LICENSE_PRODUCT_ID = "license.product_id"`
|
||||
- `ATTR_LICENSE_SIGNING_KEY_ID = "license.signing_key_id"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is fetched at module load via `metrics.getMeter(...)`. The
|
||||
`@opentelemetry/api` library guarantees that subsequent calls with the
|
||||
same name+version return the same meter instance, so importing
|
||||
`instruments.ts` from multiple call sites does not create duplicate
|
||||
counters. The Noop fallback means `licenseGenerateTotal.add(...)` is a
|
||||
zero-cost call when no SDK is wired — safe to leave the instrumentation
|
||||
on in every code path.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
ATTR_LICENSE_VALID,
|
||||
licenseGenerateTotal,
|
||||
licenseValidateDurationMs,
|
||||
licenseValidateTotal,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("OTel instruments", () => {
|
||||
it("registers counter and histogram instruments", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
licenseGenerateTotal.add(1, { "license.tier": 2 });
|
||||
licenseValidateTotal.add(1, { [ATTR_LICENSE_VALID]: true });
|
||||
licenseValidateDurationMs.record(7);
|
||||
|
||||
const collected = await flushTestMetrics();
|
||||
expect(collected.length).toBeGreaterThan(0);
|
||||
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((s) =>
|
||||
s.metrics.map((mm) => mm.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("license.generate_total");
|
||||
expect(names).toContain("license.validate_total");
|
||||
expect(names).toContain("license.validate_duration_ms");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/license";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
const meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
/** Counter — number of license keys generated. */
|
||||
export const licenseGenerateTotal = meter.createCounter(
|
||||
"license.generate_total",
|
||||
{
|
||||
description: "Total license keys generated",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter — number of validation attempts. Attribute `license.valid: boolean`. */
|
||||
export const licenseValidateTotal = meter.createCounter(
|
||||
"license.validate_total",
|
||||
{
|
||||
description: "Total license validation attempts",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram — validation duration in milliseconds. */
|
||||
export const licenseValidateDurationMs = meter.createHistogram(
|
||||
"license.validate_duration_ms",
|
||||
{
|
||||
description: "Duration of license validation in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Attribute keys, exported so tests can assert on them by name. */
|
||||
export const ATTR_LICENSE_VALID = "license.valid" as const;
|
||||
export const ATTR_LICENSE_ERROR = "license.error" as const;
|
||||
export const ATTR_LICENSE_TIER = "license.tier" as const;
|
||||
export const ATTR_LICENSE_PRODUCT_ID = "license.product_id" as const;
|
||||
export const ATTR_LICENSE_SIGNING_KEY_ID = "license.signing_key_id" as const;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "./revocation-store.js";
|
||||
export type {
|
||||
MemoryRevocationStoreOptions,
|
||||
RevocationStore,
|
||||
} from "./revocation-store.types.js";
|
||||
@@ -0,0 +1,102 @@
|
||||
# `@suaveplan/license` — revocation/revocation-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pluggable revocation blocklist for the license package. The
|
||||
`RevocationStore` interface defines three methods (`add`, `has`,
|
||||
`list`), each of which may return synchronously or as a `Promise` —
|
||||
that lets callers wire an in-memory store for tests, a Redis-backed
|
||||
store for production, or a remote API without changing the
|
||||
`LicenseManager` call sites. A synchronous in-memory implementation
|
||||
ships with the package; `fingerprintLicense` produces a SHA-256 hex of
|
||||
an obfuscated key suitable as a store key.
|
||||
|
||||
## Features
|
||||
|
||||
- `createMemoryRevocationStore(options?)` — synchronous in-memory store
|
||||
with optional `maxEntries` FIFO eviction cap.
|
||||
- `fingerprintLicense(licenseKey)` — stable SHA-256 hex of the
|
||||
(obfuscated) license-key string.
|
||||
- Insertion-ordered `list()` snapshot for backup / migration.
|
||||
- Re-adding an existing fingerprint moves it to the end (LRU-on-write)
|
||||
so frequently-revoked keys survive eviction longest.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "@suaveplan/license/revocation/revocation-store/revocation-store";
|
||||
|
||||
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
|
||||
await store.add(fingerprintLicense("XXXXX-YYYYY-..."));
|
||||
const blocked = await store.has(fingerprintLicense("XXXXX-YYYYY-..."));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a custom async store backed by a database:
|
||||
|
||||
```ts
|
||||
import type { RevocationStore } from "@suaveplan/license";
|
||||
|
||||
function createDbRevocationStore(db: SomeDb): RevocationStore {
|
||||
return {
|
||||
async add(fingerprint) {
|
||||
await db.insert("revocations", { fingerprint });
|
||||
},
|
||||
async has(fingerprint) {
|
||||
return await db.exists("revocations", { fingerprint });
|
||||
},
|
||||
async list() {
|
||||
const rows = await db.select("revocations", ["fingerprint"]);
|
||||
return rows.map((r) => r.fingerprint);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface RevocationStore`
|
||||
|
||||
```ts
|
||||
interface RevocationStore {
|
||||
add(fingerprint: string): void | Promise<void>;
|
||||
has(fingerprint: string): boolean | Promise<boolean>;
|
||||
list(): readonly string[] | Promise<readonly string[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### `interface MemoryRevocationStoreOptions`
|
||||
|
||||
```ts
|
||||
interface MemoryRevocationStoreOptions {
|
||||
readonly maxEntries?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### `createMemoryRevocationStore(options?): RevocationStore`
|
||||
|
||||
Build a fresh in-memory store. Returns synchronous methods; safe to
|
||||
`await` regardless.
|
||||
|
||||
### `fingerprintLicense(licenseKey: string): string`
|
||||
|
||||
SHA-256 hex of the UTF-8 bytes of `licenseKey`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The in-memory store uses a `Set<string>` because `Set` preserves
|
||||
insertion order in every modern JS engine and gives O(1) `add`/`has`.
|
||||
The LRU-on-write semantic (re-add moves to end) matches the typical
|
||||
"a fingerprint just got revoked again, so keep it" intuition without
|
||||
needing a separate access timestamp. `fingerprintLicense` uses SHA-256
|
||||
because the fingerprint is the wire-facing key for the store; using
|
||||
the obfuscated key directly would make the store quadratic in key
|
||||
length and leak structure.
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "./revocation-store.js";
|
||||
import type { RevocationStore } from "./revocation-store.types.js";
|
||||
|
||||
describe("createMemoryRevocationStore", () => {
|
||||
it("add then has returns true", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryRevocationStore();
|
||||
await store.add("abc");
|
||||
expect(await store.has("abc")).toBe(true);
|
||||
expect(await store.has("xyz")).toBe(false);
|
||||
});
|
||||
|
||||
it("list returns insertion order", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryRevocationStore();
|
||||
await store.add("a");
|
||||
await store.add("b");
|
||||
await store.add("c");
|
||||
expect(await store.list()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("LRU evicts oldest when maxEntries exceeded", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryRevocationStore({ maxEntries: 2 });
|
||||
await store.add("a");
|
||||
await store.add("b");
|
||||
await store.add("c");
|
||||
expect(await store.list()).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("re-adding moves entry to end", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryRevocationStore({ maxEntries: 3 });
|
||||
await store.add("a");
|
||||
await store.add("b");
|
||||
await store.add("a"); // re-add a
|
||||
await store.add("c");
|
||||
await store.add("d"); // should evict b, not a
|
||||
expect(await store.list()).toEqual(["a", "c", "d"]);
|
||||
});
|
||||
|
||||
it("async-shape implementation is awaitable", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const inner = createMemoryRevocationStore();
|
||||
const asyncStore: RevocationStore = {
|
||||
async add(f) {
|
||||
return inner.add(f);
|
||||
},
|
||||
async has(f) {
|
||||
return inner.has(f);
|
||||
},
|
||||
async list() {
|
||||
return inner.list();
|
||||
},
|
||||
};
|
||||
await asyncStore.add("xyz");
|
||||
expect(await asyncStore.has("xyz")).toBe(true);
|
||||
expect(await asyncStore.list()).toEqual(["xyz"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fingerprintLicense", () => {
|
||||
it("fingerprintLicense returns stable sha256 hex", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const f1 = fingerprintLicense("ABCDE-FGHJK-MNPQR");
|
||||
const f2 = fingerprintLicense("ABCDE-FGHJK-MNPQR");
|
||||
expect(f1).toBe(f2);
|
||||
expect(f1).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("different keys produce different fingerprints", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(fingerprintLicense("AAAAA")).not.toBe(
|
||||
fingerprintLicense("BBBBB")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { sha256, toHex } from "@suaveplan/crypto-hash";
|
||||
import type {
|
||||
MemoryRevocationStoreOptions,
|
||||
RevocationStore,
|
||||
} from "./revocation-store.types.js";
|
||||
|
||||
const utf8 = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Synchronous in-memory {@link RevocationStore}. Insertion-ordered; when
|
||||
* an `add` would exceed `maxEntries`, the oldest entries are evicted
|
||||
* (FIFO). Defaults to unbounded.
|
||||
*/
|
||||
export function createMemoryRevocationStore(
|
||||
options: MemoryRevocationStoreOptions = {}
|
||||
): RevocationStore {
|
||||
const max = options.maxEntries;
|
||||
const set = new Set<string>();
|
||||
|
||||
return {
|
||||
add(fingerprint: string): void {
|
||||
if (set.has(fingerprint)) {
|
||||
set.delete(fingerprint); // re-insert at end (LRU-on-write)
|
||||
}
|
||||
set.add(fingerprint);
|
||||
if (max !== undefined && set.size > max) {
|
||||
const oldest = set.values().next().value as string;
|
||||
set.delete(oldest);
|
||||
}
|
||||
},
|
||||
has(fingerprint: string): boolean {
|
||||
return set.has(fingerprint);
|
||||
},
|
||||
list(): readonly string[] {
|
||||
return Array.from(set);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 hex of the (obfuscated) license-key string. Suitable as a
|
||||
* `RevocationStore` key — collision resistance is 2^128, far beyond
|
||||
* any plausible adversarial key-mint rate.
|
||||
*/
|
||||
export function fingerprintLicense(licenseKey: string): string {
|
||||
const bytes = utf8.encode(licenseKey);
|
||||
return toHex(sha256(bytes));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pluggable revocation store contract. Synchronous or asynchronous: every
|
||||
* method may return either the bare value or a `Promise` of it, letting
|
||||
* callers wire an in-memory store, a Redis-backed store, or a remote
|
||||
* blocklist without changing the consumer's call sites.
|
||||
*/
|
||||
export interface RevocationStore {
|
||||
/** Append a fingerprint (hex SHA-256 of the obfuscated key). */
|
||||
add(fingerprint: string): void | Promise<void>;
|
||||
/** Probe presence of a fingerprint. */
|
||||
has(fingerprint: string): boolean | Promise<boolean>;
|
||||
/** Snapshot every fingerprint currently in the store, in insertion order. */
|
||||
list(): readonly string[] | Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
/** Options for {@link createMemoryRevocationStore}. */
|
||||
export interface MemoryRevocationStoreOptions {
|
||||
/**
|
||||
* Optional upper bound on the number of retained fingerprints. When
|
||||
* `add` would exceed `maxEntries`, the oldest entries are evicted
|
||||
* (insertion-order FIFO).
|
||||
*/
|
||||
readonly maxEntries?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
LicenseGenerationOptionsSchema,
|
||||
LicensePayloadSchema,
|
||||
LicenseValidationOptionsSchema,
|
||||
QuickLicenseOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# `@suaveplan/license` — schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas for every public input boundary in the license package.
|
||||
`generateLicense`, `validateLicense`, `createPayload`, and the
|
||||
`LicenseManager` methods parse their inputs against these schemas at
|
||||
entry; failed parses raise `LicenseError(LICENSE.INVALID_INPUT)` with
|
||||
the `ZodError` attached as `cause`. Centralising the schemas here keeps
|
||||
the runtime modules thin and ensures downstream Zod-aware consumers
|
||||
(form validators, OpenAPI generators) can reuse the same shapes.
|
||||
|
||||
## Features
|
||||
|
||||
- `LicensePayloadSchema` — every 9 payload fields bounded by the
|
||||
per-field maximums in `LICENSE_SCHEMA`.
|
||||
- `LicenseGenerationOptionsSchema` — wraps `SignKey` (with a `Uint8Array`
|
||||
secret), the optional `signingKeyIdOverride`, `useFullSignature`,
|
||||
`signal`, and `rng`.
|
||||
- `LicenseValidationOptionsSchema` — accepts `publicKeys` as either a
|
||||
`Map` or a `Record`, mirroring the upstream API.
|
||||
- `QuickLicenseOptionsSchema` — covers the quick-builder fields with the
|
||||
same per-field bounds.
|
||||
- Every schema is `safeParse`-friendly; the runtime callers turn parse
|
||||
failures into typed `LicenseError`s.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
|
||||
|
||||
const parsed = LicensePayloadSchema.parse(input); // throws ZodError on failure
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Turn a parse failure into a typed `LicenseError`:
|
||||
|
||||
```ts
|
||||
import { LICENSE_CODES, LicenseError } from "@suaveplan/license";
|
||||
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
|
||||
|
||||
function parsePayload(input: unknown) {
|
||||
const r = LicensePayloadSchema.safeParse(input);
|
||||
if (!r.success) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_INPUT,
|
||||
"Invalid license payload",
|
||||
{ cause: r.error, context: { issues: r.error.issues.length } }
|
||||
);
|
||||
}
|
||||
return r.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LicensePayloadSchema`
|
||||
|
||||
`z.object({ version, signingKeyId, productId, packageTier, flags,
|
||||
timeEncodingType, timeValue, featureMask, nonce })` with every field
|
||||
bounded by `LICENSE_SCHEMA`.
|
||||
|
||||
### `LicenseGenerationOptionsSchema`
|
||||
|
||||
`z.object({ signingKey, signingKeyIdOverride?, useFullSignature?, signal?,
|
||||
rng? })`. `signingKey` is `{ s: Uint8Array, p?: Uint8Array }`.
|
||||
|
||||
### `LicenseValidationOptionsSchema`
|
||||
|
||||
`z.object({ publicKeys, referenceDate?, creationDate?, signal? })`.
|
||||
`publicKeys` is a `Map` or `Record<number, { p: Uint8Array }>`.
|
||||
|
||||
### `QuickLicenseOptionsSchema`
|
||||
|
||||
`z.object({ productId?, tier?, expirationDays?, flags?, features?,
|
||||
useAbsoluteTime? })` with per-field bounds.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`rng` is intentionally typed as `z.unknown().optional()` because the
|
||||
`Rng` interface from `@suaveplan/types` is structural (a duck-typed bag
|
||||
of methods); attempting to validate it with a structural Zod schema
|
||||
would force consumers to surface every optional method even when their
|
||||
RNG implementation chooses not to. The runtime modules treat `rng` as
|
||||
opaque and call only the documented `nextUint32` method, so the loose
|
||||
schema is safe.
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import {
|
||||
LicenseGenerationOptionsSchema,
|
||||
LicensePayloadSchema,
|
||||
LicenseValidationOptionsSchema,
|
||||
QuickLicenseOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
const validPayload = {
|
||||
version: 2,
|
||||
signingKeyId: 0,
|
||||
productId: 1,
|
||||
packageTier: 2,
|
||||
flags: 0,
|
||||
timeEncodingType: 0,
|
||||
timeValue: 0,
|
||||
featureMask: 0,
|
||||
nonce: 5,
|
||||
};
|
||||
|
||||
describe("zod schemas", () => {
|
||||
it("LicensePayloadSchema accepts valid payload", { timeout: 5000 }, () => {
|
||||
const r = LicensePayloadSchema.safeParse(validPayload);
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("LicensePayloadSchema rejects missing fields", { timeout: 5000 }, () => {
|
||||
const r = LicensePayloadSchema.safeParse({ version: 2 });
|
||||
expect(r.success).toBe(false);
|
||||
if (!r.success) {
|
||||
expect(r.error).toBeInstanceOf(z.ZodError);
|
||||
}
|
||||
});
|
||||
|
||||
it("LicensePayloadSchema rejects out-of-range field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = LicensePayloadSchema.safeParse({
|
||||
...validPayload,
|
||||
productId: 64,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("LicenseGenerationOptionsSchema requires signingKey", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = LicenseGenerationOptionsSchema.safeParse({});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("LicenseGenerationOptionsSchema accepts a SignKey with bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = LicenseGenerationOptionsSchema.safeParse({
|
||||
signingKey: { s: new Uint8Array(32) },
|
||||
useFullSignature: true,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("LicenseValidationOptionsSchema accepts Map publicKeys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = LicenseValidationOptionsSchema.safeParse({
|
||||
publicKeys: new Map([[0, { p: new Uint8Array(33) }]]),
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("LicenseValidationOptionsSchema accepts Record publicKeys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = LicenseValidationOptionsSchema.safeParse({
|
||||
publicKeys: { 0: { p: new Uint8Array(33) } },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("QuickLicenseOptionsSchema accepts empty input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = QuickLicenseOptionsSchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("QuickLicenseOptionsSchema rejects out-of-range tier", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = QuickLicenseOptionsSchema.safeParse({ tier: 99 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("QuickLicenseOptionsSchema rejects negative expirationDays", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = QuickLicenseOptionsSchema.safeParse({
|
||||
expirationDays: -1,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import z from "zod";
|
||||
import { LICENSE_SCHEMA } from "../constants/constants.js";
|
||||
|
||||
const u8 = (max: number): z.ZodNumber =>
|
||||
z.number().int().nonnegative().max(max);
|
||||
|
||||
/** Zod schema mirroring `LicensePayload`. Each numeric field is bounded by `LICENSE_SCHEMA`. */
|
||||
export const LicensePayloadSchema = z.object({
|
||||
version: u8(LICENSE_SCHEMA.version),
|
||||
signingKeyId: u8(LICENSE_SCHEMA.signingKeyId),
|
||||
productId: u8(LICENSE_SCHEMA.productId),
|
||||
packageTier: u8(LICENSE_SCHEMA.packageTier),
|
||||
flags: u8(LICENSE_SCHEMA.flags),
|
||||
timeEncodingType: u8(LICENSE_SCHEMA.timeEncodingType),
|
||||
timeValue: u8(LICENSE_SCHEMA.timeValue),
|
||||
featureMask: u8(LICENSE_SCHEMA.featureMask),
|
||||
nonce: u8(LICENSE_SCHEMA.nonce),
|
||||
});
|
||||
|
||||
const SignKeySchema = z.object({
|
||||
s: z.instanceof(Uint8Array),
|
||||
p: z.instanceof(Uint8Array).optional(),
|
||||
});
|
||||
|
||||
const PublicKeySchema = z.object({
|
||||
p: z.instanceof(Uint8Array),
|
||||
});
|
||||
|
||||
/** Zod schema mirroring `LicenseGenerationOptions`. */
|
||||
export const LicenseGenerationOptionsSchema = z.object({
|
||||
signingKey: SignKeySchema,
|
||||
signingKeyIdOverride: u8(LICENSE_SCHEMA.signingKeyId).optional(),
|
||||
useFullSignature: z.boolean().optional(),
|
||||
signal: z.instanceof(AbortSignal).optional(),
|
||||
rng: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema mirroring `LicenseValidationOptions`. */
|
||||
export const LicenseValidationOptionsSchema = z.object({
|
||||
publicKeys: z.union([
|
||||
z.instanceof(Map),
|
||||
z.record(z.union([z.string(), z.number()]), PublicKeySchema),
|
||||
]),
|
||||
referenceDate: z.instanceof(Date).optional(),
|
||||
creationDate: z.instanceof(Date).optional(),
|
||||
signal: z.instanceof(AbortSignal).optional(),
|
||||
});
|
||||
|
||||
/** Zod schema mirroring `QuickLicenseOptions`. */
|
||||
export const QuickLicenseOptionsSchema = z.object({
|
||||
productId: u8(LICENSE_SCHEMA.productId).optional(),
|
||||
tier: u8(LICENSE_SCHEMA.packageTier).optional(),
|
||||
expirationDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(LICENSE_SCHEMA.timeValue)
|
||||
.optional(),
|
||||
flags: u8(LICENSE_SCHEMA.flags).optional(),
|
||||
features: u8(LICENSE_SCHEMA.featureMask).optional(),
|
||||
useAbsoluteTime: z.boolean().optional(),
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
checkExpiration,
|
||||
dayNumberToDate,
|
||||
getDayNumber,
|
||||
} from "./time.js";
|
||||
85
packages/security-stack/security/license/src/time/time.md
Normal file
85
packages/security-stack/security/license/src/time/time.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# `@suaveplan/license` — time
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Time-encoding helpers for the four `TimeEncodingType` variants. The
|
||||
72-bit payload encodes time in 18 bits with an additional 2-bit
|
||||
discriminator (`NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` /
|
||||
`ABSOLUTE_TIMESTAMP`), giving four distinct expiration strategies. This
|
||||
module turns those numeric fields into concrete `{ isExpired,
|
||||
expirationDate }` records and lets callers inject a `Clock` (from
|
||||
`@suaveplan/types`) for deterministic test runs.
|
||||
|
||||
## Features
|
||||
|
||||
- Four expiration strategies, one switch — no separate code paths per
|
||||
call site.
|
||||
- Injectable `Clock` parameter; defaults to `Date.now()` so production
|
||||
callers don't have to thread it through.
|
||||
- `getDayNumber` / `dayNumberToDate` round-trip helpers used by
|
||||
generation when computing absolute-day expirations.
|
||||
- Default-arm fall-through returns `{ isExpired: false }` for unknown
|
||||
encodings, matching upstream's permissive behaviour (so a future
|
||||
encoding variant never bricks an older validator).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { checkExpiration } from "@suaveplan/license/time/time";
|
||||
import { TimeEncodingType } from "@suaveplan/license/types/license";
|
||||
|
||||
const r = checkExpiration(TimeEncodingType.NONE, 0);
|
||||
console.log(r.isExpired); // false (perpetual)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Threading a deterministic `Clock` through to relative-day expiry:
|
||||
|
||||
```ts
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { checkExpiration } from "@suaveplan/license/time/time";
|
||||
import { TimeEncodingType } from "@suaveplan/license/types/license";
|
||||
|
||||
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
|
||||
const creation = new Date("2026-06-01T00:00:00Z");
|
||||
const r = checkExpiration(
|
||||
TimeEncodingType.RELATIVE_DAYS,
|
||||
14,
|
||||
undefined, // use clock
|
||||
creation,
|
||||
clock
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getDayNumber(date?: Date, clock?: Clock): number`
|
||||
|
||||
Days since the Unix epoch. Uses `date.getTime()` when provided, otherwise
|
||||
the injected `clock` (default `Date.now()`).
|
||||
|
||||
### `dayNumberToDate(dayNumber: number): Date`
|
||||
|
||||
Days since Unix epoch → `Date`.
|
||||
|
||||
### `checkExpiration(timeEncodingType, timeValue, referenceDate?, creationDate?, clock?): { isExpired, expirationDate? }`
|
||||
|
||||
Determine expiration for a license. `referenceDate` defaults to
|
||||
`new Date(clock())`. `creationDate` is consulted only for
|
||||
`RELATIVE_DAYS` (falls back to `referenceDate`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The unknown-encoding default arm (`return { isExpired: false }`) is
|
||||
intentional — it makes adding a new encoding variant a backward-
|
||||
compatible change for existing validators, which is important because
|
||||
license keys are long-lived and may outlive the validator that
|
||||
originally minted them. The `clock` parameter sits last in the
|
||||
argument list and defaults to `Date.now()`, so the public
|
||||
`checkExpiration` call site stays compact in normal usage and only
|
||||
expands when a test needs determinism.
|
||||
118
packages/security-stack/security/license/src/time/time.test.ts
Normal file
118
packages/security-stack/security/license/src/time/time.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { TimeEncodingType } from "../types/license.js";
|
||||
import { checkExpiration, dayNumberToDate, getDayNumber } from "./time.js";
|
||||
|
||||
describe("time helpers", () => {
|
||||
it("NONE is never expired", { timeout: 5000 }, () => {
|
||||
const result = checkExpiration(TimeEncodingType.NONE, 0);
|
||||
expect(result.isExpired).toBe(false);
|
||||
expect(result.expirationDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ABSOLUTE_DAYS expires after its day number", { timeout: 5000 }, () => {
|
||||
const day = 20000; // 2024-10-04ish
|
||||
const refAfter = new Date((day + 1) * 86_400_000);
|
||||
const refBefore = new Date((day - 1) * 86_400_000);
|
||||
const after = checkExpiration(
|
||||
TimeEncodingType.ABSOLUTE_DAYS,
|
||||
day,
|
||||
refAfter
|
||||
);
|
||||
const before = checkExpiration(
|
||||
TimeEncodingType.ABSOLUTE_DAYS,
|
||||
day,
|
||||
refBefore
|
||||
);
|
||||
expect(after.isExpired).toBe(true);
|
||||
expect(after.expirationDate?.getTime()).toBe(day * 86_400_000);
|
||||
expect(before.isExpired).toBe(false);
|
||||
});
|
||||
|
||||
it("RELATIVE_DAYS uses creationDate baseline", { timeout: 5000 }, () => {
|
||||
const creation = new Date("2026-01-01T00:00:00Z");
|
||||
const refAfter = new Date("2026-01-15T00:00:01Z"); // 14 days + 1s
|
||||
const refBefore = new Date("2026-01-10T00:00:00Z");
|
||||
const after = checkExpiration(
|
||||
TimeEncodingType.RELATIVE_DAYS,
|
||||
14,
|
||||
refAfter,
|
||||
creation
|
||||
);
|
||||
const before = checkExpiration(
|
||||
TimeEncodingType.RELATIVE_DAYS,
|
||||
14,
|
||||
refBefore,
|
||||
creation
|
||||
);
|
||||
expect(after.isExpired).toBe(true);
|
||||
expect(before.isExpired).toBe(false);
|
||||
});
|
||||
|
||||
it("RELATIVE_DAYS falls back to referenceDate when no creationDate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ref = new Date("2026-01-01T00:00:00Z");
|
||||
const r = checkExpiration(TimeEncodingType.RELATIVE_DAYS, 5, ref);
|
||||
expect(r.isExpired).toBe(false);
|
||||
expect(r.expirationDate?.getTime()).toBe(
|
||||
ref.getTime() + 5 * 86_400_000
|
||||
);
|
||||
});
|
||||
|
||||
it("ABSOLUTE_TIMESTAMP expires after its second", { timeout: 5000 }, () => {
|
||||
const ts = 1_700_000_000; // a moment in 2023
|
||||
const before = checkExpiration(
|
||||
TimeEncodingType.ABSOLUTE_TIMESTAMP,
|
||||
ts,
|
||||
new Date((ts - 10) * 1000)
|
||||
);
|
||||
const after = checkExpiration(
|
||||
TimeEncodingType.ABSOLUTE_TIMESTAMP,
|
||||
ts,
|
||||
new Date((ts + 10) * 1000)
|
||||
);
|
||||
expect(before.isExpired).toBe(false);
|
||||
expect(after.isExpired).toBe(true);
|
||||
});
|
||||
|
||||
it("uses injected Clock when no referenceDate supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixed = 1_000_000_000_000; // 2001-09-09T01:46:40Z
|
||||
const clock: Clock = () => fixed;
|
||||
// ABSOLUTE_DAYS at day 11574 (which equals fixed/MS_PER_DAY=11574)
|
||||
const day = Math.floor(fixed / 86_400_000);
|
||||
const exactNow = checkExpiration(
|
||||
TimeEncodingType.ABSOLUTE_DAYS,
|
||||
day - 1,
|
||||
undefined,
|
||||
undefined,
|
||||
clock
|
||||
);
|
||||
expect(exactNow.isExpired).toBe(true);
|
||||
});
|
||||
|
||||
it("default unknown encoding falls through to non-expired", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = checkExpiration(99 as TimeEncodingType, 0);
|
||||
expect(r.isExpired).toBe(false);
|
||||
});
|
||||
|
||||
it("getDayNumber uses provided date", { timeout: 5000 }, () => {
|
||||
expect(getDayNumber(new Date(0))).toBe(0);
|
||||
expect(getDayNumber(new Date(86_400_000))).toBe(1);
|
||||
});
|
||||
|
||||
it("getDayNumber uses injected clock when no date", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock: Clock = () => 5 * 86_400_000;
|
||||
expect(getDayNumber(undefined, clock)).toBe(5);
|
||||
});
|
||||
|
||||
it("dayNumberToDate inverts getDayNumber", { timeout: 5000 }, () => {
|
||||
expect(dayNumberToDate(10).getTime()).toBe(10 * 86_400_000);
|
||||
});
|
||||
});
|
||||
60
packages/security-stack/security/license/src/time/time.ts
Normal file
60
packages/security-stack/security/license/src/time/time.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { MS_PER_DAY } from "../constants/constants.js";
|
||||
import { TimeEncodingType } from "../types/license.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Convert a `Date` (or now) to days since the Unix epoch. The 18-bit
|
||||
* `timeValue` field can hold ~718 years of day numbers.
|
||||
*/
|
||||
export function getDayNumber(date?: Date, clock: Clock = defaultClock): number {
|
||||
const ms = date !== undefined ? date.getTime() : clock();
|
||||
return Math.floor(ms / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/** Inverse of {@link getDayNumber}. */
|
||||
export function dayNumberToDate(dayNumber: number): Date {
|
||||
return new Date(dayNumber * MS_PER_DAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a license is expired given its time-encoding metadata.
|
||||
* The optional `clock` defaults to `Date.now()` and is read only when
|
||||
* `referenceDate` is omitted.
|
||||
*/
|
||||
export function checkExpiration(
|
||||
timeEncodingType: TimeEncodingType,
|
||||
timeValue: number,
|
||||
referenceDate?: Date,
|
||||
creationDate?: Date,
|
||||
clock: Clock = defaultClock
|
||||
): { isExpired: boolean; expirationDate?: Date } {
|
||||
const ref = referenceDate ?? new Date(clock());
|
||||
|
||||
switch (timeEncodingType) {
|
||||
case TimeEncodingType.NONE:
|
||||
return { isExpired: false };
|
||||
|
||||
case TimeEncodingType.ABSOLUTE_DAYS: {
|
||||
const expirationDate = dayNumberToDate(timeValue);
|
||||
return { isExpired: ref > expirationDate, expirationDate };
|
||||
}
|
||||
|
||||
case TimeEncodingType.RELATIVE_DAYS: {
|
||||
const creation = creationDate ?? ref;
|
||||
const expirationDate = new Date(
|
||||
creation.getTime() + timeValue * MS_PER_DAY
|
||||
);
|
||||
return { isExpired: ref > expirationDate, expirationDate };
|
||||
}
|
||||
|
||||
case TimeEncodingType.ABSOLUTE_TIMESTAMP: {
|
||||
const expirationDate = new Date(timeValue * 1000);
|
||||
return { isExpired: ref > expirationDate, expirationDate };
|
||||
}
|
||||
|
||||
default:
|
||||
return { isExpired: false };
|
||||
}
|
||||
}
|
||||
24
packages/security-stack/security/license/src/types/index.ts
Normal file
24
packages/security-stack/security/license/src/types/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type {
|
||||
CreatePayloadInjection,
|
||||
KeyRegistry,
|
||||
LicenseDetails,
|
||||
LicenseGenerationOptions,
|
||||
LicenseGenerationResult,
|
||||
LicenseManagerConfig,
|
||||
LicenseParsedResult,
|
||||
LicensePayload,
|
||||
LicenseValidationFailure,
|
||||
LicenseValidationOptions,
|
||||
LicenseValidationResult,
|
||||
LicenseValidationSuccess,
|
||||
PublicKey,
|
||||
QuickLicenseOptions,
|
||||
RevocationStore,
|
||||
SignKey,
|
||||
} from "./license.js";
|
||||
export {
|
||||
LicenseFlags,
|
||||
LicenseValidationError,
|
||||
PackageTier,
|
||||
TimeEncodingType,
|
||||
} from "./license.js";
|
||||
121
packages/security-stack/security/license/src/types/license.md
Normal file
121
packages/security-stack/security/license/src/types/license.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `@suaveplan/license` — types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Public type surface for the license package, ported verbatim from the
|
||||
upstream `index.types.ts` at the wire-format level. This module owns
|
||||
every interface, type alias, and enum that crosses the package
|
||||
boundary; the runtime modules import their shapes from here so the
|
||||
binary contract (72-bit payload layout, signature lengths, validation
|
||||
error variants) is declared in one place.
|
||||
|
||||
Two additive suaveplan extensions sit alongside the upstream surface:
|
||||
`LicenseValidationError.REVOKED` (additional failure variant) and the
|
||||
`LicenseManagerConfig.{clock, rng, revocationStore}` injection trio.
|
||||
Both are opt-in and never affect callers that don't reach for them.
|
||||
|
||||
## Features
|
||||
|
||||
- Three enums verbatim from upstream: `TimeEncodingType`,
|
||||
`LicenseFlags`, `PackageTier`.
|
||||
- `LicensePayload` (the 9-field 72-bit struct), `LicenseDetails`,
|
||||
`LicenseGenerationOptions`/`Result`, `LicenseValidationOptions`,
|
||||
`LicenseValidationResult` discriminated union.
|
||||
- `PublicKey` / `SignKey` shaped to upstream's `{ p, s }` records so the
|
||||
truncated-signature verification path round-trips bit-identically.
|
||||
- `RevocationStore` re-export so the package root can serve a single
|
||||
named entry for downstream consumers.
|
||||
- `CreatePayloadInjection` bag carrying optional `Clock` and `Rng`,
|
||||
threaded through to deterministic test runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LicensePayload,
|
||||
LicenseValidationResult,
|
||||
} from "@suaveplan/license/types/license";
|
||||
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
|
||||
|
||||
const payload: LicensePayload = {
|
||||
version: 2,
|
||||
signingKeyId: 0,
|
||||
productId: 7,
|
||||
packageTier: PackageTier.PRO,
|
||||
flags: 0,
|
||||
timeEncodingType: TimeEncodingType.NONE,
|
||||
timeValue: 0,
|
||||
featureMask: 0,
|
||||
nonce: 0x123,
|
||||
};
|
||||
|
||||
function isExpired(result: LicenseValidationResult): boolean {
|
||||
return !result.success && result.error === "EXPIRED";
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Narrowing the discriminated union and switching on every error variant:
|
||||
|
||||
```ts
|
||||
import {
|
||||
LicenseValidationError,
|
||||
type LicenseValidationResult,
|
||||
} from "@suaveplan/license/types/license";
|
||||
|
||||
function describe(result: LicenseValidationResult): string {
|
||||
if (result.success) {
|
||||
return `Valid (tier ${result.details.packageTier})`;
|
||||
}
|
||||
switch (result.error) {
|
||||
case LicenseValidationError.MALFORMED: return "Malformed key";
|
||||
case LicenseValidationError.INVALID_ENCODING: return "Invalid encoding";
|
||||
case LicenseValidationError.INVALID_SIGNATURE: return "Bad signature";
|
||||
case LicenseValidationError.UNKNOWN_SIGNING_KEY: return "Unknown key";
|
||||
case LicenseValidationError.EXPIRED: return "Expired";
|
||||
case LicenseValidationError.UNSUPPORTED_VERSION: return "Wrong version";
|
||||
case LicenseValidationError.REVOKED: return "Revoked";
|
||||
default: {
|
||||
const _exhaustive: never = result.error;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Enums
|
||||
|
||||
- `TimeEncodingType` — `NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` / `ABSOLUTE_TIMESTAMP`.
|
||||
- `LicenseFlags` — 5-bit bitfield: `TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`, `EDUCATIONAL`.
|
||||
- `PackageTier` — `FREE` / `BASIC` / `PRO` / `BUSINESS` / `ENTERPRISE`.
|
||||
- `LicenseValidationError` — string-literal enum with seven variants (six verbatim + additive `REVOKED`).
|
||||
|
||||
### Interfaces
|
||||
|
||||
`PublicKey`, `SignKey`, `LicensePayload`, `LicenseDetails`,
|
||||
`LicenseGenerationOptions`, `LicenseGenerationResult`,
|
||||
`LicenseValidationOptions`, `LicenseValidationFailure`,
|
||||
`LicenseValidationSuccess`, `LicenseParsedResult`, `KeyRegistry`,
|
||||
`LicenseManagerConfig`, `QuickLicenseOptions`, `CreatePayloadInjection`,
|
||||
`RevocationStore` (re-export).
|
||||
|
||||
### Types
|
||||
|
||||
`LicenseValidationResult = LicenseValidationSuccess | LicenseValidationFailure`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every enum is declared with literal numeric or string values that match
|
||||
the upstream wire format; do not renumber. `LicensePayload` is plain
|
||||
`number` for each field — runtime bounds checks live in
|
||||
`packPayload` (via `LICENSE_SCHEMA`) and the Zod schemas in
|
||||
`src/schemas/schemas.ts`, leaving the type free of brand types so
|
||||
consumers can construct payloads from arbitrary integer math without
|
||||
fighting the type system.
|
||||
216
packages/security-stack/security/license/src/types/license.ts
Normal file
216
packages/security-stack/security/license/src/types/license.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Public type surface for `@suaveplan/license`. Ported verbatim from the
|
||||
* upstream `index.types.ts` at the wire-format level, with two additive
|
||||
* genesis extensions:
|
||||
*
|
||||
* - `LicenseValidationError.REVOKED` — additional enum variant for
|
||||
* revocation failures (the original 6 variants are preserved unchanged).
|
||||
* - `LicenseManagerConfig` gains optional `clock?`, `rng?`,
|
||||
* `revocationStore?` for genesis dependency injection.
|
||||
*
|
||||
* `PublicKey` and `SignKey` are shaped identically to upstream
|
||||
* (`{ p: Uint8Array }` / `{ s: Uint8Array; p?: Uint8Array }`) so the
|
||||
* truncated-signature verification path (`payloadBytes || publicKey.p`)
|
||||
* works bit-identically to upstream.
|
||||
*/
|
||||
|
||||
import type { Clock, Rng } from "@suaveplan/types";
|
||||
import type { RevocationStore } from "../revocation/revocation-store/revocation-store.types.js";
|
||||
|
||||
/* =======================
|
||||
KEY MATERIAL (wire-compatible with upstream `@suaveplan/crypto`)
|
||||
======================= */
|
||||
|
||||
/**
|
||||
* secp256k1 public key. `p` is the 33-byte compressed-form public-key
|
||||
* material — the shape used by upstream's truncated-signature
|
||||
* verification scheme (`SHA-256(payloadBytes || publicKey.p)`).
|
||||
*/
|
||||
export interface PublicKey {
|
||||
readonly p: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* secp256k1 signing key. `s` is the 32-byte secret scalar. `p` is the
|
||||
* optional matching public-key material — not used by signing but kept
|
||||
* to mirror the upstream `SignKey` shape so consumers can pass the same
|
||||
* record to both libraries.
|
||||
*/
|
||||
export interface SignKey {
|
||||
readonly s: Uint8Array;
|
||||
readonly p?: Uint8Array;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
ENUMS (verbatim from upstream)
|
||||
======================= */
|
||||
|
||||
/** Time-encoding strategy for license expiration. */
|
||||
export enum TimeEncodingType {
|
||||
/** No expiration — license is perpetual. */
|
||||
NONE = 0,
|
||||
/** Absolute days since Unix epoch (Jan 1, 1970). */
|
||||
ABSOLUTE_DAYS = 1,
|
||||
/** Relative days from license creation/activation. */
|
||||
RELATIVE_DAYS = 2,
|
||||
/** Absolute timestamp in seconds since Unix epoch. */
|
||||
ABSOLUTE_TIMESTAMP = 3,
|
||||
}
|
||||
|
||||
/** License flags for various license states and features (5 bits). */
|
||||
export enum LicenseFlags {
|
||||
/** Trial license. */
|
||||
TRIAL = 1 << 0,
|
||||
/** Upgrade from a previous version. */
|
||||
UPGRADE = 1 << 1,
|
||||
/** Machine-locked license (requires hardware ID). */
|
||||
MACHINE_LOCKED = 1 << 2,
|
||||
/** Subscription-based license. */
|
||||
SUBSCRIPTION = 1 << 3,
|
||||
/** Educational / academic license. */
|
||||
EDUCATIONAL = 1 << 4,
|
||||
}
|
||||
|
||||
/** Package / pricing tier levels. */
|
||||
export enum PackageTier {
|
||||
FREE = 0,
|
||||
BASIC = 1,
|
||||
PRO = 2,
|
||||
BUSINESS = 3,
|
||||
ENTERPRISE = 4,
|
||||
}
|
||||
|
||||
/* =======================
|
||||
PAYLOAD + RESULT TYPES (verbatim from upstream)
|
||||
======================= */
|
||||
|
||||
/** Decoded license payload data (72-bit packed). */
|
||||
export interface LicensePayload {
|
||||
version: number;
|
||||
signingKeyId: number;
|
||||
productId: number;
|
||||
packageTier: number;
|
||||
flags: number;
|
||||
timeEncodingType: TimeEncodingType;
|
||||
timeValue: number;
|
||||
featureMask: number;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
/** Validated license details with trust + expiration metadata. */
|
||||
export interface LicenseDetails extends LicensePayload {
|
||||
trusted: boolean;
|
||||
isExpired: boolean;
|
||||
expirationDate?: Date;
|
||||
rawKey?: string;
|
||||
}
|
||||
|
||||
/** Options for license generation. */
|
||||
export interface LicenseGenerationOptions {
|
||||
signingKey: SignKey;
|
||||
signingKeyIdOverride?: number;
|
||||
/**
|
||||
* Use full 64-byte signature (`true`) or truncated `SIGNATURE_BYTES`
|
||||
* signature (`false`). Defaults at the function level to `true`;
|
||||
* `LicenseManager.generate` flips the default to `false` to match
|
||||
* upstream's compact-key behaviour.
|
||||
*/
|
||||
useFullSignature?: boolean;
|
||||
/** Optional abort signal for cancelling the underlying crypto call. */
|
||||
signal?: AbortSignal;
|
||||
/** Injectable RNG used by `createPayload` when called transitively. */
|
||||
rng?: Rng;
|
||||
}
|
||||
|
||||
/** Options for license validation. */
|
||||
export interface LicenseValidationOptions {
|
||||
publicKeys: Map<number, PublicKey> | Record<number, PublicKey>;
|
||||
referenceDate?: Date;
|
||||
creationDate?: Date;
|
||||
/** Optional abort signal for cancelling the underlying crypto call. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Result of license generation. */
|
||||
export interface LicenseGenerationResult {
|
||||
key: string;
|
||||
payload: LicensePayload;
|
||||
signature: Uint8Array;
|
||||
}
|
||||
|
||||
/** Result of license parsing (before validation). */
|
||||
export interface LicenseParsedResult {
|
||||
payloadBytes: Uint8Array;
|
||||
signatureBytes: Uint8Array;
|
||||
payload: LicensePayload;
|
||||
}
|
||||
|
||||
/** License validation error variants (literal-string enum). */
|
||||
export enum LicenseValidationError {
|
||||
MALFORMED = "MALFORMED",
|
||||
INVALID_ENCODING = "INVALID_ENCODING",
|
||||
INVALID_SIGNATURE = "INVALID_SIGNATURE",
|
||||
UNKNOWN_SIGNING_KEY = "UNKNOWN_SIGNING_KEY",
|
||||
EXPIRED = "EXPIRED",
|
||||
UNSUPPORTED_VERSION = "UNSUPPORTED_VERSION",
|
||||
/** Additive genesis variant — surfaced when a manager's `RevocationStore` contains the key's fingerprint. */
|
||||
REVOKED = "REVOKED",
|
||||
}
|
||||
|
||||
/** Detailed validation failure result. */
|
||||
export interface LicenseValidationFailure {
|
||||
success: false;
|
||||
error: LicenseValidationError;
|
||||
message: string;
|
||||
partialDetails?: Partial<LicenseDetails>;
|
||||
}
|
||||
|
||||
/** Successful validation result. */
|
||||
export interface LicenseValidationSuccess {
|
||||
success: true;
|
||||
details: LicenseDetails;
|
||||
}
|
||||
|
||||
/** Discriminated union returned by `validateLicense`. */
|
||||
export type LicenseValidationResult =
|
||||
| LicenseValidationSuccess
|
||||
| LicenseValidationFailure;
|
||||
|
||||
/** Key registry for managing public keys (introspection helpers). */
|
||||
export interface KeyRegistry {
|
||||
getPublicKey(keyId: number): PublicKey | undefined;
|
||||
hasKey(keyId: number): boolean;
|
||||
getKeyIds(): number[];
|
||||
}
|
||||
|
||||
/** Manager constructor configuration. */
|
||||
export interface LicenseManagerConfig {
|
||||
publicKeys?: Map<number, PublicKey> | Record<number, PublicKey>;
|
||||
signingKey?: SignKey;
|
||||
defaultSigningKeyId?: number;
|
||||
defaultProductId?: number;
|
||||
/** Injectable wall-clock; defaults to `() => Date.now()`. */
|
||||
clock?: Clock;
|
||||
/** Injectable RNG; defaults to `crypto.getRandomValues`-backed source. */
|
||||
rng?: Rng;
|
||||
/** Opt-in pluggable revocation blocklist. */
|
||||
revocationStore?: RevocationStore;
|
||||
}
|
||||
|
||||
/** Quick-builder options for `createPayload`. */
|
||||
export interface QuickLicenseOptions {
|
||||
productId?: number;
|
||||
tier?: PackageTier;
|
||||
expirationDays?: number;
|
||||
flags?: LicenseFlags | number;
|
||||
features?: number;
|
||||
useAbsoluteTime?: boolean;
|
||||
}
|
||||
|
||||
/** Options for the `createPayload` quick-builder (injection bag). */
|
||||
export interface CreatePayloadInjection {
|
||||
clock?: Clock;
|
||||
rng?: Rng;
|
||||
}
|
||||
|
||||
export type { RevocationStore } from "../revocation/revocation-store/revocation-store.types.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
isValidLicense,
|
||||
validateLicense,
|
||||
} from "./validate.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
# `@suaveplan/license` — validate
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
License-key validation: parse, verify signature, check version, check
|
||||
expiration, surface every failure as a typed enum on a discriminated
|
||||
union. `validateLicense` accepts `publicKeys` as either a `Map` or a
|
||||
plain `Record` (matching upstream), honours an optional `AbortSignal`,
|
||||
and emits the OpenTelemetry counter / histogram pair on every call.
|
||||
`isValidLicense` is a convenience boolean wrapper.
|
||||
|
||||
The package's truncated-signature scheme (default 20 bytes) cannot use
|
||||
standard secp256k1 verification (which requires the full 64-byte
|
||||
signature). The upstream-compatible fallback hashes `payloadBytes ||
|
||||
publicKey.p` with SHA-256 and compares the first N bytes against the
|
||||
embedded signature bytes — giving ~2^160 forgery resistance for the
|
||||
default 20-byte length.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven failure modes returned on the discriminated union:
|
||||
`MALFORMED`, `INVALID_ENCODING`, `INVALID_SIGNATURE`,
|
||||
`UNKNOWN_SIGNING_KEY`, `EXPIRED`, `UNSUPPORTED_VERSION`, `REVOKED`
|
||||
(`REVOKED` is surfaced by `LicenseManager.validate`).
|
||||
- Full-signature path uses `@suaveplan/crypto-curve`'s
|
||||
`secp256k1.verify` on the SHA-256 digest of `payloadBytes`.
|
||||
- Truncated-signature path uses the upstream hash-prefix scheme.
|
||||
- Zod boundary validation on `options`; failures raise
|
||||
`LicenseError(LICENSE.INVALID_INPUT)`.
|
||||
- OTel emission: counter `license.validate_total{license.valid,
|
||||
license.error?}` and histogram `license.validate_duration_ms`.
|
||||
- Pre-aborted `AbortSignal` rejects with
|
||||
`LicenseError(LICENSE.ABORTED)` before any crypto runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateLicense } from "@suaveplan/license/validate/validate";
|
||||
|
||||
const v = await validateLicense(key, {
|
||||
publicKeys: { 0: { p: publicKeyBytes } },
|
||||
});
|
||||
if (v.success) {
|
||||
console.log("Tier:", v.details.packageTier);
|
||||
} else {
|
||||
console.warn("Invalid:", v.error, v.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Validate with an injected `referenceDate`, `creationDate` for a
|
||||
relative-day expiry check, and an `AbortSignal` for cancellation:
|
||||
|
||||
```ts
|
||||
import { validateLicense } from "@suaveplan/license/validate/validate";
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 100);
|
||||
try {
|
||||
const v = await validateLicense(key, {
|
||||
publicKeys: new Map([[0, { p }]]),
|
||||
referenceDate: new Date("2026-06-17T00:00:00Z"),
|
||||
creationDate: new Date("2026-06-01T00:00:00Z"),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (v.success) {
|
||||
// ...
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
|
||||
console.warn("Validation cancelled");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validateLicense(licenseKey, options): Promise<LicenseValidationResult>`
|
||||
|
||||
Options: `publicKeys` (required), `referenceDate?`, `creationDate?`,
|
||||
`signal?`. Returns the discriminated success/failure union. Throws only
|
||||
on `LicenseError(LICENSE.INVALID_INPUT)` (bad options) or
|
||||
`LicenseError(LICENSE.ABORTED)` (aborted signal); every other failure
|
||||
is surfaced on the result.
|
||||
|
||||
### `isValidLicense(licenseKey, options): Promise<boolean>`
|
||||
|
||||
Thin wrapper that returns `result.success`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The truncated-signature scheme is what makes the keys compact (~50-60
|
||||
chars vs ~120). For most consumer products the ~2^160 forgery
|
||||
resistance is generous; for higher-value licenses callers should opt
|
||||
into `useFullSignature: true` at generation time. Validation auto-
|
||||
detects which scheme the key uses by checking the signature byte
|
||||
length against `FULL_SIGNATURE_BYTES`.
|
||||
|
||||
The OTel emission runs on every code path — including aborts and
|
||||
parse failures — so dashboards always see a complete success/failure
|
||||
ratio. The histogram's unit is documented as `ms`; the recorded value
|
||||
is `Date.now() - start`, a wall-clock measurement (acceptable here
|
||||
because validation latency is dominated by the crypto, not by clock
|
||||
skew).
|
||||
@@ -0,0 +1,368 @@
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { base32Decode, base32Encode } from "../codec/base32/base32.js";
|
||||
import {
|
||||
extractSignatureFromKey,
|
||||
obfuscateLicenseKey,
|
||||
} from "../codec/obfuscation/obfuscation.js";
|
||||
import { packPayload } from "../codec/payload/payload.js";
|
||||
import { LicenseError } from "../error-codes.js";
|
||||
import { createPayload, generateLicense } from "../generate/generate.js";
|
||||
import type { LicensePayload } from "../types/license.js";
|
||||
import {
|
||||
LicenseValidationError,
|
||||
PackageTier,
|
||||
TimeEncodingType,
|
||||
} from "../types/license.js";
|
||||
import { isValidLicense, validateLicense } from "./validate.js";
|
||||
|
||||
function freshKeypair() {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const p = secp256k1.getPublicKey(s);
|
||||
return { signingKey: { s, p }, publicKey: { p } };
|
||||
}
|
||||
|
||||
const basePayload = createPayload({
|
||||
productId: 5,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 30,
|
||||
});
|
||||
|
||||
// helper: re-obfuscate a synthetic payload with a fresh signature
|
||||
function buildKey(payload: LicensePayload, signature: Uint8Array): string {
|
||||
const combined = new Uint8Array(9 + signature.length);
|
||||
combined.set(packPayload(payload));
|
||||
combined.set(signature, 9);
|
||||
const rawKey = base32Encode(combined);
|
||||
return obfuscateLicenseKey(rawKey, extractSignatureFromKey(rawKey));
|
||||
}
|
||||
|
||||
describe("validateLicense", () => {
|
||||
it("valid key returns success", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(true);
|
||||
if (v.success) {
|
||||
expect(v.details.trusted).toBe(true);
|
||||
expect(v.details.rawKey).toBe(r.key);
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies full 64-byte signature", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: true,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("verifies truncated 20-byte signature", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
expect(r.signature.length).toBe(20);
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(true);
|
||||
});
|
||||
|
||||
it("tampered payload yields INVALID_SIGNATURE", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
// Tamper: flip a bit in the payload region. We do this by
|
||||
// deobfuscating, base32-decoding, mutating byte 0, then
|
||||
// re-encoding without re-signing.
|
||||
const seed = extractSignatureFromKey(r.key);
|
||||
const deobf = base32Encode(base32Decode(r.key));
|
||||
void deobf; // unused — keep symmetric
|
||||
// Simpler: rebuild with mutated payload + original signature
|
||||
const mutated: LicensePayload = {
|
||||
...basePayload,
|
||||
productId: basePayload.productId + 1,
|
||||
};
|
||||
const tampered = buildKey(mutated, r.signature);
|
||||
void seed;
|
||||
const v = await validateLicense(tampered, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.INVALID_SIGNATURE);
|
||||
}
|
||||
});
|
||||
|
||||
it("tampered truncated signature yields INVALID_SIGNATURE", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const tamperedSig = new Uint8Array(r.signature);
|
||||
tamperedSig[0] = (tamperedSig[0] ?? 0) ^ 0xff;
|
||||
const tamperedKey = buildKey(basePayload, tamperedSig);
|
||||
const v = await validateLicense(tamperedKey, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.INVALID_SIGNATURE);
|
||||
}
|
||||
});
|
||||
|
||||
it("wrong public key yields INVALID_SIGNATURE", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const { publicKey: wrongPub } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: true,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, wrongPub]]),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.INVALID_SIGNATURE);
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown signing key yields UNKNOWN_SIGNING_KEY", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map(),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.UNKNOWN_SIGNING_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("unsupported version yields UNSUPPORTED_VERSION", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const futurePayload: LicensePayload = {
|
||||
...basePayload,
|
||||
version: 3,
|
||||
};
|
||||
const r = await generateLicense(futurePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[futurePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.UNSUPPORTED_VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("expired RELATIVE_DAYS yields EXPIRED", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const payload: LicensePayload = {
|
||||
...basePayload,
|
||||
timeEncodingType: TimeEncodingType.RELATIVE_DAYS,
|
||||
timeValue: 1,
|
||||
};
|
||||
const r = await generateLicense(payload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const creation = new Date("2026-01-01T00:00:00Z");
|
||||
const refAfter = new Date("2026-01-05T00:00:00Z");
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[payload.signingKeyId, publicKey]]),
|
||||
creationDate: creation,
|
||||
referenceDate: refAfter,
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.EXPIRED);
|
||||
expect(v.partialDetails?.rawKey).toBe(r.key);
|
||||
}
|
||||
});
|
||||
|
||||
it("expired ABSOLUTE_DAYS yields EXPIRED", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const payload: LicensePayload = {
|
||||
...basePayload,
|
||||
timeEncodingType: TimeEncodingType.ABSOLUTE_DAYS,
|
||||
timeValue: 100,
|
||||
};
|
||||
const r = await generateLicense(payload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[payload.signingKeyId, publicKey]]),
|
||||
referenceDate: new Date(1000 * 86_400_000),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.EXPIRED);
|
||||
}
|
||||
});
|
||||
|
||||
it("expired ABSOLUTE_TIMESTAMP yields EXPIRED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const payload: LicensePayload = {
|
||||
...basePayload,
|
||||
timeEncodingType: TimeEncodingType.ABSOLUTE_TIMESTAMP,
|
||||
timeValue: 1000,
|
||||
};
|
||||
const r = await generateLicense(payload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const v = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[payload.signingKeyId, publicKey]]),
|
||||
referenceDate: new Date(2000 * 1000),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.EXPIRED);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts Record-shape publicKeys", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const viaMap = await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
const viaRecord = await validateLicense(r.key, {
|
||||
publicKeys: { [basePayload.signingKeyId]: publicKey },
|
||||
});
|
||||
expect(viaMap.success).toBe(true);
|
||||
expect(viaRecord.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects with LICENSE_ABORTED when signal aborts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toBeInstanceOf(LicenseError);
|
||||
});
|
||||
|
||||
it("emits validate counter and histogram", { timeout: 30000 }, async () => {
|
||||
await flushTestMetrics();
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
await validateLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((s) =>
|
||||
s.metrics.map((mm) => mm.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("license.validate_total");
|
||||
expect(names).toContain("license.validate_duration_ms");
|
||||
});
|
||||
|
||||
it("malformed key returns MALFORMED", { timeout: 5000 }, async () => {
|
||||
const v = await validateLicense("AAAA", {
|
||||
publicKeys: new Map(),
|
||||
});
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid-encoding key returns INVALID_ENCODING", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const v = await validateLicense(
|
||||
"INVALID-CHAR-!!!!!-AAAAA-AAAAA-AAAAA-AAAAA",
|
||||
{
|
||||
publicKeys: new Map(),
|
||||
}
|
||||
);
|
||||
expect(v.success).toBe(false);
|
||||
if (!v.success) {
|
||||
expect(v.error).toBe(LicenseValidationError.INVALID_ENCODING);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed options with LICENSE_INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await validateLicense("AAAA", { publicKeys: undefined as never });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LicenseError);
|
||||
expect((caught as LicenseError).code).toBe("LICENSE.INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidLicense", () => {
|
||||
it("returns true for valid key", { timeout: 30000 }, async () => {
|
||||
const { signingKey, publicKey } = freshKeypair();
|
||||
const r = await generateLicense(basePayload, {
|
||||
signingKey,
|
||||
useFullSignature: false,
|
||||
});
|
||||
const ok = await isValidLicense(r.key, {
|
||||
publicKeys: new Map([[basePayload.signingKeyId, publicKey]]),
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for malformed key", { timeout: 5000 }, async () => {
|
||||
const ok = await isValidLicense("AAAA", { publicKeys: new Map() });
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
import {
|
||||
FULL_SIGNATURE_BYTES,
|
||||
LICENSE_VERSION,
|
||||
} from "../constants/constants.js";
|
||||
import { LICENSE_CODES, LicenseError } from "../error-codes.js";
|
||||
import { parseLicense } from "../generate/generate.js";
|
||||
import {
|
||||
ATTR_LICENSE_ERROR,
|
||||
ATTR_LICENSE_VALID,
|
||||
licenseValidateDurationMs,
|
||||
licenseValidateTotal,
|
||||
} from "../otel/instruments.js";
|
||||
import { LicenseValidationOptionsSchema } from "../schemas/schemas.js";
|
||||
import { checkExpiration } from "../time/time.js";
|
||||
import type {
|
||||
LicenseDetails,
|
||||
LicenseParsedResult,
|
||||
LicenseValidationFailure,
|
||||
LicenseValidationOptions,
|
||||
LicenseValidationResult,
|
||||
LicenseValidationSuccess,
|
||||
PublicKey,
|
||||
} from "../types/license.js";
|
||||
import { LicenseValidationError } from "../types/license.js";
|
||||
|
||||
function ensureNotAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.ABORTED,
|
||||
"License operation was aborted",
|
||||
{ context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(options: LicenseValidationOptions): void {
|
||||
const r = LicenseValidationOptionsSchema.safeParse(options);
|
||||
if (!r.success) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_INPUT,
|
||||
`Invalid validation options: ${r.error.issues.map((i) => i.message).join("; ")}`,
|
||||
{ cause: r.error, context: { label: "validation options" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePublicKeys(
|
||||
publicKeys: Map<number, PublicKey> | Record<number, PublicKey>
|
||||
): Map<number, PublicKey> {
|
||||
if (publicKeys instanceof Map) return publicKeys;
|
||||
const map = new Map<number, PublicKey>();
|
||||
for (const [id, key] of Object.entries(publicKeys)) {
|
||||
map.set(Number(id), key as PublicKey);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function verifyTruncated(
|
||||
publicKey: PublicKey,
|
||||
payloadBytes: Uint8Array,
|
||||
signatureBytes: Uint8Array
|
||||
): boolean {
|
||||
const merged = new Uint8Array(payloadBytes.length + publicKey.p.length);
|
||||
merged.set(payloadBytes);
|
||||
merged.set(publicKey.p, payloadBytes.length);
|
||||
const hash = sha256(merged);
|
||||
const n = Math.min(signatureBytes.length, hash.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (signatureBytes[i] !== hash[i]) return false;
|
||||
}
|
||||
return n === signatureBytes.length;
|
||||
}
|
||||
|
||||
function verifyFull(
|
||||
publicKey: PublicKey,
|
||||
payloadBytes: Uint8Array,
|
||||
signatureBytes: Uint8Array
|
||||
): boolean {
|
||||
const digest = sha256(payloadBytes);
|
||||
return secp256k1.verify(signatureBytes, digest, publicKey.p);
|
||||
}
|
||||
|
||||
function recordOutcome(
|
||||
valid: boolean,
|
||||
durationMs: number,
|
||||
error?: LicenseValidationError
|
||||
): void {
|
||||
const attrs: Record<string, string | number | boolean> = {
|
||||
[ATTR_LICENSE_VALID]: valid,
|
||||
};
|
||||
if (error !== undefined) {
|
||||
attrs[ATTR_LICENSE_ERROR] = error;
|
||||
}
|
||||
licenseValidateTotal.add(1, attrs);
|
||||
licenseValidateDurationMs.record(durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a license key against a public-key registry. Returns a
|
||||
* discriminated `LicenseValidationResult`: `{ success: true, details }`
|
||||
* on success or `{ success: false, error, message, partialDetails? }`
|
||||
* on any of the seven failure modes.
|
||||
*/
|
||||
export async function validateLicense(
|
||||
licenseKey: string,
|
||||
options: LicenseValidationOptions
|
||||
): Promise<LicenseValidationResult> {
|
||||
parseOptions(options);
|
||||
ensureNotAborted(options.signal);
|
||||
|
||||
const start = Date.now();
|
||||
const publicKeys = normalizePublicKeys(options.publicKeys);
|
||||
|
||||
let parsed: LicenseParsedResult;
|
||||
try {
|
||||
parsed = parseLicense(licenseKey);
|
||||
} catch (error) {
|
||||
const errCode =
|
||||
error instanceof LicenseError &&
|
||||
error.code === LICENSE_CODES.INVALID_ENCODING
|
||||
? LicenseValidationError.INVALID_ENCODING
|
||||
: LicenseValidationError.MALFORMED;
|
||||
recordOutcome(false, Date.now() - start, errCode);
|
||||
return {
|
||||
success: false,
|
||||
error: errCode,
|
||||
message: (error as Error).message,
|
||||
} satisfies LicenseValidationFailure;
|
||||
}
|
||||
|
||||
const { payload, payloadBytes, signatureBytes } = parsed;
|
||||
|
||||
if (payload.version > LICENSE_VERSION) {
|
||||
recordOutcome(
|
||||
false,
|
||||
Date.now() - start,
|
||||
LicenseValidationError.UNSUPPORTED_VERSION
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: LicenseValidationError.UNSUPPORTED_VERSION,
|
||||
message: `License version ${payload.version} is not supported (max: ${LICENSE_VERSION})`,
|
||||
partialDetails: payload,
|
||||
};
|
||||
}
|
||||
|
||||
const publicKey = publicKeys.get(payload.signingKeyId);
|
||||
if (!publicKey) {
|
||||
recordOutcome(
|
||||
false,
|
||||
Date.now() - start,
|
||||
LicenseValidationError.UNKNOWN_SIGNING_KEY
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: LicenseValidationError.UNKNOWN_SIGNING_KEY,
|
||||
message: `Unknown signing key ID: ${payload.signingKeyId}`,
|
||||
partialDetails: payload,
|
||||
};
|
||||
}
|
||||
|
||||
ensureNotAborted(options.signal);
|
||||
|
||||
const trusted =
|
||||
signatureBytes.length === FULL_SIGNATURE_BYTES
|
||||
? verifyFull(publicKey, payloadBytes, signatureBytes)
|
||||
: verifyTruncated(publicKey, payloadBytes, signatureBytes);
|
||||
|
||||
if (!trusted) {
|
||||
recordOutcome(
|
||||
false,
|
||||
Date.now() - start,
|
||||
LicenseValidationError.INVALID_SIGNATURE
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: LicenseValidationError.INVALID_SIGNATURE,
|
||||
message: "License signature verification failed",
|
||||
partialDetails: payload,
|
||||
};
|
||||
}
|
||||
|
||||
const { isExpired, expirationDate } = checkExpiration(
|
||||
payload.timeEncodingType,
|
||||
payload.timeValue,
|
||||
options.referenceDate,
|
||||
options.creationDate
|
||||
);
|
||||
|
||||
const details: LicenseDetails = {
|
||||
...payload,
|
||||
trusted,
|
||||
isExpired,
|
||||
rawKey: licenseKey,
|
||||
...(expirationDate !== undefined ? { expirationDate } : {}),
|
||||
};
|
||||
|
||||
if (isExpired) {
|
||||
recordOutcome(
|
||||
false,
|
||||
Date.now() - start,
|
||||
LicenseValidationError.EXPIRED
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: LicenseValidationError.EXPIRED,
|
||||
message: `License expired on ${(expirationDate as Date).toISOString()}`,
|
||||
partialDetails: details,
|
||||
};
|
||||
}
|
||||
|
||||
recordOutcome(true, Date.now() - start);
|
||||
return { success: true, details } satisfies LicenseValidationSuccess;
|
||||
}
|
||||
|
||||
/** Convenience boolean wrapper around {@link validateLicense}. */
|
||||
export async function isValidLicense(
|
||||
licenseKey: string,
|
||||
options: LicenseValidationOptions
|
||||
): Promise<boolean> {
|
||||
const result = await validateLicense(licenseKey, options);
|
||||
return result.success;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG = resolve(__dirname, "..", "package.json");
|
||||
const PACKAGE_JSON = JSON.parse(readFileSync(PKG, "utf8"));
|
||||
|
||||
const ALLOWED_RUNTIME_DEPS = new Set([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/crypto-curve",
|
||||
"@suaveplan/crypto-hash",
|
||||
"@suaveplan/crypto-utils",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
|
||||
const REQUIRED_SUBPATHS = [
|
||||
"./error-codes/error-codes",
|
||||
"./constants/constants",
|
||||
"./types/license",
|
||||
"./codec/base32/base32",
|
||||
"./codec/bitpack/bitpack",
|
||||
"./codec/payload/payload",
|
||||
"./codec/obfuscation/obfuscation",
|
||||
"./time/time",
|
||||
"./generate/generate",
|
||||
"./validate/validate",
|
||||
"./flags/flags",
|
||||
"./manager/manager",
|
||||
"./revocation/revocation-store/revocation-store",
|
||||
"./otel/instruments",
|
||||
"./schemas/schemas",
|
||||
];
|
||||
|
||||
describe("dep-graph and subpath exports", () => {
|
||||
it("dependencies match the universal allowlist", { timeout: 5000 }, () => {
|
||||
const deps = Object.keys(
|
||||
PACKAGE_JSON.dependencies as Record<string, string>
|
||||
);
|
||||
for (const dep of deps) {
|
||||
expect(ALLOWED_RUNTIME_DEPS).toContain(dep);
|
||||
}
|
||||
// Every allowlisted dep with a runtime use must be present
|
||||
for (const dep of ALLOWED_RUNTIME_DEPS) {
|
||||
expect(deps).toContain(dep);
|
||||
}
|
||||
});
|
||||
|
||||
it("subpath exports cover every feature module", { timeout: 5000 }, () => {
|
||||
const exports = PACKAGE_JSON.exports as Record<string, unknown>;
|
||||
for (const subpath of REQUIRED_SUBPATHS) {
|
||||
expect(exports).toHaveProperty(subpath);
|
||||
}
|
||||
expect(exports).toHaveProperty(".");
|
||||
expect(exports).toHaveProperty("./package.json");
|
||||
});
|
||||
|
||||
it("package is universal (no browser/Node-only deps)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = Object.keys(
|
||||
PACKAGE_JSON.dependencies as Record<string, string>
|
||||
);
|
||||
for (const dep of deps) {
|
||||
expect(dep.startsWith("node:")).toBe(false);
|
||||
expect(dep).not.toMatch(/^@?(react|vue|svelte|angular|expo)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
expect(PACKAGE_JSON.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("type is module", { timeout: 5000 }, () => {
|
||||
expect(PACKAGE_JSON.type).toBe("module");
|
||||
});
|
||||
|
||||
it("version is 0.1.0", { timeout: 5000 }, () => {
|
||||
expect(PACKAGE_JSON.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
155
packages/security-stack/security/license/tests/property.test.ts
Normal file
155
packages/security-stack/security/license/tests/property.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/license.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any
|
||||
* source module is imported, so the meter resolved by `instruments.ts`
|
||||
* at module load is the real provider's meter — not the noop fallback.
|
||||
* Tests that inspect emitted metrics can read them out of
|
||||
* `testInMemoryMetricExporter`.
|
||||
*
|
||||
* Node ≥18 already exposes `crypto.subtle` and `crypto.getRandomValues`
|
||||
* globally, so no crypto polyfill is required.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000, // tests flush manually via `flushTestMetrics`
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
/**
|
||||
* Force a flush of the test meter provider, returning the collected
|
||||
* `ResourceMetrics` snapshots captured by the in-memory exporter so
|
||||
* far. Resets the exporter's buffer.
|
||||
*/
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/security-stack/security/license/tsconfig.json
Normal file
12
packages/security-stack/security/license/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/security-stack/security/license/tsconfig.lib.json
Normal file
12
packages/security-stack/security/license/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
35
packages/security-stack/security/license/vite.config.ts
Normal file
35
packages/security-stack/security/license/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/license",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/security-stack/security/license/vitest.config.ts
Normal file
32
packages/security-stack/security/license/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user