@usecontextlayer/secret-cipher 0.5.23
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/dist/index.d.mts +53 -0
- package/dist/index.mjs +81 -0
- package/package.json +32 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//#region lib/cipher.d.ts
|
|
2
|
+
declare const cipherKeyBrand: unique symbol;
|
|
3
|
+
/**
|
|
4
|
+
* A validated 32-byte (256-bit) master key that wraps each value's per-value
|
|
5
|
+
* content key. Obtain one only via `parseKey` — the brand makes an unvalidated
|
|
6
|
+
* byte array fail to type-check as a key.
|
|
7
|
+
*/
|
|
8
|
+
type CipherKey = Uint8Array & {
|
|
9
|
+
readonly [cipherKeyBrand]: true;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Parse a 64-hex-char (32-byte) master key. Throws on any other shape — the key
|
|
13
|
+
* is validated here so `encrypt`/`decrypt` can trust it.
|
|
14
|
+
*/
|
|
15
|
+
declare function parseKey(hex: string): CipherKey;
|
|
16
|
+
/**
|
|
17
|
+
* True iff `hex` is a valid master-key string (64 hex chars) — the exact shape
|
|
18
|
+
* `parseKey` accepts. Lets an env boundary validate a key without materializing
|
|
19
|
+
* one, so the 64-hex contract lives in a single place.
|
|
20
|
+
*/
|
|
21
|
+
declare function isKeyHex(hex: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* A stable, one-way fingerprint of the master key, stamped as the JWE `kid`. It
|
|
24
|
+
* identifies which key encrypted a value (for a future keychain) without
|
|
25
|
+
* revealing the key — SHA-256 of the key bytes, truncated.
|
|
26
|
+
*/
|
|
27
|
+
declare function keyId(key: CipherKey): string;
|
|
28
|
+
/**
|
|
29
|
+
* Envelope-encrypt a value: a fresh random content key encrypts the value
|
|
30
|
+
* (A256GCM); the master key wraps that content key (A256GCMKW). Returns a
|
|
31
|
+
* compact JWE (RFC 7516) — self-describing, single string, `kid` in the header.
|
|
32
|
+
* Keep this wrapper async so validation failures reject through the returned
|
|
33
|
+
* Promise instead of throwing before a caller can attach a rejection handler.
|
|
34
|
+
*/
|
|
35
|
+
declare function encrypt(plaintext: string, key: CipherKey): Promise<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Decrypt a compact JWE produced by `encrypt`. Throws on any failure (tampered
|
|
38
|
+
* ciphertext, wrong key, malformed input) — never returns plaintext on failure.
|
|
39
|
+
* The alg/enc allowlist pins decryption to exactly what `encrypt` emits, so a
|
|
40
|
+
* forged JWE using a different scheme (e.g. `alg: "dir"`) is rejected, not
|
|
41
|
+
* silently unwrapped — defense-in-depth on the crypto boundary.
|
|
42
|
+
*/
|
|
43
|
+
declare function decrypt(jwe: string, key: CipherKey): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* True iff `value` is a compact JWE in this library's exact envelope format:
|
|
46
|
+
* five segments with a protected header of `alg: A256GCMKW` + `enc: A256GCM`. A
|
|
47
|
+
* format marker, NOT an authenticator — it never touches the key or the GCM tag,
|
|
48
|
+
* so it proves shape, not authenticity. Backfill-only: lets a migration skip
|
|
49
|
+
* already-encrypted rows. The read path never calls this; it decrypts-or-throws.
|
|
50
|
+
*/
|
|
51
|
+
declare function isSecretCiphertext(value: string): boolean;
|
|
52
|
+
//#endregion
|
|
53
|
+
export { CipherKey, decrypt, encrypt, isKeyHex, isSecretCiphertext, keyId, parseKey };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CompactEncrypt, compactDecrypt, decodeProtectedHeader } from "jose";
|
|
3
|
+
|
|
4
|
+
//#region lib/cipher.ts
|
|
5
|
+
const ALG = "A256GCMKW";
|
|
6
|
+
const ENC = "A256GCM";
|
|
7
|
+
const KEY_HEX_PATTERN = /^[0-9a-f]{64}$/i;
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
10
|
+
/**
|
|
11
|
+
* Parse a 64-hex-char (32-byte) master key. Throws on any other shape — the key
|
|
12
|
+
* is validated here so `encrypt`/`decrypt` can trust it.
|
|
13
|
+
*/
|
|
14
|
+
function parseKey(hex) {
|
|
15
|
+
if (!isKeyHex(hex)) throw new Error("secret-cipher key must be 64 hex chars (32 bytes)");
|
|
16
|
+
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* True iff `hex` is a valid master-key string (64 hex chars) — the exact shape
|
|
20
|
+
* `parseKey` accepts. Lets an env boundary validate a key without materializing
|
|
21
|
+
* one, so the 64-hex contract lives in a single place.
|
|
22
|
+
*/
|
|
23
|
+
function isKeyHex(hex) {
|
|
24
|
+
return KEY_HEX_PATTERN.test(hex);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A stable, one-way fingerprint of the master key, stamped as the JWE `kid`. It
|
|
28
|
+
* identifies which key encrypted a value (for a future keychain) without
|
|
29
|
+
* revealing the key — SHA-256 of the key bytes, truncated.
|
|
30
|
+
*/
|
|
31
|
+
function keyId(key) {
|
|
32
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Envelope-encrypt a value: a fresh random content key encrypts the value
|
|
36
|
+
* (A256GCM); the master key wraps that content key (A256GCMKW). Returns a
|
|
37
|
+
* compact JWE (RFC 7516) — self-describing, single string, `kid` in the header.
|
|
38
|
+
* Keep this wrapper async so validation failures reject through the returned
|
|
39
|
+
* Promise instead of throwing before a caller can attach a rejection handler.
|
|
40
|
+
*/
|
|
41
|
+
async function encrypt(plaintext, key) {
|
|
42
|
+
if (!plaintext.isWellFormed()) throw new Error("secret-cipher: refusing to encrypt a non-well-formed string (lone surrogate) — decrypt could not round-trip it");
|
|
43
|
+
return new CompactEncrypt(encoder.encode(plaintext)).setProtectedHeader({
|
|
44
|
+
alg: ALG,
|
|
45
|
+
enc: ENC,
|
|
46
|
+
kid: keyId(key)
|
|
47
|
+
}).encrypt(key);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Decrypt a compact JWE produced by `encrypt`. Throws on any failure (tampered
|
|
51
|
+
* ciphertext, wrong key, malformed input) — never returns plaintext on failure.
|
|
52
|
+
* The alg/enc allowlist pins decryption to exactly what `encrypt` emits, so a
|
|
53
|
+
* forged JWE using a different scheme (e.g. `alg: "dir"`) is rejected, not
|
|
54
|
+
* silently unwrapped — defense-in-depth on the crypto boundary.
|
|
55
|
+
*/
|
|
56
|
+
async function decrypt(jwe, key) {
|
|
57
|
+
const { plaintext } = await compactDecrypt(jwe, key, {
|
|
58
|
+
contentEncryptionAlgorithms: [ENC],
|
|
59
|
+
keyManagementAlgorithms: [ALG]
|
|
60
|
+
});
|
|
61
|
+
return decoder.decode(plaintext);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* True iff `value` is a compact JWE in this library's exact envelope format:
|
|
65
|
+
* five segments with a protected header of `alg: A256GCMKW` + `enc: A256GCM`. A
|
|
66
|
+
* format marker, NOT an authenticator — it never touches the key or the GCM tag,
|
|
67
|
+
* so it proves shape, not authenticity. Backfill-only: lets a migration skip
|
|
68
|
+
* already-encrypted rows. The read path never calls this; it decrypts-or-throws.
|
|
69
|
+
*/
|
|
70
|
+
function isSecretCiphertext(value) {
|
|
71
|
+
try {
|
|
72
|
+
if (value.split(".").length !== 5) return false;
|
|
73
|
+
const header = decodeProtectedHeader(value);
|
|
74
|
+
return header.alg === ALG && header.enc === ENC;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
export { decrypt, encrypt, isKeyHex, isSecretCiphertext, keyId, parseKey };
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"jose": "^6.2.3"
|
|
4
|
+
},
|
|
5
|
+
"devDependencies": {
|
|
6
|
+
"fast-check": "^4.8.0",
|
|
7
|
+
"vitest": "^4.1.10"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"default": "./dist/index.mjs",
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
|
+
"types": "./dist/index.d.mts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"main": "./dist/index.mjs",
|
|
20
|
+
"name": "@usecontextlayer/secret-cipher",
|
|
21
|
+
"private": false,
|
|
22
|
+
"type": "module",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"version": "0.5.23",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "npx tsdown",
|
|
27
|
+
"clean": "rm -rf dist dist-types *.tsbuildinfo",
|
|
28
|
+
"dev:watch": "npx tsdown --watch",
|
|
29
|
+
"test": "npx vitest run --config vitest.config.ts",
|
|
30
|
+
"tsc": "npx tsc -b tsconfig.json"
|
|
31
|
+
}
|
|
32
|
+
}
|