json-seal 0.11.1 → 0.12.0

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/README.md CHANGED
@@ -6,7 +6,6 @@
6
6
  <img src="https://img.shields.io/badge/Deterministic%20JSON-RFC%208785%20Compliant-success" alt="Deterministic JSON" />
7
7
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="dependencies" />
8
8
  <img src="https://img.shields.io/badge/types-TypeScript-blue" alt="types" />
9
- <img src="https://img.shields.io/bundlephobia/minzip/json-seal" alt="bundle size" />
10
9
  <img src="https://img.shields.io/github/license/cmyers/json-seal" alt="license" />
11
10
  </p>
12
11
 
@@ -18,9 +17,9 @@
18
17
 
19
18
  ## **Why json‑seal**
20
19
 
21
- Apps often need to store or transmit JSON in a way that guarantees it hasn’t been tampered with without relying on servers, tokens, or opaque binary formats. Most security libraries focus on encrypted blobs, authentication tokens, or low‑level crypto primitives, but none solve the simple problem:
20
+ Apps often need to store or transmit JSON in a way that guarantees it hasn’t been tampered with - without relying on servers, tokens, or opaque binary formats. Most security libraries focus on encrypted blobs, authentication tokens, or low‑level crypto primitives, but none solve the simple problem:
22
21
 
23
- **“I need to store JSON in a way that guarantees integrity while keeping it readable, portable, and framework‑agnostic.”**
22
+ **“I need to store JSON in a way that guarantees integrity - while keeping it readable, portable, and framework‑agnostic.”**
24
23
 
25
24
  json‑seal fills that gap. It lets you:
26
25
 
@@ -45,7 +44,7 @@ It’s built for **offline‑first apps**, **local backups**, and **portable int
45
44
  - booleans
46
45
  - null
47
46
 
48
- Typed TypeScript interfaces work automatically as long as their fields are JSON‑compatible.
47
+ TypeScript interfaces work automatically as long as their fields are JSON‑compatible.
49
48
 
50
49
  ### **Rejected values**
51
50
 
@@ -170,7 +169,7 @@ if (result.valid) {
170
169
 
171
170
  ## **Tamper Detection**
172
171
 
173
- Any modification even deep inside nested objects invalidates the signature.
172
+ Any modification - even deep inside nested objects - invalidates the signature.
174
173
 
175
174
  ```ts
176
175
  const tampered = { ...backup, payload: { id: 1, data: "modified" } };
@@ -231,7 +230,7 @@ json‑seal focuses on a simpler, narrower goal:
231
230
  - **Zero dependencies**
232
231
  - **Portable across browsers, Node, Deno, Bun, and hybrid mobile apps**
233
232
 
234
- It’s not a replacement for JWS it’s a lightweight alternative for cases where you simply need to **seal JSON and verify it later**, without the complexity of JOSE.
233
+ It’s not a replacement for JWS - it’s a lightweight alternative for cases where you simply need to **seal JSON and verify it later**, without the complexity of JOSE.
235
234
 
236
235
  ---
237
236
 
@@ -0,0 +1,2 @@
1
+ export declare function arrayBufferToBase64(buf: ArrayBuffer): string;
2
+ export declare function base64ToArrayBuffer(base64: string): ArrayBuffer;
package/dist/base64.js ADDED
@@ -0,0 +1,23 @@
1
+ // Cross‑runtime base64 helpers (browser + Node 18+)
2
+ const _atob = typeof atob === "function"
3
+ ? atob
4
+ : (b64) => Buffer.from(b64, "base64").toString("binary");
5
+ const _btoa = typeof btoa === "function"
6
+ ? btoa
7
+ : (bin) => Buffer.from(bin, "binary").toString("base64");
8
+ export function arrayBufferToBase64(buf) {
9
+ const bytes = new Uint8Array(buf);
10
+ let binary = "";
11
+ for (let i = 0; i < bytes.length; i++) {
12
+ binary += String.fromCharCode(bytes[i]);
13
+ }
14
+ return _btoa(binary);
15
+ }
16
+ export function base64ToArrayBuffer(base64) {
17
+ const binary = _atob(base64);
18
+ const bytes = new Uint8Array(binary.length);
19
+ for (let i = 0; i < binary.length; i++) {
20
+ bytes[i] = binary.charCodeAt(i);
21
+ }
22
+ return bytes.buffer;
23
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * RFC 8785 Canonical JSON implementation
3
+ * Deterministic, strict, and cross‑runtime stable.
4
+ */
5
+ export declare function canonicalize(value: any): string;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * RFC 8785 Canonical JSON implementation
3
+ * Deterministic, strict, and cross‑runtime stable.
4
+ */
5
+ export function canonicalize(value) {
6
+ switch (typeof value) {
7
+ case "string":
8
+ return escapeString(value);
9
+ case "number":
10
+ return serializeNumber(value);
11
+ case "boolean":
12
+ return value ? "true" : "false";
13
+ case "object":
14
+ if (value === null)
15
+ return "null";
16
+ if (Array.isArray(value))
17
+ return serializeArray(value);
18
+ return serializeObject(value);
19
+ default:
20
+ throw new Error(`Unsupported type in canonical JSON: ${typeof value}`);
21
+ }
22
+ }
23
+ /* -------------------------------------------------------------------------- */
24
+ /* Strings */
25
+ /* -------------------------------------------------------------------------- */
26
+ function escapeString(str) {
27
+ // RFC 8785 defines canonicalization in terms of ECMAScript strings (UTF‑16),
28
+ // but the final canonical form must be encoded as UTF‑8 when serialized.
29
+ // Therefore we validate UTF‑16 correctness (surrogate pairs) here.
30
+ let out = '"';
31
+ for (let i = 0; i < str.length; i++) {
32
+ const c = str.charCodeAt(i);
33
+ // Surrogate handling (UTF‑16 correctness)
34
+ if (c >= 0xd800 && c <= 0xdbff) {
35
+ // High surrogate: must be followed by a low surrogate
36
+ if (i + 1 >= str.length) {
37
+ throw new Error("Invalid UTF‑16: isolated high surrogate");
38
+ }
39
+ const d = str.charCodeAt(i + 1);
40
+ if (d < 0xdc00 || d > 0xdfff) {
41
+ throw new Error("Invalid UTF‑16: high surrogate not followed by low surrogate");
42
+ }
43
+ // Valid surrogate pair → append both as-is
44
+ out += str[i] + str[i + 1];
45
+ i++; // skip the low surrogate
46
+ continue;
47
+ }
48
+ else if (c >= 0xdc00 && c <= 0xdfff) {
49
+ // Low surrogate without preceding high surrogate
50
+ throw new Error("Invalid UTF‑16: isolated low surrogate");
51
+ }
52
+ switch (c) {
53
+ case 0x22:
54
+ out += '\\"';
55
+ break; // "
56
+ case 0x5C:
57
+ out += '\\\\';
58
+ break; // \
59
+ case 0x08:
60
+ out += '\\b';
61
+ break;
62
+ case 0x0C:
63
+ out += '\\f';
64
+ break;
65
+ case 0x0A:
66
+ out += '\\n';
67
+ break;
68
+ case 0x0D:
69
+ out += '\\r';
70
+ break;
71
+ case 0x09:
72
+ out += '\\t';
73
+ break;
74
+ default:
75
+ if (c < 0x20) {
76
+ // Control characters → \u00XX
77
+ out += "\\u" + hex4(c);
78
+ }
79
+ else {
80
+ out += str[i];
81
+ }
82
+ }
83
+ }
84
+ return out + '"';
85
+ }
86
+ function hex4(n) {
87
+ return n.toString(16).padStart(4, "0");
88
+ }
89
+ /* -------------------------------------------------------------------------- */
90
+ /* Numbers */
91
+ /* -------------------------------------------------------------------------- */
92
+ function serializeNumber(n) {
93
+ if (!Number.isFinite(n)) {
94
+ throw new Error("Non‑finite numbers are not permitted in canonical JSON");
95
+ }
96
+ // RFC 8785: -0 must be serialized as 0
97
+ if (Object.is(n, -0))
98
+ return "0";
99
+ // Use JS number → string, then normalize exponent form if present.
100
+ // JS already produces minimal mantissa/decimal representation.
101
+ let s = n.toString();
102
+ // Normalize exponent to RFC 8785 rules
103
+ const eIndex = s.indexOf("e");
104
+ if (eIndex !== -1) {
105
+ const mantissa = s.slice(0, eIndex);
106
+ let exp = s.slice(eIndex + 1); // after 'e'
107
+ const negative = exp.startsWith("-");
108
+ exp = exp.replace(/^[+-]/, "");
109
+ // Remove leading zeros in exponent
110
+ exp = exp.replace(/^0+/, "");
111
+ if (exp === "")
112
+ exp = "0";
113
+ // RFC 8785: exponent must not include "+"
114
+ s = `${mantissa}E${negative ? "-" : ""}${exp}`;
115
+ }
116
+ return s;
117
+ }
118
+ /* -------------------------------------------------------------------------- */
119
+ /* Arrays */
120
+ /* -------------------------------------------------------------------------- */
121
+ function serializeArray(arr) {
122
+ const items = arr.map(canonicalize);
123
+ return `[${items.join(",")}]`;
124
+ }
125
+ /* -------------------------------------------------------------------------- */
126
+ /* Objects */
127
+ /* -------------------------------------------------------------------------- */
128
+ function serializeObject(obj) {
129
+ const keys = Object.keys(obj);
130
+ // RFC 8785: duplicate keys MUST be rejected
131
+ detectDuplicateKeys(keys);
132
+ // Sort by UTF‑16 code units (JS default), as required by RFC 8785
133
+ keys.sort();
134
+ const entries = keys.map((k) => `${escapeString(k)}:${canonicalize(obj[k])}`);
135
+ return `{${entries.join(",")}}`;
136
+ }
137
+ function detectDuplicateKeys(keys) {
138
+ const seen = new Set();
139
+ for (const k of keys) {
140
+ if (seen.has(k)) {
141
+ throw new Error(`Duplicate key in object: ${k}`);
142
+ }
143
+ seen.add(k);
144
+ }
145
+ }
@@ -0,0 +1 @@
1
+ export declare function signCanonical(canonical: string, privateKey: CryptoKey): Promise<ArrayBuffer>;
@@ -0,0 +1,8 @@
1
+ const encoder = new TextEncoder();
2
+ export async function signCanonical(canonical, privateKey) {
3
+ const bytes = encoder.encode(canonical);
4
+ return crypto.subtle.sign({
5
+ name: "RSA-PSS",
6
+ saltLength: 32
7
+ }, privateKey, bytes);
8
+ }
@@ -0,0 +1 @@
1
+ export declare function verifyCanonical(canonical: string, signature: ArrayBuffer, publicKey: CryptoKey): Promise<boolean>;
@@ -0,0 +1,8 @@
1
+ const encoder = new TextEncoder();
2
+ export async function verifyCanonical(canonical, signature, publicKey) {
3
+ const bytes = encoder.encode(canonical);
4
+ return crypto.subtle.verify({
5
+ name: "RSA-PSS",
6
+ saltLength: 32
7
+ }, publicKey, signature, bytes);
8
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./canonicalize.js";
2
+ export * from "./sign.js";
3
+ export * from "./verify.js";
4
+ export * from "./keys.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./canonicalize.js";
2
+ export * from "./sign.js";
3
+ export * from "./verify.js";
4
+ export * from "./keys.js";
@@ -0,0 +1,4 @@
1
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
2
+ [key: string]: JsonValue;
3
+ };
4
+ export declare function isJsonValue(value: unknown): value is JsonValue;
@@ -0,0 +1,25 @@
1
+ export function isJsonValue(value) {
2
+ if (value === null ||
3
+ typeof value === "string" ||
4
+ typeof value === "number" ||
5
+ typeof value === "boolean") {
6
+ return true;
7
+ }
8
+ if (Array.isArray(value)) {
9
+ return value.every(isJsonValue);
10
+ }
11
+ // Objects (but not class instances, Dates, Maps, Sets, etc.)
12
+ if (typeof value === "object") {
13
+ const proto = Object.getPrototypeOf(value);
14
+ if (proto !== Object.prototype && proto !== null) {
15
+ return false;
16
+ }
17
+ for (const key of Object.keys(value)) {
18
+ if (!isJsonValue(value[key])) {
19
+ return false;
20
+ }
21
+ }
22
+ return true;
23
+ }
24
+ return false;
25
+ }
package/dist/keys.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare function generateKeyPair(): Promise<{
2
+ privateKey: string;
3
+ publicKey: string;
4
+ }>;
5
+ export declare function importPrivateKey(privateKeyPem: string): Promise<CryptoKey>;
6
+ export declare function importPublicKey(publicKeyPem: string): Promise<CryptoKey>;
package/dist/keys.js ADDED
@@ -0,0 +1,29 @@
1
+ import { pemToArrayBuffer, arrayBufferToPem } from "./pem.js";
2
+ export async function generateKeyPair() {
3
+ const { publicKey, privateKey } = await crypto.subtle.generateKey({
4
+ name: "RSA-PSS",
5
+ modulusLength: 2048,
6
+ publicExponent: new Uint8Array([1, 0, 1]),
7
+ hash: "SHA-256"
8
+ }, true, ["sign", "verify"]);
9
+ const pkcs8 = await crypto.subtle.exportKey("pkcs8", privateKey);
10
+ const spki = await crypto.subtle.exportKey("spki", publicKey);
11
+ return {
12
+ privateKey: arrayBufferToPem(pkcs8, "private"),
13
+ publicKey: arrayBufferToPem(spki, "public")
14
+ };
15
+ }
16
+ export async function importPrivateKey(privateKeyPem) {
17
+ const pkcs8 = pemToArrayBuffer(privateKeyPem);
18
+ return crypto.subtle.importKey("pkcs8", pkcs8, {
19
+ name: "RSA-PSS",
20
+ hash: "SHA-256"
21
+ }, false, ["sign"]);
22
+ }
23
+ export async function importPublicKey(publicKeyPem) {
24
+ const spki = pemToArrayBuffer(publicKeyPem);
25
+ return crypto.subtle.importKey("spki", spki, {
26
+ name: "RSA-PSS",
27
+ hash: "SHA-256"
28
+ }, false, ["verify"]);
29
+ }
package/dist/pem.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function pemToArrayBuffer(pem: string): ArrayBuffer;
2
+ export declare function arrayBufferToPem(buf: ArrayBuffer, type: "private" | "public"): string;
package/dist/pem.js ADDED
@@ -0,0 +1,19 @@
1
+ import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
2
+ // strip header/footer + whitespace
3
+ function pemBody(pem) {
4
+ return pem
5
+ .replace(/-----BEGIN [^-]+-----/, "")
6
+ .replace(/-----END [^-]+-----/, "")
7
+ .replace(/\s+/g, "");
8
+ }
9
+ export function pemToArrayBuffer(pem) {
10
+ const base64 = pemBody(pem);
11
+ return base64ToArrayBuffer(base64);
12
+ }
13
+ export function arrayBufferToPem(buf, type) {
14
+ const base64 = arrayBufferToBase64(buf);
15
+ if (type === "private") {
16
+ return `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----`;
17
+ }
18
+ return `-----BEGIN PUBLIC KEY-----\n${base64}\n-----END PUBLIC KEY-----`;
19
+ }
package/dist/sign.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export declare function signPayload(payload: any, privateKeyPem: string, publicKeyPem: string): Promise<{
2
+ version: number;
3
+ timestamp: string;
4
+ payload: import("./isJsonValue.js").JsonValue;
5
+ signature: {
6
+ algorithm: string;
7
+ publicKey: string;
8
+ value: string;
9
+ };
10
+ }>;
package/dist/sign.js ADDED
@@ -0,0 +1,23 @@
1
+ import { canonicalize } from "./canonicalize.js";
2
+ import { importPrivateKey } from "./keys.js";
3
+ import { signCanonical } from "./crypto-sign.js";
4
+ import { arrayBufferToBase64 } from "./base64.js";
5
+ import { isJsonValue } from "./isJsonValue.js";
6
+ export async function signPayload(payload, privateKeyPem, publicKeyPem) {
7
+ if (!isJsonValue(payload))
8
+ throw new Error("signPayload only accepts JSON-compatible values");
9
+ const canonical = canonicalize(payload);
10
+ const privateKey = await importPrivateKey(privateKeyPem);
11
+ const signatureBytes = await signCanonical(canonical, privateKey);
12
+ const signature = arrayBufferToBase64(signatureBytes);
13
+ return {
14
+ version: 1,
15
+ timestamp: new Date().toISOString(),
16
+ payload,
17
+ signature: {
18
+ algorithm: "RSA-PSS-SHA256",
19
+ publicKey: publicKeyPem,
20
+ value: signature
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,4 @@
1
+ export declare function verifyBackup(backup: any): Promise<{
2
+ valid: boolean;
3
+ payload: any;
4
+ }>;
package/dist/verify.js ADDED
@@ -0,0 +1,15 @@
1
+ import { canonicalize } from "./canonicalize.js";
2
+ import { importPublicKey } from "./keys.js";
3
+ import { verifyCanonical } from "./crypto-verify.js";
4
+ import { base64ToArrayBuffer } from "./base64.js";
5
+ export async function verifyBackup(backup) {
6
+ const { payload, signature } = backup;
7
+ const canonical = canonicalize(payload);
8
+ const publicKey = await importPublicKey(signature.publicKey);
9
+ const signatureBytes = base64ToArrayBuffer(signature.value);
10
+ const valid = await verifyCanonical(canonical, signatureBytes, publicKey);
11
+ return {
12
+ valid,
13
+ payload: valid ? payload : undefined
14
+ };
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-seal",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "author": "Chris Myers <cmyers2015@outlook.com> (https://www.npmjs.com/~cmyers-dev)",
5
5
  "description": "Create cryptographically signed, tamper‑proof JSON backups with zero dependencies.",
6
6
  "type": "module",