@agentshouse/envelope 0.1.0-alpha.1

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.
Files changed (4) hide show
  1. package/README.md +32 -0
  2. package/index.d.ts +17 -0
  3. package/index.js +75 -0
  4. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @agentshouse/envelope
2
+
3
+ The AES-256-GCM purpose-bound envelope agents.house seals recoverable secrets
4
+ with. Every value gets a fresh data key and nonce, an authentication tag, and an
5
+ authenticated context that binds it to its immutable owning object and one
6
+ purpose the caller supplies. Key management stays behind a port the caller
7
+ implements, so the package names no purpose and no key-management provider.
8
+
9
+ ## Use
10
+
11
+ ```js
12
+ const { open, seal } = require('@agentshouse/envelope');
13
+
14
+ const envelope = await seal(keys, 'provider-token', objectId, plaintext);
15
+ const token = await open(keys, 'provider-token', objectId, envelope, (plaintext) =>
16
+ plaintext.toString('utf8'),
17
+ );
18
+ ```
19
+
20
+ `keys` implements `Keys`: `wrap(dataKey, authenticatedData)` and
21
+ `unwrap(wrappedDataKey, authenticatedData)`. The authenticated context is the
22
+ canonical JSON `{"object":…,"purpose":…,"version":1}` and is passed to both.
23
+ `objectId` is a UUID.
24
+
25
+ The `Envelope` is five components the caller stores as they are: `version`,
26
+ `wrappedDataKey`, `nonce`, `tag` and `ciphertext`. `open` refuses a malformed or
27
+ forged envelope, and one opened under another object or purpose, with
28
+ `IntegrityError`. `valid(envelope)` is the check of the stored form `open`
29
+ makes before it asks the port, so a caller can refuse a malformed record when it
30
+ reads one. An error the port throws passes through unchanged, so the caller maps
31
+ its own refusals and unavailability. The plaintext exists only inside the
32
+ callback and is wiped when it returns.
package/index.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export interface Keys {
2
+ wrap(dataKey: Uint8Array, authenticatedData: Uint8Array): Promise<Uint8Array>;
3
+ unwrap(wrappedDataKey: Uint8Array, authenticatedData: Uint8Array): Promise<Uint8Array>;
4
+ }
5
+ export interface Envelope {
6
+ readonly version: number;
7
+ readonly wrappedDataKey: Buffer;
8
+ readonly nonce: Buffer;
9
+ readonly tag: Buffer;
10
+ readonly ciphertext: Buffer;
11
+ }
12
+ export declare class IntegrityError extends Error {
13
+ constructor();
14
+ }
15
+ export declare function valid(envelope: Envelope): boolean;
16
+ export declare function seal(keys: Keys, purpose: string, objectId: string, plaintext: Uint8Array): Promise<Envelope>;
17
+ export declare function open<T>(keys: Keys, purpose: string, objectId: string, envelope: Envelope, work: (plaintext: Buffer) => Promise<T> | T): Promise<T>;
package/index.js ADDED
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IntegrityError = void 0;
4
+ exports.valid = valid;
5
+ exports.seal = seal;
6
+ exports.open = open;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const VERSION = 1;
9
+ const DATA_KEY_BYTES = 32;
10
+ const NONCE_BYTES = 12;
11
+ const TAG_BYTES = 16;
12
+ const OBJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ class IntegrityError extends Error {
14
+ constructor() {
15
+ super('envelope_integrity');
16
+ this.name = 'IntegrityError';
17
+ }
18
+ }
19
+ exports.IntegrityError = IntegrityError;
20
+ function authenticatedContext(purpose, objectId) {
21
+ if (!OBJECT_ID.test(objectId))
22
+ throw new IntegrityError();
23
+ return Buffer.from(JSON.stringify({ object: objectId, purpose, version: VERSION }), 'utf8');
24
+ }
25
+ function length(component) {
26
+ return component instanceof Uint8Array ? component.length : -1;
27
+ }
28
+ function valid(envelope) {
29
+ return (envelope.version === VERSION &&
30
+ length(envelope.wrappedDataKey) > 0 &&
31
+ length(envelope.nonce) === NONCE_BYTES &&
32
+ length(envelope.tag) === TAG_BYTES &&
33
+ length(envelope.ciphertext) > 0);
34
+ }
35
+ async function seal(keys, purpose, objectId, plaintext) {
36
+ if (plaintext.length < 1)
37
+ throw new IntegrityError();
38
+ const aad = authenticatedContext(purpose, objectId);
39
+ const dataKey = (0, node_crypto_1.randomBytes)(DATA_KEY_BYTES);
40
+ try {
41
+ const nonce = (0, node_crypto_1.randomBytes)(NONCE_BYTES);
42
+ const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', dataKey, nonce);
43
+ cipher.setAAD(aad);
44
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
45
+ const tag = cipher.getAuthTag();
46
+ const wrappedDataKey = Buffer.from(await keys.wrap(dataKey, aad));
47
+ return { version: VERSION, wrappedDataKey, nonce, tag, ciphertext };
48
+ }
49
+ finally {
50
+ dataKey.fill(0);
51
+ }
52
+ }
53
+ async function open(keys, purpose, objectId, envelope, work) {
54
+ if (!valid(envelope))
55
+ throw new IntegrityError();
56
+ const aad = authenticatedContext(purpose, objectId);
57
+ const dataKey = Buffer.from(await keys.unwrap(envelope.wrappedDataKey, aad));
58
+ let opened;
59
+ try {
60
+ try {
61
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', dataKey, envelope.nonce);
62
+ decipher.setAAD(aad);
63
+ decipher.setAuthTag(envelope.tag);
64
+ opened = Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]);
65
+ }
66
+ catch {
67
+ throw new IntegrityError();
68
+ }
69
+ return await work(opened);
70
+ }
71
+ finally {
72
+ dataKey.fill(0);
73
+ opened?.fill(0);
74
+ }
75
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@agentshouse/envelope",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "AES-256-GCM purpose-bound envelope over a key-management port: a fresh data key and nonce per value, bound to its owning object and one caller purpose.",
5
+ "license": "UNLICENSED",
6
+ "type": "commonjs",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=24"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "default": "./index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "*.js",
19
+ "*.d.ts",
20
+ "README.md"
21
+ ]
22
+ }