@asafarim/appsafe 0.1.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +145 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alisafari
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @asafarim/appsafe
|
|
2
|
+
|
|
3
|
+
A small browser-first encryption package built on the Web Crypto API. It encrypts arbitrary bytes or UTF-8 text with AES-256-GCM and derives the key from a user-supplied password with PBKDF2-HMAC-SHA-256. Passwords and plaintext stay in the calling runtime; this package performs no network requests.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @asafarim/appsafe
|
|
9
|
+
# or
|
|
10
|
+
npm install @asafarim/appsafe
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires a runtime that provides `globalThis.crypto.subtle` (all modern browsers, Node 20+, and Deno).
|
|
14
|
+
|
|
15
|
+
## API
|
|
16
|
+
|
|
17
|
+
### `encryptBytes(input, password, options?): Promise<Uint8Array>`
|
|
18
|
+
|
|
19
|
+
Encrypts an `ArrayBuffer` or `Uint8Array` and returns a self-describing binary payload.
|
|
20
|
+
|
|
21
|
+
### `decryptBytes(input, password): Promise<Uint8Array>`
|
|
22
|
+
|
|
23
|
+
Decrypts a payload produced by `encryptBytes`. Throws `AppSafeCryptoError` with code `INVALID_PASSWORD_OR_DATA` if the password or payload is wrong.
|
|
24
|
+
|
|
25
|
+
### `encryptText(input, password, options?): Promise<Uint8Array>`
|
|
26
|
+
|
|
27
|
+
UTF-8 encodes the string, then calls `encryptBytes`.
|
|
28
|
+
|
|
29
|
+
### `decryptText(input, password): Promise<string>`
|
|
30
|
+
|
|
31
|
+
Calls `decryptBytes` and UTF-8 decodes the result. Throws `AppSafeCryptoError` with code `INVALID_TEXT` if the decrypted bytes are not valid UTF-8.
|
|
32
|
+
|
|
33
|
+
### `isAppSafePayload(input): boolean`
|
|
34
|
+
|
|
35
|
+
Cheap header check — verifies the `ASAFE` magic and version byte. Useful for validating a file before attempting decryption.
|
|
36
|
+
|
|
37
|
+
### `DEFAULT_PBKDF2_ITERATIONS`
|
|
38
|
+
|
|
39
|
+
The default PBKDF2 work factor: `600_000` SHA-256 iterations.
|
|
40
|
+
|
|
41
|
+
### `EncryptOptions`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
interface EncryptOptions {
|
|
45
|
+
iterations?: number; // default 600_000; clamped to [100_000, 2_000_000]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `AppSafeCryptoError`
|
|
50
|
+
|
|
51
|
+
Typed error thrown by all functions. Inspect `error.code` to differentiate failure modes:
|
|
52
|
+
|
|
53
|
+
| Code | Meaning |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `EMPTY_PASSWORD` | No password was provided. |
|
|
56
|
+
| `INVALID_OPTIONS` | `iterations` is outside the allowed range. |
|
|
57
|
+
| `INVALID_PAYLOAD` | The input is not a supported AppSafe payload. |
|
|
58
|
+
| `INVALID_PASSWORD_OR_DATA` | AES-GCM authentication failed. |
|
|
59
|
+
| `INVALID_TEXT` | Decrypted bytes are not valid UTF-8. |
|
|
60
|
+
| `UNSUPPORTED_RUNTIME` | The runtime has no Web Crypto `subtle` API. |
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
### Text round-trip
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { decryptText, encryptText } from "@asafarim/appsafe";
|
|
68
|
+
|
|
69
|
+
const encrypted = await encryptText("private note", password);
|
|
70
|
+
const plaintext = await decryptText(encrypted, password);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### File round-trip
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { decryptBytes, encryptBytes } from "@asafarim/appsafe";
|
|
77
|
+
|
|
78
|
+
const input = new Uint8Array(await file.arrayBuffer());
|
|
79
|
+
const payload = await encryptBytes(input, password);
|
|
80
|
+
const original = await decryptBytes(payload, password);
|
|
81
|
+
|
|
82
|
+
// Persist or download the payload:
|
|
83
|
+
const blob = new Blob([payload], { type: "application/octet-stream" });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Validate a payload before decrypting
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { isAppSafePayload } from "@asafarim/appsafe";
|
|
90
|
+
|
|
91
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
92
|
+
if (!isAppSafePayload(bytes)) {
|
|
93
|
+
throw new Error("Not an AppSafe payload");
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Custom PBKDF2 iterations
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { encryptBytes } from "@asafarim/appsafe";
|
|
101
|
+
|
|
102
|
+
const payload = await encryptBytes(data, password, { iterations: 1_000_000 });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Payload format
|
|
106
|
+
|
|
107
|
+
The returned `Uint8Array` is a single binary envelope:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
[ magic "ASAFE" (5) ][ version (1) ][ salt (16) ][ iv (12) ][ iterations (4, big-endian) ][ ciphertext + GCM tag ]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The full header is authenticated as AES-GCM additional data, so any modification to the salt, IV, or iteration count causes decryption to fail closed.
|
|
114
|
+
|
|
115
|
+
## Security notes
|
|
116
|
+
|
|
117
|
+
- Uses standardized primitives already implemented by modern browsers — no custom crypto.
|
|
118
|
+
- A new random salt and 96-bit nonce are generated for every encryption operation.
|
|
119
|
+
- Wrong passwords and tampered payloads fail closed via AES-GCM authentication.
|
|
120
|
+
- The package never reads files, creates downloads, or makes network requests for you.
|
|
121
|
+
- The default 600,000-iteration PBKDF2 work factor can be raised via `EncryptOptions.iterations` (max 2,000,000) when your product needs a different performance/security balance.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const DEFAULT_PBKDF2_ITERATIONS = 600000;
|
|
2
|
+
export type ByteSource = ArrayBuffer | Uint8Array;
|
|
3
|
+
export type AppSafeCryptoErrorCode = "EMPTY_PASSWORD" | "INVALID_OPTIONS" | "INVALID_PAYLOAD" | "INVALID_PASSWORD_OR_DATA" | "INVALID_TEXT" | "UNSUPPORTED_RUNTIME";
|
|
4
|
+
export declare class AppSafeCryptoError extends Error {
|
|
5
|
+
readonly code: AppSafeCryptoErrorCode;
|
|
6
|
+
constructor(code: AppSafeCryptoErrorCode, message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface EncryptOptions {
|
|
9
|
+
iterations?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function isAppSafePayload(input: ByteSource): boolean;
|
|
12
|
+
export declare function encryptBytes(input: ByteSource, password: string, options?: EncryptOptions): Promise<Uint8Array>;
|
|
13
|
+
export declare function decryptBytes(input: ByteSource, password: string): Promise<Uint8Array>;
|
|
14
|
+
export declare function encryptText(input: string, password: string, options?: EncryptOptions): Promise<Uint8Array>;
|
|
15
|
+
export declare function decryptText(input: ByteSource, password: string): Promise<string>;
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,yBAAyB,SAAU,CAAC;AAEjD,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,UAAU,CAAC;AAElD,MAAM,MAAM,sBAAsB,GAC9B,gBAAgB,GAChB,iBAAiB,GACjB,iBAAiB,GACjB,0BAA0B,GAC1B,cAAc,GACd,qBAAqB,CAAC;AAE1B,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,IAAI,EAAE,sBAAsB,CAAC;IAEtC,YAAY,IAAI,EAAE,sBAAsB,EAAE,OAAO,EAAE,MAAM,EAIxD;CACF;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA2ID,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAO3D;AAED,wBAAsB,YAAY,CAChC,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,UAAU,CAAC,CAyBrB;AAED,wBAAsB,YAAY,CAChC,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,UAAU,CAAC,CA0BrB;AAED,wBAAsB,WAAW,CAC/B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,UAAU,CAAC,CAErB;AAED,wBAAsB,WAAW,CAC/B,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,CAAC,CAWjB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const MAGIC = new Uint8Array([0x41, 0x53, 0x41, 0x46, 0x45]);
|
|
2
|
+
const VERSION = 1;
|
|
3
|
+
const SALT_LENGTH = 16;
|
|
4
|
+
const IV_LENGTH = 12;
|
|
5
|
+
const HEADER_LENGTH = MAGIC.length + 1 + SALT_LENGTH + IV_LENGTH + 4;
|
|
6
|
+
const AUTH_TAG_LENGTH = 16;
|
|
7
|
+
const MIN_ITERATIONS = 100_000;
|
|
8
|
+
const MAX_ITERATIONS = 2_000_000;
|
|
9
|
+
export const DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
|
10
|
+
export class AppSafeCryptoError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "AppSafeCryptoError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function getWebCrypto() {
|
|
19
|
+
if (typeof globalThis.crypto === "undefined" || !globalThis.crypto.subtle) {
|
|
20
|
+
throw new AppSafeCryptoError("UNSUPPORTED_RUNTIME", "This runtime does not provide the Web Crypto API.");
|
|
21
|
+
}
|
|
22
|
+
return globalThis.crypto;
|
|
23
|
+
}
|
|
24
|
+
function toBytes(input) {
|
|
25
|
+
if (input instanceof ArrayBuffer) {
|
|
26
|
+
return new Uint8Array(input.slice(0));
|
|
27
|
+
}
|
|
28
|
+
return new Uint8Array(input);
|
|
29
|
+
}
|
|
30
|
+
function asBufferSource(bytes) {
|
|
31
|
+
return bytes;
|
|
32
|
+
}
|
|
33
|
+
function assertPassword(password) {
|
|
34
|
+
if (typeof password !== "string" || password.length === 0) {
|
|
35
|
+
throw new AppSafeCryptoError("EMPTY_PASSWORD", "An encryption password is required.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function getIterations(options) {
|
|
39
|
+
const iterations = options?.iterations ?? DEFAULT_PBKDF2_ITERATIONS;
|
|
40
|
+
if (!Number.isSafeInteger(iterations) ||
|
|
41
|
+
iterations < MIN_ITERATIONS ||
|
|
42
|
+
iterations > MAX_ITERATIONS) {
|
|
43
|
+
throw new AppSafeCryptoError("INVALID_OPTIONS", `PBKDF2 iterations must be between ${MIN_ITERATIONS} and ${MAX_ITERATIONS}.`);
|
|
44
|
+
}
|
|
45
|
+
return iterations;
|
|
46
|
+
}
|
|
47
|
+
function randomBytes(length, cryptoApi) {
|
|
48
|
+
const bytes = new Uint8Array(length);
|
|
49
|
+
cryptoApi.getRandomValues(bytes);
|
|
50
|
+
return bytes;
|
|
51
|
+
}
|
|
52
|
+
function createHeader(salt, iv, iterations) {
|
|
53
|
+
const header = new Uint8Array(HEADER_LENGTH);
|
|
54
|
+
header.set(MAGIC, 0);
|
|
55
|
+
header[5] = VERSION;
|
|
56
|
+
header.set(salt, 6);
|
|
57
|
+
header.set(iv, 6 + SALT_LENGTH);
|
|
58
|
+
new DataView(header.buffer).setUint32(HEADER_LENGTH - 4, iterations);
|
|
59
|
+
return header;
|
|
60
|
+
}
|
|
61
|
+
function readHeader(encrypted) {
|
|
62
|
+
if (encrypted.length < HEADER_LENGTH + AUTH_TAG_LENGTH) {
|
|
63
|
+
throw new AppSafeCryptoError("INVALID_PAYLOAD", "The encrypted data is incomplete.");
|
|
64
|
+
}
|
|
65
|
+
const header = encrypted.slice(0, HEADER_LENGTH);
|
|
66
|
+
const hasMagic = MAGIC.every((byte, index) => header[index] === byte);
|
|
67
|
+
if (!hasMagic || header[5] !== VERSION) {
|
|
68
|
+
throw new AppSafeCryptoError("INVALID_PAYLOAD", "The data is not a supported AppSafe payload.");
|
|
69
|
+
}
|
|
70
|
+
const iterations = new DataView(header.buffer).getUint32(HEADER_LENGTH - 4);
|
|
71
|
+
if (iterations < MIN_ITERATIONS || iterations > MAX_ITERATIONS) {
|
|
72
|
+
throw new AppSafeCryptoError("INVALID_PAYLOAD", "The encrypted data has an invalid key-derivation setting.");
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
header,
|
|
76
|
+
salt: header.slice(6, 6 + SALT_LENGTH),
|
|
77
|
+
iv: header.slice(6 + SALT_LENGTH, 6 + SALT_LENGTH + IV_LENGTH),
|
|
78
|
+
iterations,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function deriveKey(password, salt, iterations, cryptoApi) {
|
|
82
|
+
const passwordKey = await cryptoApi.subtle.importKey("raw", asBufferSource(new TextEncoder().encode(password)), { name: "PBKDF2" }, false, ["deriveKey"]);
|
|
83
|
+
return cryptoApi.subtle.deriveKey({
|
|
84
|
+
name: "PBKDF2",
|
|
85
|
+
salt: asBufferSource(salt),
|
|
86
|
+
iterations,
|
|
87
|
+
hash: "SHA-256",
|
|
88
|
+
}, passwordKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
89
|
+
}
|
|
90
|
+
export function isAppSafePayload(input) {
|
|
91
|
+
const bytes = toBytes(input);
|
|
92
|
+
return (bytes.length >= HEADER_LENGTH + AUTH_TAG_LENGTH &&
|
|
93
|
+
MAGIC.every((byte, index) => bytes[index] === byte) &&
|
|
94
|
+
bytes[5] === VERSION);
|
|
95
|
+
}
|
|
96
|
+
export async function encryptBytes(input, password, options) {
|
|
97
|
+
assertPassword(password);
|
|
98
|
+
const iterations = getIterations(options);
|
|
99
|
+
const cryptoApi = getWebCrypto();
|
|
100
|
+
const plaintext = toBytes(input);
|
|
101
|
+
const salt = randomBytes(SALT_LENGTH, cryptoApi);
|
|
102
|
+
const iv = randomBytes(IV_LENGTH, cryptoApi);
|
|
103
|
+
const header = createHeader(salt, iv, iterations);
|
|
104
|
+
const key = await deriveKey(password, salt, iterations, cryptoApi);
|
|
105
|
+
const ciphertext = new Uint8Array(await cryptoApi.subtle.encrypt({
|
|
106
|
+
name: "AES-GCM",
|
|
107
|
+
iv: asBufferSource(iv),
|
|
108
|
+
additionalData: asBufferSource(header),
|
|
109
|
+
tagLength: 128,
|
|
110
|
+
}, key, asBufferSource(plaintext)));
|
|
111
|
+
const payload = new Uint8Array(header.length + ciphertext.length);
|
|
112
|
+
payload.set(header, 0);
|
|
113
|
+
payload.set(ciphertext, header.length);
|
|
114
|
+
return payload;
|
|
115
|
+
}
|
|
116
|
+
export async function decryptBytes(input, password) {
|
|
117
|
+
assertPassword(password);
|
|
118
|
+
const cryptoApi = getWebCrypto();
|
|
119
|
+
const encrypted = toBytes(input);
|
|
120
|
+
const { header, salt, iv, iterations } = readHeader(encrypted);
|
|
121
|
+
const key = await deriveKey(password, salt, iterations, cryptoApi);
|
|
122
|
+
try {
|
|
123
|
+
return new Uint8Array(await cryptoApi.subtle.decrypt({
|
|
124
|
+
name: "AES-GCM",
|
|
125
|
+
iv: asBufferSource(iv),
|
|
126
|
+
additionalData: asBufferSource(header),
|
|
127
|
+
tagLength: 128,
|
|
128
|
+
}, key, asBufferSource(encrypted.slice(HEADER_LENGTH))));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new AppSafeCryptoError("INVALID_PASSWORD_OR_DATA", "The password or encrypted data is invalid.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function encryptText(input, password, options) {
|
|
135
|
+
return encryptBytes(new TextEncoder().encode(input), password, options);
|
|
136
|
+
}
|
|
137
|
+
export async function decryptText(input, password) {
|
|
138
|
+
const plaintext = await decryptBytes(input, password);
|
|
139
|
+
try {
|
|
140
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new AppSafeCryptoError("INVALID_TEXT", "The decrypted data is not valid UTF-8 text.");
|
|
144
|
+
}
|
|
145
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@asafarim/appsafe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser-native password-based AES-256-GCM encryption for files and data",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.0.0"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"tsx": "4.23.12",
|
|
31
|
+
"typescript": "7.0.2"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"check": "tsc --noEmit -p tsconfig.json",
|
|
36
|
+
"test": "tsx --test test/*.test.ts"
|
|
37
|
+
}
|
|
38
|
+
}
|