json-seal 0.11.0 → 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 +39 -15
- package/dist/base64.d.ts +2 -0
- package/dist/base64.js +23 -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 +1 -0
- package/dist/index.js +1 -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 +3 -7
- package/dist/sign.js +10 -20
- package/dist/verify.d.ts +2 -2
- package/dist/verify.js +7 -11
- 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
|
|
|
@@ -28,9 +28,9 @@ json‑seal fills that gap. It lets you:
|
|
|
28
28
|
- Sign it with a private key
|
|
29
29
|
- Embed the public key
|
|
30
30
|
- Verify integrity later
|
|
31
|
-
- Detect any tampering
|
|
31
|
+
- Detect any tampering
|
|
32
32
|
|
|
33
|
-
It’s
|
|
33
|
+
It’s built for **offline‑first apps**, **local backups**, and **portable integrity checks**, where JSON must remain human‑readable and self‑verifying.
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -45,7 +45,7 @@ It’s like JWS, but for **arbitrary JSON documents**, without JWT complexity, a
|
|
|
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" } };
|
|
@@ -197,16 +197,41 @@ Full RFC 8785 Canonical JSON implementation.
|
|
|
197
197
|
|
|
198
198
|
---
|
|
199
199
|
|
|
200
|
-
##
|
|
200
|
+
## Prior Art
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
### JSON Web Signature (JWS)
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
- **rfc8785 (Python)** — pure Python canonicalizer
|
|
206
|
-
- **jcs (Elixir)** — Elixir implementation of JCS
|
|
207
|
-
- **JOSE / JWS / JWT** — signing standards focused on tokens, not arbitrary JSON
|
|
204
|
+
JWS is the established IETF standard for signing JSON‑related data, but it solves a very different problem. JWS is designed for **token exchange between untrusted parties** (OAuth, OpenID Connect, identity providers), not for **deterministic, portable, tamper‑evident JSON objects**.
|
|
208
205
|
|
|
209
|
-
|
|
206
|
+
Key differences:
|
|
207
|
+
|
|
208
|
+
- **JWS signs bytes, not JSON**
|
|
209
|
+
The payload must be base64url‑encoded. Two equivalent JSON objects can produce different signatures.
|
|
210
|
+
|
|
211
|
+
- **No canonicalization**
|
|
212
|
+
JWS does not define how JSON should be normalized. json‑seal uses deterministic canonicalization so the same logical object always produces the same signature.
|
|
213
|
+
|
|
214
|
+
- **Heavy structural overhead**
|
|
215
|
+
Protected headers, unprotected headers, algorithm identifiers, key IDs, and two serialization formats (compact and JSON).
|
|
216
|
+
|
|
217
|
+
- **Not offline‑first**
|
|
218
|
+
JWS is built for network protocols. json‑seal is built for sealed backups, hash chains, and local integrity.
|
|
219
|
+
|
|
220
|
+
- **Not WebView‑friendly**
|
|
221
|
+
Most JOSE libraries depend on Node’s crypto module. json‑seal uses WebCrypto and works in browsers, Ionic, Capacitor, and mobile WebViews.
|
|
222
|
+
|
|
223
|
+
### In contrast
|
|
224
|
+
|
|
225
|
+
json‑seal focuses on a simpler, narrower goal:
|
|
226
|
+
|
|
227
|
+
- **Pure JSON in, pure JSON out**
|
|
228
|
+
- **Deterministic canonicalization**
|
|
229
|
+
- **WebCrypto‑based RSA‑PSS signatures**
|
|
230
|
+
- **Self‑contained sealed objects with embedded public keys**
|
|
231
|
+
- **Zero dependencies**
|
|
232
|
+
- **Portable across browsers, Node, Deno, Bun, and hybrid mobile apps**
|
|
233
|
+
|
|
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.
|
|
210
235
|
|
|
211
236
|
---
|
|
212
237
|
|
|
@@ -228,7 +253,6 @@ Run tests:
|
|
|
228
253
|
```bash
|
|
229
254
|
npm test
|
|
230
255
|
```
|
|
231
|
-
|
|
232
256
|
---
|
|
233
257
|
|
|
234
258
|
Pull Requests are welcome.
|
|
@@ -237,4 +261,4 @@ Pull Requests are welcome.
|
|
|
237
261
|
|
|
238
262
|
MIT
|
|
239
263
|
|
|
240
|
-
---
|
|
264
|
+
---
|
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 @@
|
|
|
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
CHANGED
package/dist/index.js
CHANGED
|
@@ -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
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
export declare function
|
|
2
|
-
privateKey: string;
|
|
3
|
-
publicKey: string;
|
|
4
|
-
};
|
|
5
|
-
export declare function signPayload(payload: any, privateKeyPem: string, publicKeyPem: string): {
|
|
1
|
+
export declare function signPayload(payload: any, privateKeyPem: string, publicKeyPem: string): Promise<{
|
|
6
2
|
version: number;
|
|
7
3
|
timestamp: string;
|
|
8
|
-
payload:
|
|
4
|
+
payload: import("./isJsonValue.js").JsonValue;
|
|
9
5
|
signature: {
|
|
10
6
|
algorithm: string;
|
|
11
7
|
publicKey: string;
|
|
12
8
|
value: string;
|
|
13
9
|
};
|
|
14
|
-
}
|
|
10
|
+
}>;
|
package/dist/sign.js
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
1
|
-
import { generateKeyPairSync, createSign, constants } from "crypto";
|
|
2
1
|
import { canonicalize } from "./canonicalize.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
export function signPayload(payload, privateKeyPem, publicKeyPem) {
|
|
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");
|
|
13
9
|
const canonical = canonicalize(payload);
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
signer.end();
|
|
18
|
-
const signature = signer.sign({
|
|
19
|
-
key: privateKeyPem,
|
|
20
|
-
padding: constants.RSA_PKCS1_PSS_PADDING,
|
|
21
|
-
saltLength: 32
|
|
22
|
-
}).toString("base64");
|
|
10
|
+
const privateKey = await importPrivateKey(privateKeyPem);
|
|
11
|
+
const signatureBytes = await signCanonical(canonical, privateKey);
|
|
12
|
+
const signature = arrayBufferToBase64(signatureBytes);
|
|
23
13
|
return {
|
|
24
14
|
version: 1,
|
|
25
15
|
timestamp: new Date().toISOString(),
|
package/dist/verify.d.ts
CHANGED
package/dist/verify.js
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
import { createVerify, constants } from "crypto";
|
|
2
1
|
import { canonicalize } from "./canonicalize.js";
|
|
3
|
-
|
|
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) {
|
|
4
6
|
const { payload, signature } = backup;
|
|
5
7
|
const canonical = canonicalize(payload);
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
verifier.end();
|
|
10
|
-
const valid = verifier.verify({
|
|
11
|
-
key: signature.publicKey,
|
|
12
|
-
padding: constants.RSA_PKCS1_PSS_PADDING,
|
|
13
|
-
saltLength: 32
|
|
14
|
-
}, Buffer.from(signature.value, "base64"));
|
|
8
|
+
const publicKey = await importPublicKey(signature.publicKey);
|
|
9
|
+
const signatureBytes = base64ToArrayBuffer(signature.value);
|
|
10
|
+
const valid = await verifyCanonical(canonical, signatureBytes, publicKey);
|
|
15
11
|
return {
|
|
16
12
|
valid,
|
|
17
13
|
payload: valid ? payload : undefined
|
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",
|