json-seal 0.11.1 → 0.11.2
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 -5
- package/dist/base64.d.ts +2 -0
- package/dist/base64.js +23 -0
- package/dist/canonicalize.d.ts +5 -0
- package/dist/canonicalize.js +122 -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
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
## **Why json‑seal**
|
|
20
20
|
|
|
21
|
-
Apps often need to store or transmit JSON in a way that guarantees it hasn’t been tampered with
|
|
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:
|
|
22
22
|
|
|
23
|
-
**“I need to store JSON in a way that guarantees integrity
|
|
23
|
+
**“I need to store JSON in a way that guarantees integrity - while keeping it readable, portable, and framework‑agnostic.”**
|
|
24
24
|
|
|
25
25
|
json‑seal fills that gap. It lets you:
|
|
26
26
|
|
|
@@ -45,7 +45,7 @@ It’s built for **offline‑first apps**, **local backups**, and **portable int
|
|
|
45
45
|
- booleans
|
|
46
46
|
- null
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
TypeScript interfaces work automatically as long as their fields are JSON‑compatible.
|
|
49
49
|
|
|
50
50
|
### **Rejected values**
|
|
51
51
|
|
|
@@ -170,7 +170,7 @@ if (result.valid) {
|
|
|
170
170
|
|
|
171
171
|
## **Tamper Detection**
|
|
172
172
|
|
|
173
|
-
Any modification
|
|
173
|
+
Any modification - even deep inside nested objects - invalidates the signature.
|
|
174
174
|
|
|
175
175
|
```ts
|
|
176
176
|
const tampered = { ...backup, payload: { id: 1, data: "modified" } };
|
|
@@ -231,7 +231,7 @@ json‑seal focuses on a simpler, narrower goal:
|
|
|
231
231
|
- **Zero dependencies**
|
|
232
232
|
- **Portable across browsers, Node, Deno, Bun, and hybrid mobile apps**
|
|
233
233
|
|
|
234
|
-
It’s not a replacement for JWS
|
|
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.
|
|
235
235
|
|
|
236
236
|
---
|
|
237
237
|
|
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,122 @@
|
|
|
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 uses ECMAScript string escaping rules.
|
|
28
|
+
let out = '"';
|
|
29
|
+
for (let i = 0; i < str.length; i++) {
|
|
30
|
+
const c = str.charCodeAt(i);
|
|
31
|
+
switch (c) {
|
|
32
|
+
case 0x22:
|
|
33
|
+
out += '\\"';
|
|
34
|
+
break; // "
|
|
35
|
+
case 0x5C:
|
|
36
|
+
out += '\\\\';
|
|
37
|
+
break; // \
|
|
38
|
+
case 0x08:
|
|
39
|
+
out += '\\b';
|
|
40
|
+
break;
|
|
41
|
+
case 0x0C:
|
|
42
|
+
out += '\\f';
|
|
43
|
+
break;
|
|
44
|
+
case 0x0A:
|
|
45
|
+
out += '\\n';
|
|
46
|
+
break;
|
|
47
|
+
case 0x0D:
|
|
48
|
+
out += '\\r';
|
|
49
|
+
break;
|
|
50
|
+
case 0x09:
|
|
51
|
+
out += '\\t';
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
if (c < 0x20) {
|
|
55
|
+
// Control characters → \u00XX
|
|
56
|
+
out += "\\u" + hex4(c);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
out += str[i];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out + '"';
|
|
64
|
+
}
|
|
65
|
+
function hex4(n) {
|
|
66
|
+
return n.toString(16).padStart(4, "0");
|
|
67
|
+
}
|
|
68
|
+
/* -------------------------------------------------------------------------- */
|
|
69
|
+
/* Numbers */
|
|
70
|
+
/* -------------------------------------------------------------------------- */
|
|
71
|
+
function serializeNumber(n) {
|
|
72
|
+
if (!Number.isFinite(n)) {
|
|
73
|
+
throw new Error("Non‑finite numbers are not permitted in canonical JSON");
|
|
74
|
+
}
|
|
75
|
+
// RFC 8785: -0 must be serialized as 0
|
|
76
|
+
if (Object.is(n, -0))
|
|
77
|
+
return "0";
|
|
78
|
+
// Use JS number → string, then normalize exponent form
|
|
79
|
+
let s = n.toString();
|
|
80
|
+
// Normalize exponent to uppercase E
|
|
81
|
+
if (s.includes("e")) {
|
|
82
|
+
const [mantissa, exp] = s.split("e");
|
|
83
|
+
const sign = exp.startsWith("-") ? "-" : "+";
|
|
84
|
+
let digits = exp.replace(/^[+-]/, "");
|
|
85
|
+
// Remove leading zeros in exponent
|
|
86
|
+
digits = digits.replace(/^0+/, "");
|
|
87
|
+
if (digits === "")
|
|
88
|
+
digits = "0";
|
|
89
|
+
// RFC 8785: exponent must not include "+"
|
|
90
|
+
const normalized = `${mantissa}E${sign === "-" ? "-" : ""}${digits}`;
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
return s;
|
|
94
|
+
}
|
|
95
|
+
/* -------------------------------------------------------------------------- */
|
|
96
|
+
/* Arrays */
|
|
97
|
+
/* -------------------------------------------------------------------------- */
|
|
98
|
+
function serializeArray(arr) {
|
|
99
|
+
const items = arr.map(canonicalize);
|
|
100
|
+
return `[${items.join(",")}]`;
|
|
101
|
+
}
|
|
102
|
+
/* -------------------------------------------------------------------------- */
|
|
103
|
+
/* Objects */
|
|
104
|
+
/* -------------------------------------------------------------------------- */
|
|
105
|
+
function serializeObject(obj) {
|
|
106
|
+
const keys = Object.keys(obj);
|
|
107
|
+
// RFC 8785: duplicate keys MUST be rejected
|
|
108
|
+
detectDuplicateKeys(keys);
|
|
109
|
+
// Sort by UTF‑16 code units (JS default)
|
|
110
|
+
keys.sort();
|
|
111
|
+
const entries = keys.map(k => `${escapeString(k)}:${canonicalize(obj[k])}`);
|
|
112
|
+
return `{${entries.join(",")}}`;
|
|
113
|
+
}
|
|
114
|
+
function detectDuplicateKeys(keys) {
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const k of keys) {
|
|
117
|
+
if (seen.has(k)) {
|
|
118
|
+
throw new Error(`Duplicate key in object: ${k}`);
|
|
119
|
+
}
|
|
120
|
+
seen.add(k);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -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.11.
|
|
3
|
+
"version": "0.11.2",
|
|
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",
|