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 +5 -6
- package/dist/base64.d.ts +2 -0
- package/dist/base64.js +23 -0
- package/dist/canonicalize.d.ts +5 -0
- package/dist/canonicalize.js +145 -0
- package/dist/crypto-sign.d.ts +1 -0
- package/dist/crypto-sign.js +8 -0
- package/dist/crypto-verify.d.ts +1 -0
- package/dist/crypto-verify.js +8 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/isJsonValue.d.ts +4 -0
- package/dist/isJsonValue.js +25 -0
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +29 -0
- package/dist/pem.d.ts +2 -0
- package/dist/pem.js +19 -0
- package/dist/sign.d.ts +10 -0
- package/dist/sign.js +23 -0
- package/dist/verify.d.ts +4 -0
- package/dist/verify.js +15 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
package/dist/base64.d.ts
ADDED
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,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 @@
|
|
|
1
|
+
export declare function verifyCanonical(canonical: string, signature: ArrayBuffer, publicKey: CryptoKey): Promise<boolean>;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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
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
|
+
}
|
package/dist/verify.d.ts
ADDED
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.
|
|
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",
|