@hyperbrowser/sdk 1.4.1 → 1.5.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.
package/README.md CHANGED
@@ -154,10 +154,11 @@ try {
154
154
  ```
155
155
 
156
156
  - `credentials` takes up to 20 items: a secret's name (or `{ secret }`),
157
- `{ vault }` for the secrets a stored vault names when the session is created,
158
- or `{ onePassword }` for an `op_token` secret (see
159
- [Fill from 1Password](#fill-from-1password)); the API validates names and limits.
160
- `session.credentials` lists the attached secrets as `{ id, name }`. Fill a
157
+ `{ vault }` for a stored vault, or `{ onePassword }` for an `op_token` secret
158
+ (see [Fill from 1Password](#fill-from-1password)); the API validates names
159
+ and limits. A vault is attached by reference, so each fill uses the secrets
160
+ its entries name at that moment. `session.credentials` lists the attached
161
+ secrets as `{ id, name }`, and `session.vaults` the vaults. Fill a
161
162
  single secret with `{ credential: "github-password", fields: [{ selector }] }`;
162
163
  with `vault`, each field names its entry in `field`.
163
164
  - Each input reports an `outcome`: `filled`, `not_found` (no input matched),
@@ -339,6 +340,82 @@ console.log(result.fields);
339
340
  - `CredentialFillTool` can't fill from 1Password; call
340
341
  `sessions.credentials.fill` directly (see [Use with LLM SDKs](#use-with-llm-sdks)).
341
342
 
343
+ ### Customer-protected secrets
344
+
345
+ A secret created with `protection: "customer"` is also wrapped with your own
346
+ key, so the platform stores nothing it can open alone. Each version is sealed
347
+ to the platform key as usual, then wrapped locally with your key. To use one,
348
+ unwrap it locally and pass the result. Only the platform can open the
349
+ unwrapped envelope, and it checks that the envelope belongs to the version
350
+ you pinned.
351
+
352
+ ```typescript
353
+ import { createCustomerKey } from "@hyperbrowser/sdk";
354
+
355
+ // RSA (2048 bits or more), EC P-256/P-384/P-521, X25519 or X448: a KeyObject,
356
+ // PEM/DER or a JWK. A public key can wrap; unwrapping needs the private key.
357
+ const customerKey = createCustomerKey(process.env.HB_CUSTOMER_KEY_PEM!);
358
+
359
+ await client.secrets.create({ name: "db-pass", value: "hunter2", customerKey });
360
+ await client.secrets.addVersion("db-pass", "hunter3", { customerKey });
361
+
362
+ // A reference pinned to the version unwrapped: { name, version, unwrapped }.
363
+ const dbPass = await client.secrets.unwrap("db-pass", customerKey);
364
+ const sandbox = await client.sandboxes.create({
365
+ imageName: "my-image",
366
+ secrets: { DB_PASS: dbPass },
367
+ });
368
+ await sandbox.exec("node migrate.js", { secrets: { DB_PASS: dbPass } });
369
+
370
+ // A vault's customer-protected entries, unwrapped together. Its other entries
371
+ // are delivered as usual.
372
+ const appEnv = await client.vaults.unwrap("app-env", customerKey);
373
+ await sandbox.processes.start("node worker.js", { vault: appEnv });
374
+
375
+ // A customer-protected op_token, for sandboxes and 1Password fills.
376
+ const token = await client.secrets.unwrap("op-token", customerKey);
377
+ await sandbox.exec("node app.js", {
378
+ onePassword: { token, vault: "Engineering", fields: { DB_PASS: { item: "Postgres", field: "password" } } },
379
+ });
380
+ ```
381
+
382
+ - `protection` is `"platform"` (the default) or `"customer"`, and is fixed when
383
+ the secret is created. Passing `customerKey` to `create` implies
384
+ `"customer"`. Creating one without a value gives an empty secret; add its
385
+ first version with `addVersion(secret, value, { customerKey })`.
386
+ - The key's `kid` is its RFC 7638 thumbprint unless the JWK has a `kid` or you
387
+ pass `createCustomerKey(key, { kid })`. RSA keys wrap with `RSA-OAEP-256`.
388
+ EC and OKP keys default to `ECDH-ES+A256KW`; pass `{ algorithm: "ECDH-ES" }`
389
+ for direct key agreement. To keep the private key in a KMS or HSM, implement
390
+ `CustomerKey` yourself: `kid`, `wrap(innerEnvelope, encryptionContext)` and
391
+ `unwrap(wrappedEnvelope)` produce and open a compact JWE with protected
392
+ header `{ alg, enc: "A256GCM", kid, ctx }`. `ctx` must be the secret's
393
+ `encryptionContext`. Either method may return a promise.
394
+ - `unwrap(secret, keys, { version })` unwraps the latest version by default.
395
+ `vaults.unwrap(vault, keys)` unwraps the versions the vault's entries
396
+ resolve to now. Both take several keys, such as during a rotation, and use
397
+ the one whose `kid` wrapped each version (`customer_key_mismatch` if none
398
+ did). The unwrapped envelope is checked against the version's `innerDigest`
399
+ locally, and again by the API.
400
+ - An unwrapped reference must pin a number, never `"latest"`. A vault reference
401
+ is refused with `SECRET_VAULT_CHANGED` when an entry was repointed or moved
402
+ to a newer version since it was unwrapped; unwrap it again.
403
+ - Customer-protected secrets can't be typed into browser pages, including as
404
+ vault entries in a fill (`SECRET_REQUIRES_CUSTOMER_UNWRAP`). A
405
+ customer-protected `op_token` can still sign 1Password fills: pass the
406
+ unwrapped reference as `onePassword.token`.
407
+ - Rotate your key with `rewrap(secret, { from, to })`. It unwraps each version
408
+ still wrapped with another key and rewraps it with `to`, all locally; the
409
+ value and the version numbers don't change. It skips versions already
410
+ wrapped with `to`, so an interrupted rotation resumes where it stopped.
411
+ `getWrappedVersion` and `replaceVersionWrapping` are the single-version
412
+ steps, for wrapping done elsewhere.
413
+ - Version details include `outerKid`, the customer key that wrapped them, and
414
+ `retiresAt`. A version whose platform key is retired is refused with
415
+ `SECRET_KEY_RETIRED`: add a new version.
416
+ - Fetching wrapped versions needs `secrets.use`, and rewrapping needs
417
+ `secrets.manage`. Signed-in users must be team owners or admins.
418
+
342
419
  ### Errors
343
420
 
344
421
  API errors are `HyperbrowserError`s with `statusCode`, `code` and `retryable`
@@ -347,7 +424,7 @@ own `details` holds the specifics (such as `error.details.details.origin`).
347
424
 
348
425
  | Code | Meaning |
349
426
  |---|---|
350
- | `CREDENTIAL_NOT_ATTACHED` | The secret, vault or 1Password token wasn't attached to the session, or a vault entry was repointed since. |
427
+ | `CREDENTIAL_NOT_ATTACHED` | The secret, vault or 1Password token wasn't attached to the session, directly or as an entry of an attached vault. |
351
428
  | `CREDENTIAL_ORIGIN_NOT_ALLOWED` | The page isn't on the secret's allowed origins (for 1Password, the request's); nothing was typed. |
352
429
  | `CREDENTIAL_NOT_READY` | The secret has no allowed origins or no value. |
353
430
  | `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND` | No tab or iframe matched. |
@@ -367,6 +444,12 @@ own `details` holds the specifics (such as `error.details.details.origin`).
367
444
  | `SECRET_ONEPASSWORD_AUTH_FAILED` | 1Password refused the token, or the token is malformed or signs in somewhere other than `1password.com`, `.ca` or `.eu` (or their subdomains). |
368
445
  | `SECRET_ONEPASSWORD_RATE_LIMITED`, `SECRET_ONEPASSWORD_FAILED` | Retryable. |
369
446
  | `SECRET_SOURCES_INVALID` | Bad `vault`/`onePassword`/`secrets`/`env` combination, such as a `onePassword` variable also set by the vault, more than 32 variables, or a vault with no entries. |
447
+ | `SECRET_REQUIRES_CUSTOMER_UNWRAP` | A customer-protected secret was used without its unwrapped envelope, unwrapped with `"latest"` instead of a pinned version, or used where it can't be (a fill). |
448
+ | `SECRET_PROTECTION_MISMATCH` | An unwrapped envelope for a secret that isn't customer-protected, or a version uploaded in the other protection's form. |
449
+ | `SECRET_DIGEST_MISMATCH` | The unwrapped envelope (or a rewrap's `innerDigest`) doesn't match the version. |
450
+ | `SECRET_VERSION_DUPLICATE` | Another version already holds this inner envelope; rewrap that version instead. |
451
+ | `SECRET_VAULT_CHANGED` | A vault entry resolves to another secret or version than the one unwrapped; unwrap the vault again. |
452
+ | `SECRET_KEY_RETIRED` | The version was sealed to a retired platform key; add a new version. |
370
453
  | `VAULT_NOT_FOUND`, `VAULT_ENTRY_NOT_FOUND` | |
371
454
  | `VAULT_NAME_TAKEN`, `VAULT_LIMIT_REACHED`, `VAULT_ENTRY_LIMIT_REACHED` | |
372
455
  | `SECRETS_FEATURE_DISABLED` | The team doesn't have secrets enabled. |
@@ -0,0 +1,83 @@
1
+ import crypto from "crypto";
2
+ import type { WrappedSecretVersion } from "./types/secret";
3
+ /**
4
+ * How a customer key wraps an envelope (JWE key management). RSA keys use
5
+ * `RSA-OAEP-256`; EC and X25519/X448 keys use `ECDH-ES+A256KW` unless
6
+ * `ECDH-ES` is chosen. Content is always encrypted with A256GCM.
7
+ */
8
+ export type CustomerKeyAlgorithm = "RSA-OAEP-256" | "ECDH-ES" | "ECDH-ES+A256KW";
9
+ /**
10
+ * The customer's own key for customer-protected secrets. Each version's
11
+ * envelope, already encrypted to the platform, is wrapped with this key
12
+ * before upload, so nothing the platform stores can be decrypted without it.
13
+ * Each use of the secret passes the envelope unwrapped with this key.
14
+ *
15
+ * `createCustomerKey` builds one from a local key. Implement this interface
16
+ * to keep the private key elsewhere, such as in a KMS or HSM.
17
+ */
18
+ export interface CustomerKey {
19
+ /**
20
+ * The key's ID, written as the `kid` of every envelope it wraps: 1-256
21
+ * printable ASCII characters.
22
+ */
23
+ readonly kid: string;
24
+ /**
25
+ * Wrap an inner envelope as a compact JWE (RSA-OAEP-256, ECDH-ES or
26
+ * ECDH-ES+A256KW with A256GCM) whose protected header holds exactly `alg`,
27
+ * `enc`, `kid` and `ctx` (plus `epk`, and optionally `apu` and `apv`, with
28
+ * ECDH-ES), with `ctx` set to `encryptionContext`.
29
+ */
30
+ wrap(innerEnvelope: string, encryptionContext: string): string | Promise<string>;
31
+ /** Decrypt an envelope this key wrapped and return the inner envelope. */
32
+ unwrap(wrappedEnvelope: string): string | Promise<string>;
33
+ }
34
+ /**
35
+ * A key as `createCustomerKey` takes it: a `KeyObject`, a PEM (or DER
36
+ * `Buffer`) private or public key, or a JWK. A public key can only wrap.
37
+ */
38
+ export type CustomerKeyInput = crypto.KeyObject | string | Buffer | crypto.JsonWebKey;
39
+ export interface CustomerKeyOptions {
40
+ /** Defaults to the JWK's `kid`, or else the key's RFC 7638 thumbprint. */
41
+ kid?: string;
42
+ /** For EC and X25519/X448 keys; defaults to `ECDH-ES+A256KW`. */
43
+ algorithm?: CustomerKeyAlgorithm;
44
+ }
45
+ /** Throws unless `kid` is a key ID the API stores: 1-256 printable ASCII characters. */
46
+ export declare const assertCustomerKeyId: (kid: unknown) => string;
47
+ /**
48
+ * A customer key from a local key: RSA (at least 2048 bits), EC (P-256,
49
+ * P-384 or P-521), X25519 or X448. With only the public key it can wrap
50
+ * (upload versions) but not unwrap (use them).
51
+ *
52
+ * The key ID defaults to the JWK's `kid`, or else the RFC 7638 thumbprint of
53
+ * the public key, so the same key always gets the same ID.
54
+ */
55
+ export declare const createCustomerKey: (key: CustomerKeyInput, options?: CustomerKeyOptions) => CustomerKey & {
56
+ readonly canUnwrap: boolean;
57
+ };
58
+ /**
59
+ * Identifies an inner envelope as the API does: the unpadded base64url
60
+ * SHA-256 of its compact form.
61
+ */
62
+ export declare const secretEnvelopeDigest: (envelope: string) => string;
63
+ /** @internal Checks that `key` looks like a CustomerKey before anything is sent. */
64
+ export declare const assertCustomerKey: (key: unknown, what?: string) => CustomerKey;
65
+ /**
66
+ * @internal Checks that `keys` is a CustomerKey or a list of them before
67
+ * anything is sent. An empty list passes only with `allowEmpty`: an up-front
68
+ * check must not reject a call that never needs a key (an empty vault, or a
69
+ * rewrap with nothing to rewrap); `customerKeyFor` still rejects it when a
70
+ * key is actually needed.
71
+ */
72
+ export declare const assertCustomerKeys: (keys: unknown, what?: string, { allowEmpty }?: {
73
+ allowEmpty?: boolean;
74
+ }) => readonly CustomerKey[];
75
+ /** @internal Wraps `innerEnvelope` and checks that `wrap()` returned a non-empty string. */
76
+ export declare const wrapEnvelope: (customerKey: CustomerKey, innerEnvelope: string, encryptionContext: string) => Promise<string>;
77
+ /**
78
+ * @internal Unwrap a wrapped version with the key among `keys` that wrapped
79
+ * it, and check the result is the inner envelope the version holds.
80
+ */
81
+ export declare const unwrapVersion: (wrapped: Pick<WrappedSecretVersion, "outerKid" | "innerDigest" | "envelope">, keys: CustomerKey | readonly CustomerKey[], label: string) => Promise<string>;
82
+ /** @internal The key among `keys` that wrapped an envelope with this `kid`. */
83
+ export declare const customerKeyFor: (keys: CustomerKey | readonly CustomerKey[], kid: string, label: string) => CustomerKey;
@@ -0,0 +1,419 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.customerKeyFor = exports.unwrapVersion = exports.wrapEnvelope = exports.assertCustomerKeys = exports.assertCustomerKey = exports.secretEnvelopeDigest = exports.createCustomerKey = exports.assertCustomerKeyId = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const error_1 = require("./error");
9
+ const ENC = "A256GCM";
10
+ const IV_BYTES = 12;
11
+ const TAG_BYTES = 16;
12
+ const CEK_BYTES = 32;
13
+ /** The server's limits: an inner envelope of 64 KiB, an RSA encrypted key of 256 bytes. */
14
+ const MAX_INNER_ENVELOPE_LENGTH = 64 * 1024;
15
+ const MIN_RSA_BITS = 2048;
16
+ const MAX_KID_LENGTH = 256;
17
+ const KID_PATTERN = /^[\x20-\x7E]+$/;
18
+ const AES_KW_IV = Buffer.from("A6A6A6A6A6A6A6A6", "hex");
19
+ const EC_CURVES = {
20
+ prime256v1: "P-256",
21
+ secp384r1: "P-384",
22
+ secp521r1: "P-521",
23
+ };
24
+ const keyError = (message, code = "invalid_customer_key") => new error_1.HyperbrowserError(message, { code, retryable: false });
25
+ const unwrapFailed = (message) => keyError(message, "customer_key_unwrap_failed");
26
+ const base64Url = (data) => Buffer.from(data).toString("base64url");
27
+ const decodeSegment = (segment, what) => {
28
+ const decoded = /^[A-Za-z0-9_-]*$/.test(segment) ? Buffer.from(segment, "base64url") : null;
29
+ if (!decoded || decoded.toString("base64url") !== segment) {
30
+ throw unwrapFailed(`Wrapped envelope ${what} is not canonical base64url`);
31
+ }
32
+ return decoded;
33
+ };
34
+ const uint32 = (value) => {
35
+ const buffer = Buffer.alloc(4);
36
+ buffer.writeUInt32BE(value);
37
+ return buffer;
38
+ };
39
+ const lengthPrefixed = (data) => Buffer.concat([uint32(data.length), data]);
40
+ /** RFC 7518 §4.6.2 Concat KDF with SHA-256, for one 256-bit key. */
41
+ const concatKdf = (sharedSecret, algorithmId, apu, apv) => crypto_1.default
42
+ .createHash("sha256")
43
+ .update(Buffer.concat([
44
+ uint32(1),
45
+ sharedSecret,
46
+ lengthPrefixed(Buffer.from(algorithmId, "ascii")),
47
+ lengthPrefixed(apu),
48
+ lengthPrefixed(apv),
49
+ uint32(CEK_BYTES * 8),
50
+ ]))
51
+ .digest();
52
+ /** The members of a public JWK that RFC 7638 thumbprints (and `epk` headers) hold. */
53
+ const publicMembers = (jwk) => {
54
+ switch (jwk.kty) {
55
+ case "RSA":
56
+ return { e: jwk.e, kty: "RSA", n: jwk.n };
57
+ case "EC":
58
+ return { crv: jwk.crv, kty: "EC", x: jwk.x, y: jwk.y };
59
+ default:
60
+ return { crv: jwk.crv, kty: jwk.kty, x: jwk.x };
61
+ }
62
+ };
63
+ /** The RFC 7638 SHA-256 thumbprint of a public key. */
64
+ const thumbprint = (publicKey) => base64Url(crypto_1.default
65
+ .createHash("sha256")
66
+ .update(JSON.stringify(publicMembers(publicKey.export({ format: "jwk" }))))
67
+ .digest());
68
+ const isJwk = (input) => !!input &&
69
+ typeof input === "object" &&
70
+ !Buffer.isBuffer(input) &&
71
+ !(input instanceof crypto_1.default.KeyObject) &&
72
+ typeof input.kty === "string";
73
+ const loadKeys = (input) => {
74
+ if (input instanceof crypto_1.default.KeyObject) {
75
+ if (input.type === "private") {
76
+ return { publicKey: crypto_1.default.createPublicKey(input), privateKey: input };
77
+ }
78
+ if (input.type === "public") {
79
+ return { publicKey: input };
80
+ }
81
+ throw keyError("A customer key must be an asymmetric key, not a secret key");
82
+ }
83
+ if (isJwk(input)) {
84
+ const jwkKid = typeof input.kid === "string" ? input.kid : undefined;
85
+ try {
86
+ if (typeof input.d === "string") {
87
+ const privateKey = crypto_1.default.createPrivateKey({ key: input, format: "jwk" });
88
+ return { publicKey: crypto_1.default.createPublicKey(privateKey), privateKey, jwkKid };
89
+ }
90
+ return { publicKey: crypto_1.default.createPublicKey({ key: input, format: "jwk" }), jwkKid };
91
+ }
92
+ catch (cause) {
93
+ throw new error_1.HyperbrowserError("The customer key JWK could not be read", {
94
+ code: "invalid_customer_key",
95
+ cause,
96
+ });
97
+ }
98
+ }
99
+ if (typeof input === "string" || Buffer.isBuffer(input)) {
100
+ try {
101
+ const privateKey = crypto_1.default.createPrivateKey(input);
102
+ return { publicKey: crypto_1.default.createPublicKey(privateKey), privateKey };
103
+ }
104
+ catch {
105
+ try {
106
+ return { publicKey: crypto_1.default.createPublicKey(input) };
107
+ }
108
+ catch (cause) {
109
+ throw new error_1.HyperbrowserError("The customer key is not a PEM or DER key", {
110
+ code: "invalid_customer_key",
111
+ cause,
112
+ });
113
+ }
114
+ }
115
+ }
116
+ throw keyError("A customer key must be a KeyObject, a PEM or DER key, or a JWK");
117
+ };
118
+ /** Throws unless `kid` is a key ID the API stores: 1-256 printable ASCII characters. */
119
+ const assertCustomerKeyId = (kid) => {
120
+ if (typeof kid !== "string" || kid.length > MAX_KID_LENGTH || !KID_PATTERN.test(kid)) {
121
+ throw keyError(`A customer key's kid must be 1-${MAX_KID_LENGTH} printable ASCII characters`);
122
+ }
123
+ return kid;
124
+ };
125
+ exports.assertCustomerKeyId = assertCustomerKeyId;
126
+ const keyKind = (publicKey) => {
127
+ const type = publicKey.asymmetricKeyType;
128
+ if (type === "rsa") {
129
+ const bits = publicKey.asymmetricKeyDetails?.modulusLength ?? 0;
130
+ if (bits < MIN_RSA_BITS) {
131
+ throw keyError(`RSA customer keys must be at least ${MIN_RSA_BITS} bits`);
132
+ }
133
+ return { type };
134
+ }
135
+ if (type === "ec") {
136
+ const curve = publicKey.asymmetricKeyDetails?.namedCurve ?? "";
137
+ if (!EC_CURVES[curve]) {
138
+ throw keyError("EC customer keys must be on P-256, P-384 or P-521");
139
+ }
140
+ return { type, curve };
141
+ }
142
+ if (type === "x25519" || type === "x448") {
143
+ return { type };
144
+ }
145
+ throw keyError("A customer key must be RSA, EC (P-256, P-384, P-521), X25519 or X448");
146
+ };
147
+ /** A customer key held in this process. */
148
+ class LocalCustomerKey {
149
+ constructor(publicKey, privateKey, options) {
150
+ this.publicKey = publicKey;
151
+ this.privateKey = privateKey;
152
+ this.kind = keyKind(publicKey);
153
+ const algorithm = options.algorithm ?? (this.kind.type === "rsa" ? "RSA-OAEP-256" : "ECDH-ES+A256KW");
154
+ if ((algorithm === "RSA-OAEP-256") !== (this.kind.type === "rsa")) {
155
+ throw keyError(this.kind.type === "rsa"
156
+ ? "RSA customer keys wrap with RSA-OAEP-256"
157
+ : "EC and X25519/X448 customer keys wrap with ECDH-ES or ECDH-ES+A256KW");
158
+ }
159
+ if (!["RSA-OAEP-256", "ECDH-ES", "ECDH-ES+A256KW"].includes(algorithm)) {
160
+ throw keyError(`Unsupported customer key algorithm ${String(algorithm)}`);
161
+ }
162
+ this.algorithm = algorithm;
163
+ this.kid = (0, exports.assertCustomerKeyId)(options.kid ?? options.jwkKid ?? thumbprint(publicKey));
164
+ }
165
+ /** Whether this key can unwrap, that is, holds its private key. */
166
+ get canUnwrap() {
167
+ return this.privateKey !== undefined;
168
+ }
169
+ wrap(innerEnvelope, encryptionContext) {
170
+ if (typeof innerEnvelope !== "string" || innerEnvelope.length === 0) {
171
+ throw keyError("The envelope to wrap must be a non-empty string", "invalid_request");
172
+ }
173
+ if (innerEnvelope.length > MAX_INNER_ENVELOPE_LENGTH) {
174
+ throw keyError("The envelope to wrap is larger than 64 KiB", "invalid_request");
175
+ }
176
+ if (typeof encryptionContext !== "string" || encryptionContext.length === 0) {
177
+ throw keyError("An encryption context is required to wrap an envelope", "invalid_request");
178
+ }
179
+ const header = {
180
+ alg: this.algorithm,
181
+ enc: ENC,
182
+ kid: this.kid,
183
+ ctx: encryptionContext,
184
+ };
185
+ let cek;
186
+ let encryptedKey = Buffer.alloc(0);
187
+ if (this.kind.type === "rsa") {
188
+ cek = crypto_1.default.randomBytes(CEK_BYTES);
189
+ encryptedKey = crypto_1.default.publicEncrypt({
190
+ key: this.publicKey,
191
+ padding: crypto_1.default.constants.RSA_PKCS1_OAEP_PADDING,
192
+ oaepHash: "sha256",
193
+ }, cek);
194
+ }
195
+ else {
196
+ const ephemeral = this.kind.type === "ec"
197
+ ? crypto_1.default.generateKeyPairSync("ec", { namedCurve: this.kind.curve })
198
+ : crypto_1.default.generateKeyPairSync(this.kind.type);
199
+ header.epk = publicMembers(ephemeral.publicKey.export({ format: "jwk" }));
200
+ const shared = crypto_1.default.diffieHellman({
201
+ privateKey: ephemeral.privateKey,
202
+ publicKey: this.publicKey,
203
+ });
204
+ const derived = concatKdf(shared, this.algorithm === "ECDH-ES" ? ENC : this.algorithm, Buffer.alloc(0), Buffer.alloc(0));
205
+ shared.fill(0);
206
+ if (this.algorithm === "ECDH-ES") {
207
+ cek = derived;
208
+ }
209
+ else {
210
+ cek = crypto_1.default.randomBytes(CEK_BYTES);
211
+ const wrapper = crypto_1.default.createCipheriv("id-aes256-wrap", derived, AES_KW_IV);
212
+ encryptedKey = Buffer.concat([wrapper.update(cek), wrapper.final()]);
213
+ derived.fill(0);
214
+ }
215
+ }
216
+ try {
217
+ const protectedHeader = base64Url(Buffer.from(JSON.stringify(header), "utf8"));
218
+ const iv = crypto_1.default.randomBytes(IV_BYTES);
219
+ const cipher = crypto_1.default.createCipheriv("aes-256-gcm", cek, iv);
220
+ cipher.setAAD(Buffer.from(protectedHeader, "ascii"));
221
+ const ciphertext = Buffer.concat([cipher.update(innerEnvelope, "utf8"), cipher.final()]);
222
+ return [
223
+ protectedHeader,
224
+ base64Url(encryptedKey),
225
+ base64Url(iv),
226
+ base64Url(ciphertext),
227
+ base64Url(cipher.getAuthTag()),
228
+ ].join(".");
229
+ }
230
+ finally {
231
+ cek.fill(0);
232
+ }
233
+ }
234
+ unwrap(wrappedEnvelope) {
235
+ if (!this.privateKey) {
236
+ throw keyError(`Customer key ${this.kid} has no private key, so it can wrap but not unwrap`, "customer_key_private_key_required");
237
+ }
238
+ if (typeof wrappedEnvelope !== "string") {
239
+ throw unwrapFailed("The wrapped envelope must be a string");
240
+ }
241
+ const segments = wrappedEnvelope.split(".");
242
+ if (segments.length !== 5) {
243
+ throw unwrapFailed("The wrapped envelope must have five segments");
244
+ }
245
+ const [headerSegment, keySegment, ivSegment, ciphertextSegment, tagSegment] = segments;
246
+ let header;
247
+ try {
248
+ header = JSON.parse(decodeSegment(headerSegment, "header").toString("utf8"));
249
+ }
250
+ catch (error) {
251
+ if (error instanceof error_1.HyperbrowserError)
252
+ throw error;
253
+ throw unwrapFailed("The wrapped envelope header is not JSON");
254
+ }
255
+ if (!header || typeof header !== "object" || Array.isArray(header)) {
256
+ throw unwrapFailed("The wrapped envelope header is not a JSON object");
257
+ }
258
+ if (header.kid !== this.kid) {
259
+ throw keyError(`The envelope was wrapped with key ${JSON.stringify(header.kid)}, not ${JSON.stringify(this.kid)}`, "customer_key_mismatch");
260
+ }
261
+ if (header.enc !== ENC) {
262
+ throw unwrapFailed(`Unsupported wrapped envelope encryption ${String(header.enc)}`);
263
+ }
264
+ const alg = header.alg;
265
+ const encryptedKey = decodeSegment(keySegment, "encrypted key");
266
+ const iv = decodeSegment(ivSegment, "IV");
267
+ const ciphertext = decodeSegment(ciphertextSegment, "ciphertext");
268
+ const tag = decodeSegment(tagSegment, "tag");
269
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
270
+ throw unwrapFailed("The wrapped envelope IV or tag has the wrong length");
271
+ }
272
+ let cek;
273
+ try {
274
+ if (alg === "RSA-OAEP-256" && this.kind.type === "rsa") {
275
+ cek = crypto_1.default.privateDecrypt({
276
+ key: this.privateKey,
277
+ padding: crypto_1.default.constants.RSA_PKCS1_OAEP_PADDING,
278
+ oaepHash: "sha256",
279
+ }, encryptedKey);
280
+ }
281
+ else if ((alg === "ECDH-ES" || alg === "ECDH-ES+A256KW") && this.kind.type !== "rsa") {
282
+ const optional = (name) => header[name] === undefined ? Buffer.alloc(0) : decodeSegment(String(header[name]), name);
283
+ const epk = crypto_1.default.createPublicKey({
284
+ key: header.epk,
285
+ format: "jwk",
286
+ });
287
+ const shared = crypto_1.default.diffieHellman({ privateKey: this.privateKey, publicKey: epk });
288
+ const derived = concatKdf(shared, alg === "ECDH-ES" ? ENC : alg, optional("apu"), optional("apv"));
289
+ shared.fill(0);
290
+ if (alg === "ECDH-ES") {
291
+ cek = derived;
292
+ }
293
+ else {
294
+ const unwrapper = crypto_1.default.createDecipheriv("id-aes256-wrap", derived, AES_KW_IV);
295
+ cek = Buffer.concat([unwrapper.update(encryptedKey), unwrapper.final()]);
296
+ derived.fill(0);
297
+ }
298
+ }
299
+ else {
300
+ throw unwrapFailed(`Customer key ${this.kid} can't unwrap an envelope wrapped with ${String(alg)}`);
301
+ }
302
+ }
303
+ catch (error) {
304
+ if (error instanceof error_1.HyperbrowserError)
305
+ throw error;
306
+ throw new error_1.HyperbrowserError("The wrapped envelope's key could not be decrypted", {
307
+ code: "customer_key_unwrap_failed",
308
+ cause: error,
309
+ });
310
+ }
311
+ try {
312
+ if (cek.length !== CEK_BYTES) {
313
+ throw unwrapFailed("The wrapped envelope's content key has the wrong length");
314
+ }
315
+ const decipher = crypto_1.default.createDecipheriv("aes-256-gcm", cek, iv);
316
+ decipher.setAAD(Buffer.from(headerSegment, "ascii"));
317
+ decipher.setAuthTag(tag);
318
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
319
+ }
320
+ catch (error) {
321
+ if (error instanceof error_1.HyperbrowserError)
322
+ throw error;
323
+ throw new error_1.HyperbrowserError("The wrapped envelope could not be decrypted", {
324
+ code: "customer_key_unwrap_failed",
325
+ cause: error,
326
+ });
327
+ }
328
+ finally {
329
+ cek.fill(0);
330
+ }
331
+ }
332
+ }
333
+ /**
334
+ * A customer key from a local key: RSA (at least 2048 bits), EC (P-256,
335
+ * P-384 or P-521), X25519 or X448. With only the public key it can wrap
336
+ * (upload versions) but not unwrap (use them).
337
+ *
338
+ * The key ID defaults to the JWK's `kid`, or else the RFC 7638 thumbprint of
339
+ * the public key, so the same key always gets the same ID.
340
+ */
341
+ const createCustomerKey = (key, options = {}) => {
342
+ const { publicKey, privateKey, jwkKid } = loadKeys(key);
343
+ return new LocalCustomerKey(publicKey, privateKey, { ...options, jwkKid });
344
+ };
345
+ exports.createCustomerKey = createCustomerKey;
346
+ /**
347
+ * Identifies an inner envelope as the API does: the unpadded base64url
348
+ * SHA-256 of its compact form.
349
+ */
350
+ const secretEnvelopeDigest = (envelope) => crypto_1.default.createHash("sha256").update(envelope, "utf8").digest("base64url");
351
+ exports.secretEnvelopeDigest = secretEnvelopeDigest;
352
+ /** @internal Checks that `key` looks like a CustomerKey before anything is sent. */
353
+ const assertCustomerKey = (key, what = "customerKey") => {
354
+ const candidate = key;
355
+ if (!candidate ||
356
+ typeof candidate !== "object" ||
357
+ typeof candidate.wrap !== "function" ||
358
+ typeof candidate.unwrap !== "function") {
359
+ throw new error_1.HyperbrowserError(`${what} must be a customer key, such as one from createCustomerKey()`, { code: "invalid_request", retryable: false });
360
+ }
361
+ (0, exports.assertCustomerKeyId)(candidate.kid);
362
+ return candidate;
363
+ };
364
+ exports.assertCustomerKey = assertCustomerKey;
365
+ /**
366
+ * @internal Checks that `keys` is a CustomerKey or a list of them before
367
+ * anything is sent. An empty list passes only with `allowEmpty`: an up-front
368
+ * check must not reject a call that never needs a key (an empty vault, or a
369
+ * rewrap with nothing to rewrap); `customerKeyFor` still rejects it when a
370
+ * key is actually needed.
371
+ */
372
+ const assertCustomerKeys = (keys, what = "customerKey", { allowEmpty = false } = {}) => {
373
+ if (!Array.isArray(keys)) {
374
+ return [(0, exports.assertCustomerKey)(keys, what)];
375
+ }
376
+ if (keys.length === 0 && !allowEmpty) {
377
+ throw new error_1.HyperbrowserError(`Pass at least one customer key as ${what}`, {
378
+ code: "invalid_request",
379
+ retryable: false,
380
+ });
381
+ }
382
+ return keys.map((key, index) => (0, exports.assertCustomerKey)(key, `${what}[${index}]`));
383
+ };
384
+ exports.assertCustomerKeys = assertCustomerKeys;
385
+ /** @internal Wraps `innerEnvelope` and checks that `wrap()` returned a non-empty string. */
386
+ const wrapEnvelope = async (customerKey, innerEnvelope, encryptionContext) => {
387
+ const envelope = await customerKey.wrap(innerEnvelope, encryptionContext);
388
+ if (typeof envelope !== "string" || !envelope) {
389
+ throw new error_1.HyperbrowserError("The customer key's wrap() must return the envelope as a string", {
390
+ code: "invalid_request",
391
+ retryable: false,
392
+ });
393
+ }
394
+ return envelope;
395
+ };
396
+ exports.wrapEnvelope = wrapEnvelope;
397
+ /**
398
+ * @internal Unwrap a wrapped version with the key among `keys` that wrapped
399
+ * it, and check the result is the inner envelope the version holds.
400
+ */
401
+ const unwrapVersion = async (wrapped, keys, label) => {
402
+ const key = (0, exports.customerKeyFor)(keys, wrapped.outerKid, label);
403
+ const inner = await key.unwrap(wrapped.envelope);
404
+ if (typeof inner !== "string" || (0, exports.secretEnvelopeDigest)(inner) !== wrapped.innerDigest) {
405
+ throw new error_1.HyperbrowserError(`${label} unwrapped to an envelope that doesn't match its innerDigest; it may have been uploaded with the wrong digest`, { code: "customer_key_unwrap_failed", retryable: false });
406
+ }
407
+ return inner;
408
+ };
409
+ exports.unwrapVersion = unwrapVersion;
410
+ /** @internal The key among `keys` that wrapped an envelope with this `kid`. */
411
+ const customerKeyFor = (keys, kid, label) => {
412
+ const list = (0, exports.assertCustomerKeys)(keys);
413
+ const key = list.find((candidate) => candidate.kid === kid);
414
+ if (!key) {
415
+ throw new error_1.HyperbrowserError(`${label} was wrapped with customer key ${JSON.stringify(kid)}, which none of the keys given has`, { code: "customer_key_mismatch", retryable: false });
416
+ }
417
+ return key;
418
+ };
419
+ exports.customerKeyFor = customerKeyFor;
package/dist/index.d.ts CHANGED
@@ -2,6 +2,9 @@ import { dockerBuildContextFingerprint, DockerBuildContextChangedError, imageBui
2
2
  export { dockerBuildContextFingerprint, DockerBuildContextChangedError, imageBuildName, dockerImageDigest, DockerCommandError, };
3
3
  export type { DockerBuildContextOptions, ImageBuildNameOptions, ImageBuildSource, } from "./image-builds";
4
4
  import { HyperbrowserClient, HyperbrowserError } from "./client";
5
+ import { createCustomerKey, secretEnvelopeDigest } from "./customer-key";
6
+ export { createCustomerKey, secretEnvelopeDigest };
7
+ export type { CustomerKey, CustomerKeyAlgorithm, CustomerKeyInput, CustomerKeyOptions, } from "./customer-key";
5
8
  export declare const Hyperbrowser: typeof HyperbrowserClient;
6
9
  export type Hyperbrowser = HyperbrowserClient;
7
10
  export { HyperbrowserClient, HyperbrowserError };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HyperbrowserError = exports.HyperbrowserClient = exports.Hyperbrowser = exports.DockerCommandError = exports.dockerImageDigest = exports.imageBuildName = exports.DockerBuildContextChangedError = exports.dockerBuildContextFingerprint = void 0;
3
+ exports.HyperbrowserError = exports.HyperbrowserClient = exports.Hyperbrowser = exports.secretEnvelopeDigest = exports.createCustomerKey = exports.DockerCommandError = exports.dockerImageDigest = exports.imageBuildName = exports.DockerBuildContextChangedError = exports.dockerBuildContextFingerprint = void 0;
4
4
  const image_builds_1 = require("./image-builds");
5
5
  Object.defineProperty(exports, "dockerBuildContextFingerprint", { enumerable: true, get: function () { return image_builds_1.dockerBuildContextFingerprint; } });
6
6
  Object.defineProperty(exports, "DockerBuildContextChangedError", { enumerable: true, get: function () { return image_builds_1.DockerBuildContextChangedError; } });
@@ -10,6 +10,9 @@ Object.defineProperty(exports, "DockerCommandError", { enumerable: true, get: fu
10
10
  const client_1 = require("./client");
11
11
  Object.defineProperty(exports, "HyperbrowserClient", { enumerable: true, get: function () { return client_1.HyperbrowserClient; } });
12
12
  Object.defineProperty(exports, "HyperbrowserError", { enumerable: true, get: function () { return client_1.HyperbrowserError; } });
13
+ const customer_key_1 = require("./customer-key");
14
+ Object.defineProperty(exports, "createCustomerKey", { enumerable: true, get: function () { return customer_key_1.createCustomerKey; } });
15
+ Object.defineProperty(exports, "secretEnvelopeDigest", { enumerable: true, get: function () { return customer_key_1.secretEnvelopeDigest; } });
13
16
  // Export HyperbrowserClient as Hyperbrowser for named imports
14
17
  exports.Hyperbrowser = client_1.HyperbrowserClient;
15
18
  exports.default = client_1.HyperbrowserClient;
@@ -24,5 +27,7 @@ if (typeof module !== "undefined" && module.exports) {
24
27
  module.exports.imageBuildName = image_builds_1.imageBuildName;
25
28
  module.exports.dockerImageDigest = image_builds_1.dockerImageDigest;
26
29
  module.exports.DockerCommandError = image_builds_1.DockerCommandError;
30
+ module.exports.createCustomerKey = customer_key_1.createCustomerKey;
31
+ module.exports.secretEnvelopeDigest = customer_key_1.secretEnvelopeDigest;
27
32
  module.exports.default = client_1.HyperbrowserClient;
28
33
  }