@parselo/scanner-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Parselo Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ export interface AamvaHeader {
2
+ fileType: string;
3
+ iin: string;
4
+ aamvaVersion: string;
5
+ jurisdictionVersion?: string;
6
+ }
7
+ export interface AamvaResult {
8
+ isAamva: boolean;
9
+ header?: AamvaHeader;
10
+ subfileTypes: string[];
11
+ elements: Record<string, string>;
12
+ }
13
+ export declare function parseAamva(raw: string): AamvaResult;
package/dist/aamva.js ADDED
@@ -0,0 +1,123 @@
1
+ // aamva.ts
2
+ // Zero-dependency AAMVA PDF417 payload parser (US + Canada, CDS versions 1-12).
3
+ //
4
+ // The barcode is DECODED natively (ML Kit on Android, Vision on iOS); the raw
5
+ // payload string is passed across the bridge and parsed here, so parsing is
6
+ // identical on every platform and there is one place to fix bugs.
7
+ const DATE_CODES = new Set(["DBA", "DBB", "DBD", "DDB"]);
8
+ // Codes we recognise for the regex fallback path (used only if the subfile
9
+ // directory is unparseable on an oddly-encoded card).
10
+ const KNOWN_CODES = [
11
+ "DCA", "DCB", "DCD", "DBA", "DCS", "DAC", "DAD", "DBD", "DBB", "DBC", "DAY",
12
+ "DAU", "DAG", "DAH", "DAI", "DAJ", "DAK", "DAQ", "DCF", "DCG", "DCK", "DDA",
13
+ "DDB", "DAZ", "DAW", "DCL", "DDE", "DDF", "DDG", "DDH", "DDI", "DDJ", "DDK",
14
+ "DDL", "DAR", "DAS", "DAT", "DCH", "DBN", "DBG", "DBS", "DCU", "DCE", "DDC",
15
+ "DDD", "DAA", "DAB", "DAE",
16
+ ];
17
+ // AAMVA dates: CCYYMMDD in Canada, MMDDCCYY in the US. We pre-detect the
18
+ // country from DCG; a year-first prefix (19xx/20xx) is the tie-breaker.
19
+ function normaliseDate(value, country) {
20
+ const v = value.trim();
21
+ if (v.length !== 8 || !/^\d{8}$/.test(v))
22
+ return value;
23
+ let ccyy, mm, dd;
24
+ if (country === "CAN" || v.slice(0, 2) === "19" || v.slice(0, 2) === "20") {
25
+ ccyy = v.slice(0, 4);
26
+ mm = v.slice(4, 6);
27
+ dd = v.slice(6, 8); // CCYYMMDD
28
+ }
29
+ else {
30
+ mm = v.slice(0, 2);
31
+ dd = v.slice(2, 4);
32
+ ccyy = v.slice(4, 8); // MMDDCCYY
33
+ }
34
+ return `${ccyy}-${mm}-${dd}`;
35
+ }
36
+ export function parseAamva(raw) {
37
+ const result = { isAamva: false, subfileTypes: [], elements: {} };
38
+ if (!raw.startsWith("@"))
39
+ return result;
40
+ let markerPos = -1;
41
+ let marker = null;
42
+ for (const m of ["ANSI ", "AAMVA"]) {
43
+ const idx = raw.indexOf(m);
44
+ if (idx !== -1) {
45
+ markerPos = idx;
46
+ marker = m;
47
+ break;
48
+ }
49
+ }
50
+ if (marker === null)
51
+ return result;
52
+ result.isAamva = true;
53
+ let j = markerPos + marker.length;
54
+ const iin = raw.slice(j, j + 6);
55
+ j += 6;
56
+ const aamvaVersion = raw.slice(j, j + 2);
57
+ j += 2;
58
+ const ver = parseInt(aamvaVersion, 10);
59
+ let jurisdictionVersion;
60
+ if (!Number.isNaN(ver) && ver >= 2) {
61
+ jurisdictionVersion = raw.slice(j, j + 2);
62
+ j += 2;
63
+ }
64
+ const entriesStr = raw.slice(j, j + 2);
65
+ j += 2;
66
+ const n = parseInt(entriesStr, 10);
67
+ result.header = {
68
+ fileType: marker.trim(),
69
+ iin,
70
+ aamvaVersion,
71
+ ...(jurisdictionVersion ? { jurisdictionVersion } : {}),
72
+ };
73
+ // Country first (it drives the date format). DCG can sit anywhere in the body.
74
+ let country = "";
75
+ const dcgIdx = raw.indexOf("DCG");
76
+ if (dcgIdx !== -1) {
77
+ country = raw.slice(dcgIdx + 3).replace(/\r/g, "\n").split("\n")[0].trim().slice(0, 3);
78
+ }
79
+ const store = (code, value) => {
80
+ if (!/^[A-Z]{3}$/.test(code))
81
+ return;
82
+ result.elements[code] = DATE_CODES.has(code) ? normaliseDate(value, country) : value;
83
+ };
84
+ // Primary path: walk the subfile directory. Offsets are counted from byte 0
85
+ // (the "@"), and since we decode bytes 1:1, character offsets == byte offsets.
86
+ let k = j;
87
+ const directory = [];
88
+ const entryCount = Number.isNaN(n) ? 0 : n;
89
+ for (let i = 0; i < entryCount; i++) {
90
+ if (k + 10 > raw.length)
91
+ break;
92
+ const type = raw.slice(k, k + 2);
93
+ const offStr = raw.slice(k + 2, k + 6);
94
+ const lenStr = raw.slice(k + 6, k + 10);
95
+ k += 10;
96
+ if (/^\d{4}$/.test(offStr) && /^\d{4}$/.test(lenStr)) {
97
+ directory.push({ type, off: parseInt(offStr, 10), len: parseInt(lenStr, 10) });
98
+ }
99
+ }
100
+ let parsedAny = false;
101
+ for (const { type, off, len } of directory) {
102
+ result.subfileTypes.push(type);
103
+ const seg = raw.slice(off, off + len);
104
+ const body = seg.slice(0, 2) === type ? seg.slice(2) : seg; // strip repeated subfile designator
105
+ for (const elem of body.replace(/\r/g, "\n").split("\n")) {
106
+ if (elem.length < 3)
107
+ continue;
108
+ const code = elem.slice(0, 3);
109
+ if (/^[A-Z]{3}$/.test(code)) {
110
+ store(code, elem.slice(3));
111
+ parsedAny = true;
112
+ }
113
+ }
114
+ }
115
+ // Fallback: regex scan for known codes if directory parsing yielded nothing.
116
+ if (!parsedAny) {
117
+ const re = new RegExp(`(${KNOWN_CODES.join("|")})([^\\n\\r]*)`, "g");
118
+ let m;
119
+ while ((m = re.exec(raw)) !== null)
120
+ store(m[1], m[2]);
121
+ }
122
+ return result;
123
+ }
@@ -0,0 +1,32 @@
1
+ import type { FieldSource } from "./canonical";
2
+ export interface ScanEvent {
3
+ jti: string;
4
+ ts: string;
5
+ documentType: string;
6
+ jurisdiction: string;
7
+ source: FieldSource;
8
+ success: boolean;
9
+ confidenceBucket: "high" | "medium" | "low";
10
+ deviceModel: string;
11
+ }
12
+ export interface KeyValueStore {
13
+ get(key: string): Promise<string | null>;
14
+ set(key: string, value: string): Promise<void>;
15
+ }
16
+ export interface AnalyticsConfig {
17
+ ingestUrl: string;
18
+ store: KeyValueStore;
19
+ batchSize?: number;
20
+ }
21
+ export declare class ScanAnalytics {
22
+ private readonly ingestUrl;
23
+ private readonly store;
24
+ private readonly batchSize;
25
+ constructor(cfg: AnalyticsConfig);
26
+ /** Append an event; flush opportunistically once the buffer is full. */
27
+ record(event: ScanEvent): Promise<void>;
28
+ /** Send the buffered events; clear on success, keep them on failure for retry. */
29
+ flush(): Promise<void>;
30
+ private read;
31
+ private write;
32
+ }
@@ -0,0 +1,61 @@
1
+ // analytics.ts
2
+ // Records one PII-FREE event per scan and flushes them to the ingest endpoint in
3
+ // batches, buffering when offline. NOTHING here may contain document data
4
+ // (no name, DOB, number, address) - only counts and metadata. Keeping PII out of
5
+ // this pipeline is what keeps it out of PIPEDA scope; do not "enrich" these events.
6
+ const BUFFER_KEY = "scan_events_buffer_v1";
7
+ export class ScanAnalytics {
8
+ ingestUrl;
9
+ store;
10
+ batchSize;
11
+ constructor(cfg) {
12
+ this.ingestUrl = cfg.ingestUrl;
13
+ this.store = cfg.store;
14
+ this.batchSize = cfg.batchSize ?? 25;
15
+ }
16
+ /** Append an event; flush opportunistically once the buffer is full. */
17
+ async record(event) {
18
+ const buffer = await this.read();
19
+ buffer.push(event);
20
+ await this.write(buffer);
21
+ if (buffer.length >= this.batchSize) {
22
+ // fire-and-forget: analytics must never break or block a scan
23
+ void this.flush();
24
+ }
25
+ }
26
+ /** Send the buffered events; clear on success, keep them on failure for retry. */
27
+ async flush() {
28
+ const buffer = await this.read();
29
+ if (buffer.length === 0)
30
+ return;
31
+ try {
32
+ const res = await fetch(this.ingestUrl, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ events: buffer }),
36
+ });
37
+ if (res.ok) {
38
+ await this.write([]);
39
+ }
40
+ // non-2xx: leave the buffer intact and try again next time
41
+ }
42
+ catch {
43
+ // offline / network error: keep the buffer for the next flush
44
+ }
45
+ }
46
+ async read() {
47
+ const raw = await this.store.get(BUFFER_KEY);
48
+ if (!raw)
49
+ return [];
50
+ try {
51
+ const parsed = JSON.parse(raw);
52
+ return Array.isArray(parsed) ? parsed : [];
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ async write(events) {
59
+ await this.store.set(BUFFER_KEY, JSON.stringify(events));
60
+ }
61
+ }
@@ -0,0 +1,30 @@
1
+ import type { AamvaResult } from "./aamva";
2
+ export type FieldSource = "barcode" | "ocr" | "mrz";
3
+ export interface CanonicalField {
4
+ value: string;
5
+ confidence: number;
6
+ source: FieldSource;
7
+ }
8
+ export type DocumentType = "drivers_license" | "id_card" | "passport" | "unknown";
9
+ export interface CanonicalDocument {
10
+ documentType: DocumentType;
11
+ jurisdiction: string;
12
+ fields: {
13
+ firstName?: CanonicalField;
14
+ lastName?: CanonicalField;
15
+ middleName?: CanonicalField;
16
+ dateOfBirth?: CanonicalField;
17
+ expiryDate?: CanonicalField;
18
+ issueDate?: CanonicalField;
19
+ documentNumber?: CanonicalField;
20
+ sex?: CanonicalField;
21
+ addressStreet?: CanonicalField;
22
+ addressCity?: CanonicalField;
23
+ addressRegion?: CanonicalField;
24
+ addressPostalCode?: CanonicalField;
25
+ country?: CanonicalField;
26
+ vehicleClass?: CanonicalField;
27
+ };
28
+ raw: Record<string, string>;
29
+ }
30
+ export declare function toCanonical(a: AamvaResult, confidence?: number): CanonicalDocument;
@@ -0,0 +1,38 @@
1
+ // canonical.ts
2
+ // One stable output shape every document maps into, regardless of source
3
+ // (barcode now; OCR/MRZ later). Each field carries its value, a confidence,
4
+ // and which path produced it, so the future merge step can prefer ground truth.
5
+ export function toCanonical(a, confidence = 1.0) {
6
+ const e = a.elements;
7
+ const field = (code) => e[code] !== undefined ? { value: e[code], confidence, source: "barcode" } : undefined;
8
+ const region = e["DAJ"] ?? "";
9
+ const rawCountry = e["DCG"] ?? "";
10
+ const countryPrefix = rawCountry === "CAN" ? "CA" : rawCountry === "USA" ? "US" : rawCountry;
11
+ const jurisdiction = region
12
+ ? `${countryPrefix || "??"}-${region}`
13
+ : (countryPrefix || "unknown");
14
+ const documentType = a.subfileTypes.includes("ID")
15
+ ? "id_card"
16
+ : "drivers_license";
17
+ return {
18
+ documentType,
19
+ jurisdiction,
20
+ fields: {
21
+ firstName: field("DAC"),
22
+ lastName: field("DCS"),
23
+ middleName: field("DAD"),
24
+ dateOfBirth: field("DBB"),
25
+ expiryDate: field("DBA"),
26
+ issueDate: field("DBD"),
27
+ documentNumber: field("DAQ"),
28
+ sex: field("DBC"),
29
+ addressStreet: field("DAG"),
30
+ addressCity: field("DAI"),
31
+ addressRegion: field("DAJ"),
32
+ addressPostalCode: field("DAK"),
33
+ country: field("DCG"),
34
+ vehicleClass: field("DCA"),
35
+ },
36
+ raw: e,
37
+ };
38
+ }
@@ -0,0 +1,38 @@
1
+ import { type CanonicalDocument } from "./canonical";
2
+ import { type LicenseResult } from "./license";
3
+ import { type AnalyticsConfig } from "./analytics";
4
+ export * from "./aamva";
5
+ export * from "./canonical";
6
+ export * from "./license";
7
+ export * from "./analytics";
8
+ export interface BarcodeNative {
9
+ captureAndDecodePdf417(): Promise<string | null>;
10
+ }
11
+ export interface ScannerConfig {
12
+ license: {
13
+ token: string;
14
+ bundleId: string;
15
+ publicKeys: Record<string, string>;
16
+ };
17
+ analytics: AnalyticsConfig;
18
+ native: BarcodeNative;
19
+ deviceModel: string;
20
+ }
21
+ export type ScanError = "license_malformed" | "license_unknown_key" | "license_bad_signature" | "license_expired" | "license_bundle_mismatch" | "no_barcode" | "not_aamva";
22
+ export interface ScanResult {
23
+ ok: boolean;
24
+ document?: CanonicalDocument;
25
+ error?: ScanError;
26
+ }
27
+ export declare class Scanner {
28
+ private readonly cfg;
29
+ private readonly analytics;
30
+ private claims?;
31
+ constructor(cfg: ScannerConfig);
32
+ /** Verify the signature once, up front. Call on plugin init. */
33
+ init(): Promise<LicenseResult>;
34
+ scan(): Promise<ScanResult>;
35
+ /** Flush buffered analytics (e.g. on app foreground/background). */
36
+ flushAnalytics(): Promise<void>;
37
+ private emit;
38
+ }
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ // index.ts
2
+ // Ties the core together and shows the wrap points explicitly:
3
+ // verify license (gate) -> native decode -> parse -> canonical -> emit analytics
4
+ //
5
+ // The native PDF417 decode is supplied by the plugin's Swift/Kotlin bridge via
6
+ // the BarcodeNative interface, so this orchestration is platform-agnostic.
7
+ import { parseAamva } from "./aamva";
8
+ import { toCanonical } from "./canonical";
9
+ import { verifyLicense, isExpired } from "./license";
10
+ import { ScanAnalytics } from "./analytics";
11
+ export * from "./aamva";
12
+ export * from "./canonical";
13
+ export * from "./license";
14
+ export * from "./analytics";
15
+ export class Scanner {
16
+ cfg;
17
+ analytics;
18
+ claims;
19
+ constructor(cfg) {
20
+ this.cfg = cfg;
21
+ this.analytics = new ScanAnalytics(cfg.analytics);
22
+ }
23
+ /** Verify the signature once, up front. Call on plugin init. */
24
+ async init() {
25
+ const result = await verifyLicense({
26
+ token: this.cfg.license.token,
27
+ bundleId: this.cfg.license.bundleId,
28
+ publicKeys: this.cfg.license.publicKeys,
29
+ });
30
+ if (result.valid)
31
+ this.claims = result.claims;
32
+ return result;
33
+ }
34
+ async scan() {
35
+ // (1) LICENSE GATE -----------------------------------------------------
36
+ if (!this.claims) {
37
+ const result = await this.init();
38
+ if (!result.valid)
39
+ return { ok: false, error: `license_${result.reason}` };
40
+ }
41
+ // cheap per-scan expiry re-check (no crypto) so a long session can't outlive the token
42
+ if (!this.claims || isExpired(this.claims)) {
43
+ return { ok: false, error: "license_expired" };
44
+ }
45
+ // (2) NATIVE DECODE ----------------------------------------------------
46
+ const raw = await this.cfg.native.captureAndDecodePdf417();
47
+ if (!raw) {
48
+ await this.emit("unknown", "", false, "low");
49
+ return { ok: false, error: "no_barcode" };
50
+ }
51
+ // (3) PARSE ------------------------------------------------------------
52
+ const aamva = parseAamva(raw);
53
+ if (!aamva.isAamva) {
54
+ await this.emit("unknown", "", false, "low");
55
+ return { ok: false, error: "not_aamva" };
56
+ }
57
+ // (4) CANONICAL --------------------------------------------------------
58
+ const document = toCanonical(aamva);
59
+ // (5) ANALYTICS (PII-FREE) --------------------------------------------
60
+ await this.emit(document.documentType, document.jurisdiction, true, "high");
61
+ // (6) RETURN -----------------------------------------------------------
62
+ return { ok: true, document };
63
+ }
64
+ /** Flush buffered analytics (e.g. on app foreground/background). */
65
+ async flushAnalytics() {
66
+ await this.analytics.flush();
67
+ }
68
+ async emit(documentType, jurisdiction, success, confidenceBucket) {
69
+ await this.analytics.record({
70
+ jti: this.claims?.jti ?? "unknown",
71
+ ts: new Date().toISOString(),
72
+ documentType,
73
+ jurisdiction,
74
+ source: "barcode",
75
+ success,
76
+ confidenceBucket,
77
+ deviceModel: this.cfg.deviceModel,
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,23 @@
1
+ export interface LicenseClaims {
2
+ sub: string;
3
+ plan: string;
4
+ quota?: number;
5
+ iat: number;
6
+ exp: number;
7
+ bundleIds: string[];
8
+ jti: string;
9
+ kid: string;
10
+ }
11
+ export interface LicenseResult {
12
+ valid: boolean;
13
+ reason?: "malformed" | "unknown_key" | "bad_signature" | "expired" | "bundle_mismatch";
14
+ claims?: LicenseClaims;
15
+ }
16
+ export interface VerifyOptions {
17
+ token: string;
18
+ bundleId: string;
19
+ publicKeys: Record<string, string>;
20
+ now?: number;
21
+ }
22
+ export declare function verifyLicense(opts: VerifyOptions): Promise<LicenseResult>;
23
+ export declare function isExpired(claims: LicenseClaims, now?: number): boolean;
@@ -0,0 +1,67 @@
1
+ // license.ts
2
+ // Verifies a KMS-signed license token entirely on-device, with no network call.
3
+ // The token is a compact JWS (ES256 / NIST P-256): base64url(header).base64url(payload).base64url(signature)
4
+ // The plugin embeds only the PUBLIC key (SPKI), so trials can expire offline.
5
+ // --- base64 / base64url helpers (work in the webview and in Node) ---
6
+ // Return types below are intentionally inferred: `new Uint8Array(n)` yields an
7
+ // ArrayBuffer-backed array, which Web Crypto's BufferSource requires on TS 5.7+.
8
+ // An explicit `: Uint8Array` annotation widens it to the wrong variant and fails.
9
+ function b64ToBytes(s) {
10
+ const bin = atob(s);
11
+ const out = new Uint8Array(bin.length);
12
+ for (let i = 0; i < bin.length; i++)
13
+ out[i] = bin.charCodeAt(i);
14
+ return out;
15
+ }
16
+ function b64urlToBytes(s) {
17
+ let t = s.replace(/-/g, "+").replace(/_/g, "/");
18
+ while (t.length % 4)
19
+ t += "=";
20
+ return b64ToBytes(t);
21
+ }
22
+ function b64urlToString(s) {
23
+ return new TextDecoder().decode(b64urlToBytes(s));
24
+ }
25
+ export async function verifyLicense(opts) {
26
+ const parts = opts.token.split(".");
27
+ if (parts.length !== 3)
28
+ return { valid: false, reason: "malformed" };
29
+ let header;
30
+ let claims;
31
+ try {
32
+ header = JSON.parse(b64urlToString(parts[0]));
33
+ claims = JSON.parse(b64urlToString(parts[1]));
34
+ }
35
+ catch {
36
+ return { valid: false, reason: "malformed" };
37
+ }
38
+ const kid = header.kid ?? claims.kid;
39
+ const spkiB64 = kid ? opts.publicKeys[kid] : undefined;
40
+ if (!spkiB64)
41
+ return { valid: false, reason: "unknown_key" };
42
+ // Verify the ES256 signature over "header.payload".
43
+ let signatureOk = false;
44
+ try {
45
+ const key = await crypto.subtle.importKey("spki", b64ToBytes(spkiB64), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
46
+ const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
47
+ const sig = b64urlToBytes(parts[2]); // raw r||s, 64 bytes for P-256
48
+ signatureOk = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, sig, data);
49
+ }
50
+ catch {
51
+ signatureOk = false;
52
+ }
53
+ if (!signatureOk)
54
+ return { valid: false, reason: "bad_signature", claims };
55
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
56
+ if (typeof claims.exp !== "number" || now >= claims.exp) {
57
+ return { valid: false, reason: "expired", claims };
58
+ }
59
+ if (!Array.isArray(claims.bundleIds) || !claims.bundleIds.includes(opts.bundleId)) {
60
+ return { valid: false, reason: "bundle_mismatch", claims };
61
+ }
62
+ return { valid: true, claims };
63
+ }
64
+ // Cheap re-check for a long-running session (no crypto): is the token still in date?
65
+ export function isExpired(claims, now = Math.floor(Date.now() / 1000)) {
66
+ return typeof claims.exp !== "number" || now >= claims.exp;
67
+ }
package/dist/mint.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export declare function bytesToB64url(bytes: Uint8Array): string;
2
+ export declare function strToB64url(s: string): string;
3
+ export declare function derToRaw(der: Uint8Array): Uint8Array;
4
+ export declare function kidFromSpki(spkiBytes: Uint8Array): string;
5
+ export interface PublicKeyInfo {
6
+ kid: string;
7
+ spki: string;
8
+ }
9
+ export declare function getPublicKeyInfo(kmsKeyId: string): Promise<PublicKeyInfo>;
10
+ export interface MintOptions {
11
+ kmsKeyId: string;
12
+ customerId: string;
13
+ plan: string;
14
+ bundleIds: string[];
15
+ days: number;
16
+ quota?: number;
17
+ }
18
+ export interface MintResult {
19
+ token: string;
20
+ jti: string;
21
+ exp: number;
22
+ kid: string;
23
+ }
24
+ export declare function mintLicenseToken(opts: MintOptions): Promise<MintResult>;
package/dist/mint.js ADDED
@@ -0,0 +1,107 @@
1
+ // mint.ts
2
+ // Shared KMS-signing logic used by both the mint-token CLI and the control-plane
3
+ // issuance Lambda. Keeping both callers on the same code path guarantees
4
+ // server-minted tokens are byte-compatible with the plugin's offline verifyLicense.
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { KMSClient, SignCommand, GetPublicKeyCommand } from "@aws-sdk/client-kms";
7
+ // ── base64url ────────────────────────────────────────────────────────────────
8
+ export function bytesToB64url(bytes) {
9
+ let bin = "";
10
+ for (const b of bytes)
11
+ bin += String.fromCharCode(b);
12
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
+ }
14
+ export function strToB64url(s) {
15
+ return bytesToB64url(new TextEncoder().encode(s));
16
+ }
17
+ // Standard base64 (non-URL): used for the SPKI bytes returned to callers.
18
+ function uint8ToBase64(bytes) {
19
+ let bin = "";
20
+ for (const b of bytes)
21
+ bin += String.fromCharCode(b);
22
+ return btoa(bin);
23
+ }
24
+ // ── DER ECDSA signature → raw r‖s (P-256, 64 bytes) ────────────────────────
25
+ // KMS returns DER-encoded; Web Crypto's ECDSA verify expects raw r‖s.
26
+ export function derToRaw(der) {
27
+ let offset = 0;
28
+ if (der[offset++] !== 0x30)
29
+ throw new Error("bad DER: expected SEQUENCE");
30
+ let seqLen = der[offset++];
31
+ if (seqLen & 0x80) {
32
+ const n = seqLen & 0x7f;
33
+ seqLen = 0;
34
+ for (let i = 0; i < n; i++)
35
+ seqLen = (seqLen << 8) | der[offset++];
36
+ }
37
+ const readInt = () => {
38
+ if (der[offset++] !== 0x02)
39
+ throw new Error("bad DER: expected INTEGER");
40
+ const len = der[offset++];
41
+ const v = der.slice(offset, offset + len);
42
+ offset += len;
43
+ return v;
44
+ };
45
+ const norm = (b) => {
46
+ let x = b;
47
+ while (x.length > 32 && x[0] === 0x00)
48
+ x = x.slice(1);
49
+ const out = new Uint8Array(32);
50
+ out.set(x, 32 - x.length);
51
+ return out;
52
+ };
53
+ const r = norm(readInt());
54
+ const s = norm(readInt());
55
+ const out = new Uint8Array(64);
56
+ out.set(r, 0);
57
+ out.set(s, 32);
58
+ return out;
59
+ }
60
+ // ── kid: stable fingerprint of the SPKI bytes ───────────────────────────────
61
+ export function kidFromSpki(spkiBytes) {
62
+ const hash = createHash("sha256").update(spkiBytes).digest();
63
+ return bytesToB64url(new Uint8Array(hash)).slice(0, 16);
64
+ }
65
+ export async function getPublicKeyInfo(kmsKeyId) {
66
+ const kms = new KMSClient({});
67
+ const res = await kms.send(new GetPublicKeyCommand({ KeyId: kmsKeyId }));
68
+ if (!res.PublicKey)
69
+ throw new Error("KMS returned no PublicKey");
70
+ const spki = new Uint8Array(res.PublicKey);
71
+ return { kid: kidFromSpki(spki), spki: uint8ToBase64(spki) };
72
+ }
73
+ export async function mintLicenseToken(opts) {
74
+ const kms = new KMSClient({});
75
+ const res = await kms.send(new GetPublicKeyCommand({ KeyId: opts.kmsKeyId }));
76
+ if (!res.PublicKey)
77
+ throw new Error("KMS returned no PublicKey");
78
+ const spki = new Uint8Array(res.PublicKey);
79
+ const kid = kidFromSpki(spki);
80
+ const nowSec = Math.floor(Date.now() / 1000);
81
+ const jti = "lic_" + randomUUID();
82
+ const exp = nowSec + opts.days * 86400;
83
+ const header = { alg: "ES256", typ: "JWT", kid };
84
+ const payload = {
85
+ sub: opts.customerId,
86
+ plan: opts.plan,
87
+ iat: nowSec,
88
+ exp,
89
+ bundleIds: opts.bundleIds,
90
+ jti,
91
+ kid,
92
+ };
93
+ if (opts.quota !== undefined)
94
+ payload.quota = opts.quota;
95
+ const signingInput = `${strToB64url(JSON.stringify(header))}.${strToB64url(JSON.stringify(payload))}`;
96
+ const signed = await kms.send(new SignCommand({
97
+ KeyId: opts.kmsKeyId,
98
+ Message: new TextEncoder().encode(signingInput),
99
+ MessageType: "RAW",
100
+ SigningAlgorithm: "ECDSA_SHA_256",
101
+ }));
102
+ if (!signed.Signature)
103
+ throw new Error("KMS Sign returned no Signature");
104
+ const rawSig = derToRaw(new Uint8Array(signed.Signature));
105
+ const token = `${signingInput}.${bytesToB64url(rawSig)}`;
106
+ return { token, jti, exp, kid };
107
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@parselo/scanner-core",
3
+ "version": "0.0.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/parselo-io/parselo-sdk.git",
7
+ "directory": "packages/scanner-core"
8
+ },
9
+ "description": "Platform-agnostic core: AAMVA PDF417 parsing, offline license verification, and PII-free scan analytics.",
10
+ "license": "MIT",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "module": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./mint": {
24
+ "types": "./dist/mint.d.ts",
25
+ "import": "./dist/mint.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json",
34
+ "test": "node --experimental-strip-types src/test.ts",
35
+ "mint": "node tools/mint-token.mjs",
36
+ "verify": "node --experimental-strip-types tools/verify-token.ts"
37
+ },
38
+ "devDependencies": {
39
+ "@aws-sdk/client-kms": "^3.600.0",
40
+ "typescript": "^5.5.0"
41
+ }
42
+ }