@better-update/cli 0.42.1 → 0.43.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/dist/index.mjs +1340 -99
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import os, { tmpdir } from "node:os";
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { maxBy, uniqBy } from "es-toolkit";
|
|
25
25
|
import forge from "node-forge";
|
|
26
|
-
import
|
|
26
|
+
import configPlugins from "@expo/config-plugins";
|
|
27
27
|
import plistMod from "@expo/plist";
|
|
28
28
|
import { ExpoRunFormatter } from "@expo/xcpretty";
|
|
29
29
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
@@ -35,7 +35,7 @@ var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
|
|
|
35
35
|
|
|
36
36
|
//#endregion
|
|
37
37
|
//#region package.json
|
|
38
|
-
var version = "0.
|
|
38
|
+
var version = "0.43.0";
|
|
39
39
|
|
|
40
40
|
//#endregion
|
|
41
41
|
//#region src/lib/interactive-mode.ts
|
|
@@ -133,6 +133,148 @@ const UploadHeaders = Schema.Record({
|
|
|
133
133
|
value: Schema.String
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region ../../packages/api/src/domain/user-encryption-key.ts
|
|
138
|
+
/**
|
|
139
|
+
* A recipient key's role. A `device` key is user-owned (works across the user's
|
|
140
|
+
* orgs); `recovery` (offline break-glass) and `machine` (CI) keys are org-owned
|
|
141
|
+
* and have no `userId`.
|
|
142
|
+
*/
|
|
143
|
+
const EncryptionKeyKind = Schema.Literal("device", "recovery", "machine");
|
|
144
|
+
/** An age recipient string (`age1...`) — a public key safe for the server to hold. */
|
|
145
|
+
const AgeRecipient = Schema.String.pipe(Schema.minLength(1), Schema.startsWith("age1")).annotations({ description: "age recipient public key (age1...)" });
|
|
146
|
+
/** An SSH-style key fingerprint (`SHA256:...`) shown for out-of-band verification. */
|
|
147
|
+
const KeyFingerprint = Schema.String.pipe(Schema.startsWith("SHA256:"), Schema.minLength(8)).annotations({ description: "SSH-style key fingerprint (SHA256:...)" });
|
|
148
|
+
/**
|
|
149
|
+
* A registered public recipient. Private keys never leave the owner's machine;
|
|
150
|
+
* the server only ever holds the public half.
|
|
151
|
+
*/
|
|
152
|
+
var UserEncryptionKey = class extends Schema.Class("UserEncryptionKey")({
|
|
153
|
+
id: Id,
|
|
154
|
+
userId: Schema.NullOr(Id),
|
|
155
|
+
organizationId: Schema.NullOr(Id),
|
|
156
|
+
kind: EncryptionKeyKind,
|
|
157
|
+
publicKey: AgeRecipient,
|
|
158
|
+
label: Name120,
|
|
159
|
+
fingerprint: KeyFingerprint,
|
|
160
|
+
createdAt: DateTimeString,
|
|
161
|
+
lastUsedAt: Schema.NullOr(DateTimeString),
|
|
162
|
+
revokedAt: Schema.NullOr(DateTimeString)
|
|
163
|
+
}) {};
|
|
164
|
+
/** Register a new public recipient (device on first use, or a CI/recovery key). */
|
|
165
|
+
const RegisterEncryptionKeyBody = Schema.Struct({
|
|
166
|
+
kind: EncryptionKeyKind,
|
|
167
|
+
publicKey: AgeRecipient,
|
|
168
|
+
label: Name120,
|
|
169
|
+
fingerprint: KeyFingerprint
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region ../../packages/api/src/domain/account-key.ts
|
|
174
|
+
/**
|
|
175
|
+
* Argon2id cost parameters carried in an account-key escrow header. The browser
|
|
176
|
+
* re-derives the KEK with these to open the escrow, so they travel with the blob
|
|
177
|
+
* (an enrollment can use heavier params without a format change).
|
|
178
|
+
*/
|
|
179
|
+
const AccountKeyKdfParams = Schema.Struct({
|
|
180
|
+
time: Schema.Number.pipe(Schema.int(), Schema.positive()),
|
|
181
|
+
memory: Schema.Number.pipe(Schema.int(), Schema.positive()),
|
|
182
|
+
parallelism: Schema.Number.pipe(Schema.int(), Schema.positive())
|
|
183
|
+
});
|
|
184
|
+
/** A base64 Ed25519 public key — reserved for the deferred signed-roster integrity layer. */
|
|
185
|
+
const Ed25519PublicKey = Schema.String.pipe(Schema.minLength(1)).annotations({ description: "base64-encoded Ed25519 public key (reserved for signed-roster integrity)" });
|
|
186
|
+
/**
|
|
187
|
+
* A per-user account key's PUBLIC view (no escrow ciphertext). The age public key
|
|
188
|
+
* is the env-vault recipient the browser unwraps with; the escrow that holds the
|
|
189
|
+
* private halves is served only by the gated {@link AccountKeyEscrow} endpoint.
|
|
190
|
+
*/
|
|
191
|
+
var AccountKey = class extends Schema.Class("AccountKey")({
|
|
192
|
+
id: Id,
|
|
193
|
+
userId: Id,
|
|
194
|
+
agePublicKey: AgeRecipient,
|
|
195
|
+
ed25519PublicKey: Ed25519PublicKey,
|
|
196
|
+
fingerprint: KeyFingerprint,
|
|
197
|
+
createdAt: DateTimeString,
|
|
198
|
+
lastUsedAt: Schema.NullOr(DateTimeString),
|
|
199
|
+
revokedAt: Schema.NullOr(DateTimeString)
|
|
200
|
+
}) {};
|
|
201
|
+
/**
|
|
202
|
+
* The full passphrase-sealed escrow for the caller — everything the browser needs
|
|
203
|
+
* to open it locally ({@link AccountKeyKdfParams} + salt + ciphertext). The server
|
|
204
|
+
* stores it opaquely and can never open it. Served by the `getMe` endpoint, which
|
|
205
|
+
* is gated on `vaultAccess:read`; a 2FA step-up for browser sessions is REQUIRED
|
|
206
|
+
* before any web consumer ships but is not yet implemented (P4). The contents stay
|
|
207
|
+
* passphrase-sealed regardless. `version`/`kdf`/`cipher` are the fixed v1 envelope
|
|
208
|
+
* constants, echoed so the browser can rebuild the crypto envelope.
|
|
209
|
+
*/
|
|
210
|
+
var AccountKeyEscrow = class extends Schema.Class("AccountKeyEscrow")({
|
|
211
|
+
id: Id,
|
|
212
|
+
version: Schema.Literal(1),
|
|
213
|
+
agePublicKey: AgeRecipient,
|
|
214
|
+
ed25519PublicKey: Ed25519PublicKey,
|
|
215
|
+
fingerprint: KeyFingerprint,
|
|
216
|
+
kdf: Schema.Literal("argon2id"),
|
|
217
|
+
kdfParams: AccountKeyKdfParams,
|
|
218
|
+
salt: Schema.String.pipe(Schema.minLength(1)),
|
|
219
|
+
cipher: Schema.Literal("xchacha20poly1305"),
|
|
220
|
+
escrowCt: Schema.String.pipe(Schema.minLength(1)),
|
|
221
|
+
createdAt: DateTimeString
|
|
222
|
+
}) {};
|
|
223
|
+
/**
|
|
224
|
+
* Register the caller's account key. The CLI generates the keypair and seals the
|
|
225
|
+
* private halves under the passphrase BEFORE calling this — the server only ever
|
|
226
|
+
* receives the public keys + the opaque escrow ciphertext + its KDF header.
|
|
227
|
+
*/
|
|
228
|
+
const RegisterAccountKeyBody = Schema.Struct({
|
|
229
|
+
agePublicKey: AgeRecipient,
|
|
230
|
+
ed25519PublicKey: Ed25519PublicKey,
|
|
231
|
+
fingerprint: KeyFingerprint,
|
|
232
|
+
kdfParams: AccountKeyKdfParams,
|
|
233
|
+
salt: Schema.String.pipe(Schema.minLength(1)),
|
|
234
|
+
escrowCt: Schema.String.pipe(Schema.minLength(1))
|
|
235
|
+
});
|
|
236
|
+
/**
|
|
237
|
+
* The org's members' live account keys (public view). The env-vault cutover and
|
|
238
|
+
* rotation fetch it to enumerate the account-key recipients and resolve each
|
|
239
|
+
* `account_keys.id` to the `age` recipient the new env key is wrapped to.
|
|
240
|
+
*/
|
|
241
|
+
const AccountKeyList = Schema.Struct({ items: Schema.Array(AccountKey) });
|
|
242
|
+
/**
|
|
243
|
+
* Re-seal the caller's account-key escrow under a new passphrase (the CLI
|
|
244
|
+
* `passphrase change` flow). The keypair itself is unchanged — only the
|
|
245
|
+
* passphrase-derived seal — so every env-vault wrap to it stays valid.
|
|
246
|
+
*/
|
|
247
|
+
const ResealAccountKeyBody = Schema.Struct({
|
|
248
|
+
kdfParams: AccountKeyKdfParams,
|
|
249
|
+
salt: Schema.String.pipe(Schema.minLength(1)),
|
|
250
|
+
escrowCt: Schema.String.pipe(Schema.minLength(1))
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region ../../packages/api/src/domain/errors.ts
|
|
255
|
+
var BadRequest = class extends Schema.TaggedError()("BadRequest", { message: Schema.String }, HttpApiSchema.annotations({ status: 400 })) {};
|
|
256
|
+
var Conflict = class extends Schema.TaggedError()("Conflict", { message: Schema.String }, HttpApiSchema.annotations({ status: 409 })) {};
|
|
257
|
+
var NotAcceptable = class extends Schema.TaggedError()("NotAcceptable", { message: Schema.String }, HttpApiSchema.annotations({ status: 406 })) {};
|
|
258
|
+
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region ../../packages/api/src/groups/account-keys.ts
|
|
261
|
+
var AccountKeysGroup = class extends HttpApiGroup.make("accountKeys").add(HttpApiEndpoint.post("register", "/api/account-keys").setPayload(RegisterAccountKeyBody).addSuccess(AccountKey, { status: 201 }).annotateContext(OpenApi.annotations({
|
|
262
|
+
title: "Register account key",
|
|
263
|
+
description: "Register the caller's per-user account key — the env-vault recipient the browser unwraps with. The CLI seals the private halves under the passphrase first; the server stores the escrow opaquely."
|
|
264
|
+
}))).add(HttpApiEndpoint.get("list", "/api/account-keys").addSuccess(AccountKeyList).annotateContext(OpenApi.annotations({
|
|
265
|
+
title: "List org account keys",
|
|
266
|
+
description: "List the live account keys of the org's members (public view, admin) — the env-vault cutover/rotate uses it to enumerate the account-key recipients and resolve each id to its age recipient."
|
|
267
|
+
}))).add(HttpApiEndpoint.patch("reseal", "/api/account-keys/me").setPayload(ResealAccountKeyBody).addSuccess(AccountKey).annotateContext(OpenApi.annotations({
|
|
268
|
+
title: "Re-seal account-key escrow",
|
|
269
|
+
description: "Re-seal the caller's account-key escrow under a new passphrase (the CLI `passphrase change` flow). The keypair is unchanged, so every env-vault wrap to it stays valid."
|
|
270
|
+
}))).add(HttpApiEndpoint.get("getMe", "/api/account-keys/me").addSuccess(AccountKeyEscrow).annotateContext(OpenApi.annotations({
|
|
271
|
+
title: "Get my account-key escrow",
|
|
272
|
+
description: "Return the caller's passphrase-sealed account-key escrow for local unlock. The contents stay passphrase-sealed regardless of caller. Browser (cookie) callers must first complete a WebAuthn step-up via /api/web-vault/step-up; CLI bearer callers are exempt."
|
|
273
|
+
}))).addError(NotFound).addError(Conflict).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
|
|
274
|
+
title: "Account Keys",
|
|
275
|
+
description: "Per-user account keys for browser-side env-vault access"
|
|
276
|
+
})) {};
|
|
277
|
+
|
|
136
278
|
//#endregion
|
|
137
279
|
//#region ../../packages/api/src/domain/admin.ts
|
|
138
280
|
/**
|
|
@@ -270,12 +412,6 @@ var AndroidApplicationIdentifier = class extends Schema.Class("AndroidApplicatio
|
|
|
270
412
|
const CreateAndroidApplicationIdentifierBody = Schema.Struct({ packageName: AndroidPackageName });
|
|
271
413
|
const DeleteAndroidApplicationIdentifierResult = DeletedResult;
|
|
272
414
|
|
|
273
|
-
//#endregion
|
|
274
|
-
//#region ../../packages/api/src/domain/errors.ts
|
|
275
|
-
var BadRequest = class extends Schema.TaggedError()("BadRequest", { message: Schema.String }, HttpApiSchema.annotations({ status: 400 })) {};
|
|
276
|
-
var Conflict = class extends Schema.TaggedError()("Conflict", { message: Schema.String }, HttpApiSchema.annotations({ status: 409 })) {};
|
|
277
|
-
var NotAcceptable = class extends Schema.TaggedError()("NotAcceptable", { message: Schema.String }, HttpApiSchema.annotations({ status: 406 })) {};
|
|
278
|
-
|
|
279
415
|
//#endregion
|
|
280
416
|
//#region ../../packages/api/src/groups/android-application-identifiers.ts
|
|
281
417
|
const projectIdParam$6 = HttpApiSchema.param("projectId", Schema.String);
|
|
@@ -364,7 +500,17 @@ var OrgVault = class extends Schema.Class("OrgVault")({
|
|
|
364
500
|
*/
|
|
365
501
|
rotationPending: Schema.Boolean,
|
|
366
502
|
rotationPendingSince: Schema.NullOr(DateTimeString),
|
|
367
|
-
rotationPendingReason: Schema.NullOr(Schema.String)
|
|
503
|
+
rotationPendingReason: Schema.NullOr(Schema.String),
|
|
504
|
+
/**
|
|
505
|
+
* Env-vault (EV) state from the two-vault split. `envVaultCutoverAt` is `null`
|
|
506
|
+
* until the org forks its env values into a separate key; while `null`,
|
|
507
|
+
* `envVaultVersion` is unused and env stays part of the credentials vault.
|
|
508
|
+
*/
|
|
509
|
+
envVaultVersion: VaultVersion,
|
|
510
|
+
envRotationPending: Schema.Boolean,
|
|
511
|
+
envRotationPendingSince: Schema.NullOr(DateTimeString),
|
|
512
|
+
envRotationPendingReason: Schema.NullOr(Schema.String),
|
|
513
|
+
envVaultCutoverAt: Schema.NullOr(DateTimeString)
|
|
368
514
|
}) {};
|
|
369
515
|
/**
|
|
370
516
|
* One wrap of the org vault key to a recipient's public key — an `age` blob the
|
|
@@ -1650,10 +1796,18 @@ const EnvVarListScope = Schema.Literal("all", "project", "global");
|
|
|
1650
1796
|
* AAD `credentialId` when sealing; the envelope fields are the opaque ciphertext,
|
|
1651
1797
|
* wrapped DEK, and vault version. The server stores these and never decrypts —
|
|
1652
1798
|
* env var values are end-to-end encrypted, like credentials.
|
|
1799
|
+
*
|
|
1800
|
+
* `vaultKind` names which vault the DEK was sealed under. It is OPTIONAL for
|
|
1801
|
+
* back-compat: a pre-split CLI omits it (the server treats absence as
|
|
1802
|
+
* `"credentials"`). Once an org cuts over to a separate env vault, the server
|
|
1803
|
+
* requires `"env"` here — without it a credentials-keyed blob from an un-upgraded
|
|
1804
|
+
* (or racing) CLI would otherwise be silently stored into an env-vault row and be
|
|
1805
|
+
* permanently undecryptable. See `assertEnvVaultWriteAllowed`.
|
|
1653
1806
|
*/
|
|
1654
1807
|
const EnvVarValueEnvelope = Schema.Struct({
|
|
1655
1808
|
id: Id,
|
|
1656
|
-
...encryptedEnvelopeFields
|
|
1809
|
+
...encryptedEnvelopeFields,
|
|
1810
|
+
vaultKind: Schema.optional(Schema.Literal("credentials", "env"))
|
|
1657
1811
|
});
|
|
1658
1812
|
/**
|
|
1659
1813
|
* Env var metadata. The value is **not** here — it lives encrypted in the
|
|
@@ -1751,6 +1905,9 @@ var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoi
|
|
|
1751
1905
|
}))).add(HttpApiEndpoint.get("get")`/api/env-vars/${idParam}`.addSuccess(EnvVar).annotateContext(OpenApi.annotations({
|
|
1752
1906
|
title: "Get environment variable",
|
|
1753
1907
|
description: "Get an environment variable's metadata by ID (no value)"
|
|
1908
|
+
}))).add(HttpApiEndpoint.get("getValue")`/api/env-vars/${idParam}/value`.addSuccess(EnvVarValueEnvelope).annotateContext(OpenApi.annotations({
|
|
1909
|
+
title: "Get sealed env-var value",
|
|
1910
|
+
description: "Return the active value's sealed envelope (ciphertext, wrapped DEK, vault version) for client-side decryption in the browser env-vault. Browser (cookie) callers must first complete a WebAuthn step-up; CLI bearer callers use the bulk export instead."
|
|
1754
1911
|
}))).add(HttpApiEndpoint.patch("update")`/api/env-vars/${idParam}`.setPayload(UpdateEnvVarBody).addSuccess(EnvVar).annotateContext(OpenApi.annotations({
|
|
1755
1912
|
title: "Update environment variable",
|
|
1756
1913
|
description: "Change the value (a new sealed revision) and/or the visibility tier. The environment is immutable."
|
|
@@ -1777,6 +1934,118 @@ var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoi
|
|
|
1777
1934
|
description: "Manage end-to-end encrypted, versioned environment variables for project builds"
|
|
1778
1935
|
})) {};
|
|
1779
1936
|
|
|
1937
|
+
//#endregion
|
|
1938
|
+
//#region ../../packages/api/src/domain/env-vault.ts
|
|
1939
|
+
/**
|
|
1940
|
+
* An env-vault recipient kind. Superset of the credentials-vault recipient kinds
|
|
1941
|
+
* plus `account` — the per-user account key the browser unwraps the env vault
|
|
1942
|
+
* with. `recipientId` references `user_encryption_keys.id` for the first three and
|
|
1943
|
+
* `account_keys.id` for `account` (polymorphic; see migration 0071).
|
|
1944
|
+
*/
|
|
1945
|
+
const EnvVaultRecipientKind = Schema.Literal("device", "recovery", "machine", "account");
|
|
1946
|
+
/** One wrap of the env-vault key to a recipient (an opaque `age` blob). */
|
|
1947
|
+
var OrgEnvVaultKeyWrap = class extends Schema.Class("OrgEnvVaultKeyWrap")({
|
|
1948
|
+
organizationId: Id,
|
|
1949
|
+
envVaultVersion: VaultVersion,
|
|
1950
|
+
recipientKind: EnvVaultRecipientKind,
|
|
1951
|
+
recipientId: Id,
|
|
1952
|
+
wrappedKey: Schema.String,
|
|
1953
|
+
createdAt: DateTimeString
|
|
1954
|
+
}) {};
|
|
1955
|
+
/** The wrapped env-vault key for the calling recipient — fetched, then unwrapped client-side. */
|
|
1956
|
+
const RecipientEnvVaultKey = Schema.Struct({
|
|
1957
|
+
envVaultVersion: VaultVersion,
|
|
1958
|
+
wrappedKey: Schema.String
|
|
1959
|
+
});
|
|
1960
|
+
/** A recipient currently holding the env-vault key (kind + id + when wrapped). */
|
|
1961
|
+
const EnvVaultRecipientRef = Schema.Struct({
|
|
1962
|
+
recipientKind: EnvVaultRecipientKind,
|
|
1963
|
+
recipientId: Id,
|
|
1964
|
+
createdAt: DateTimeString
|
|
1965
|
+
});
|
|
1966
|
+
/** Every recipient holding the env-vault key at the current version. */
|
|
1967
|
+
const EnvVaultRecipients = Schema.Struct({
|
|
1968
|
+
envVaultVersion: VaultVersion,
|
|
1969
|
+
recipients: Schema.Array(EnvVaultRecipientRef)
|
|
1970
|
+
});
|
|
1971
|
+
/** One recipient's wrap row in a cutover / grant / rotate submission (age blob, base64). */
|
|
1972
|
+
const EnvVaultWrapInput = Schema.Struct({
|
|
1973
|
+
recipientKind: EnvVaultRecipientKind,
|
|
1974
|
+
recipientId: Id,
|
|
1975
|
+
wrappedKey: Schema.String.pipe(Schema.minLength(1))
|
|
1976
|
+
});
|
|
1977
|
+
/** Add a single env wrap at the current env version (grant or self-link). */
|
|
1978
|
+
const AddEnvVaultWrapBody = Schema.Struct({
|
|
1979
|
+
envVaultVersion: VaultVersion,
|
|
1980
|
+
wrap: EnvVaultWrapInput
|
|
1981
|
+
});
|
|
1982
|
+
/** One env-var revision's DEK re-wrapped under the env-vault key (cutover/rotation). */
|
|
1983
|
+
const EnvVaultDekUpdate = Schema.Struct({
|
|
1984
|
+
credentialId: Id,
|
|
1985
|
+
wrappedDek: WrappedDek
|
|
1986
|
+
});
|
|
1987
|
+
/** One env-var revision's currently-stored wrapped DEK + version (the rotation source). */
|
|
1988
|
+
const EnvVaultDekRef = Schema.Struct({
|
|
1989
|
+
credentialId: Id,
|
|
1990
|
+
wrappedDek: WrappedDek,
|
|
1991
|
+
vaultVersion: VaultVersion
|
|
1992
|
+
});
|
|
1993
|
+
/** Every wrapped env DEK + the current env-vault version (the rotation source set). */
|
|
1994
|
+
const EnvVaultCredentialDeks = Schema.Struct({
|
|
1995
|
+
envVaultVersion: VaultVersion,
|
|
1996
|
+
deks: Schema.Array(EnvVaultDekRef)
|
|
1997
|
+
});
|
|
1998
|
+
/**
|
|
1999
|
+
* The one-shot cutover: fork the org's env values into a separate env vault. The
|
|
2000
|
+
* client generates the env key, wraps it to every recipient (device/recovery/
|
|
2001
|
+
* machine + each member's account key), and re-keys every env DEK from the
|
|
2002
|
+
* credentials key to the env key. Must include an offline recovery recipient and
|
|
2003
|
+
* re-key every env-var revision.
|
|
2004
|
+
*/
|
|
2005
|
+
const CutoverEnvVaultBody = Schema.Struct({
|
|
2006
|
+
wraps: Schema.Array(EnvVaultWrapInput).pipe(Schema.minItems(1)),
|
|
2007
|
+
envDeks: Schema.Array(EnvVaultDekUpdate)
|
|
2008
|
+
});
|
|
2009
|
+
/**
|
|
2010
|
+
* Rotate (or revoke) the env-vault key. The client generates a new key at
|
|
2011
|
+
* `fromVersion + 1`, re-wraps every env DEK under it, and re-wraps the new key to
|
|
2012
|
+
* the surviving recipients. Applied atomically with compare-and-swap on
|
|
2013
|
+
* `fromVersion`; must re-key every env-var revision.
|
|
2014
|
+
*/
|
|
2015
|
+
const RotateEnvVaultBody = Schema.Struct({
|
|
2016
|
+
fromVersion: VaultVersion,
|
|
2017
|
+
wraps: Schema.Array(EnvVaultWrapInput).pipe(Schema.minItems(1)),
|
|
2018
|
+
envDeks: Schema.Array(EnvVaultDekUpdate)
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
//#endregion
|
|
2022
|
+
//#region ../../packages/api/src/groups/env-vault.ts
|
|
2023
|
+
/** `:recipientKind` / `:recipientId` path params for a polymorphic env recipient. */
|
|
2024
|
+
const recipientKindParam = HttpApiSchema.param("recipientKind", EnvVaultRecipientKind);
|
|
2025
|
+
const recipientIdParam = HttpApiSchema.param("recipientId", Id);
|
|
2026
|
+
var EnvVaultGroup = class extends HttpApiGroup.make("envVault").add(HttpApiEndpoint.post("cutover", "/api/env-vault/cutover").setPayload(CutoverEnvVaultBody).addSuccess(OrgVault).annotateContext(OpenApi.annotations({
|
|
2027
|
+
title: "Cut over to the env vault",
|
|
2028
|
+
description: "One-shot fork of the org's env values into a separate env vault: wrap the new env key to every recipient and re-key every env DEK in place. Idempotent (compare-and-swap on the cutover sentinel)."
|
|
2029
|
+
}))).add(HttpApiEndpoint.get("listWraps", "/api/env-vault/wraps").addSuccess(EnvVaultRecipients).annotateContext(OpenApi.annotations({
|
|
2030
|
+
title: "List env-vault recipients",
|
|
2031
|
+
description: "List the recipients holding the env-vault key at the current version"
|
|
2032
|
+
}))).add(HttpApiEndpoint.post("addWrap", "/api/env-vault/wraps").setPayload(AddEnvVaultWrapBody).addSuccess(OrgEnvVaultKeyWrap, { status: 201 }).annotateContext(OpenApi.annotations({
|
|
2033
|
+
title: "Add env-vault wrap",
|
|
2034
|
+
description: "Wrap the env-vault key to a recipient — granting a member's account key (admin) or self-linking your own device/account key"
|
|
2035
|
+
}))).add(HttpApiEndpoint.get("getWrap")`/api/env-vault/wraps/${recipientKindParam}/${recipientIdParam}`.addSuccess(RecipientEnvVaultKey).annotateContext(OpenApi.annotations({
|
|
2036
|
+
title: "Get env-vault wrap",
|
|
2037
|
+
description: "Fetch the wrapped env-vault key for a recipient to unwrap locally"
|
|
2038
|
+
}))).add(HttpApiEndpoint.get("listCredentialDeks", "/api/env-vault/credential-deks").addSuccess(EnvVaultCredentialDeks).annotateContext(OpenApi.annotations({
|
|
2039
|
+
title: "List wrapped env DEKs",
|
|
2040
|
+
description: "Every wrapped env DEK + the current env-vault version — fetched to re-wrap under a new key during a rotation"
|
|
2041
|
+
}))).add(HttpApiEndpoint.post("rotate", "/api/env-vault/rotate").setPayload(RotateEnvVaultBody).addSuccess(OrgVault).annotateContext(OpenApi.annotations({
|
|
2042
|
+
title: "Rotate env-vault key",
|
|
2043
|
+
description: "Revoke or rotate (admin): bump the env-vault version, re-wrap every env DEK, and re-wrap the new key to the surviving recipients — applied atomically with compare-and-swap"
|
|
2044
|
+
}))).addError(NotFound).addError(Conflict).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
|
|
2045
|
+
title: "Env Vault",
|
|
2046
|
+
description: "Manage the organization's separate end-to-end encrypted env-vault key wraps"
|
|
2047
|
+
})) {};
|
|
2048
|
+
|
|
1780
2049
|
//#endregion
|
|
1781
2050
|
//#region ../../packages/api/src/groups/environments.ts
|
|
1782
2051
|
/** `:name` path parameter — the environment name (built-in or user-defined). */
|
|
@@ -2670,42 +2939,6 @@ var UpdatesGroup = class extends HttpApiGroup.make("updates").add(HttpApiEndpoin
|
|
|
2670
2939
|
description: "Update publishing, deletion, republish, and per-update rollout endpoints"
|
|
2671
2940
|
})) {};
|
|
2672
2941
|
|
|
2673
|
-
//#endregion
|
|
2674
|
-
//#region ../../packages/api/src/domain/user-encryption-key.ts
|
|
2675
|
-
/**
|
|
2676
|
-
* A recipient key's role. A `device` key is user-owned (works across the user's
|
|
2677
|
-
* orgs); `recovery` (offline break-glass) and `machine` (CI) keys are org-owned
|
|
2678
|
-
* and have no `userId`.
|
|
2679
|
-
*/
|
|
2680
|
-
const EncryptionKeyKind = Schema.Literal("device", "recovery", "machine");
|
|
2681
|
-
/** An age recipient string (`age1...`) — a public key safe for the server to hold. */
|
|
2682
|
-
const AgeRecipient = Schema.String.pipe(Schema.minLength(1), Schema.startsWith("age1")).annotations({ description: "age recipient public key (age1...)" });
|
|
2683
|
-
/** An SSH-style key fingerprint (`SHA256:...`) shown for out-of-band verification. */
|
|
2684
|
-
const KeyFingerprint = Schema.String.pipe(Schema.startsWith("SHA256:"), Schema.minLength(8)).annotations({ description: "SSH-style key fingerprint (SHA256:...)" });
|
|
2685
|
-
/**
|
|
2686
|
-
* A registered public recipient. Private keys never leave the owner's machine;
|
|
2687
|
-
* the server only ever holds the public half.
|
|
2688
|
-
*/
|
|
2689
|
-
var UserEncryptionKey = class extends Schema.Class("UserEncryptionKey")({
|
|
2690
|
-
id: Id,
|
|
2691
|
-
userId: Schema.NullOr(Id),
|
|
2692
|
-
organizationId: Schema.NullOr(Id),
|
|
2693
|
-
kind: EncryptionKeyKind,
|
|
2694
|
-
publicKey: AgeRecipient,
|
|
2695
|
-
label: Name120,
|
|
2696
|
-
fingerprint: KeyFingerprint,
|
|
2697
|
-
createdAt: DateTimeString,
|
|
2698
|
-
lastUsedAt: Schema.NullOr(DateTimeString),
|
|
2699
|
-
revokedAt: Schema.NullOr(DateTimeString)
|
|
2700
|
-
}) {};
|
|
2701
|
-
/** Register a new public recipient (device on first use, or a CI/recovery key). */
|
|
2702
|
-
const RegisterEncryptionKeyBody = Schema.Struct({
|
|
2703
|
-
kind: EncryptionKeyKind,
|
|
2704
|
-
publicKey: AgeRecipient,
|
|
2705
|
-
label: Name120,
|
|
2706
|
-
fingerprint: KeyFingerprint
|
|
2707
|
-
});
|
|
2708
|
-
|
|
2709
2942
|
//#endregion
|
|
2710
2943
|
//#region ../../packages/api/src/groups/user-encryption-keys.ts
|
|
2711
2944
|
var UserEncryptionKeysGroup = class extends HttpApiGroup.make("userEncryptionKeys").add(HttpApiEndpoint.get("list", "/api/encryption-keys").addSuccess(Schema.Struct({ items: Schema.Array(UserEncryptionKey) })).annotateContext(OpenApi.annotations({
|
|
@@ -2719,6 +2952,44 @@ var UserEncryptionKeysGroup = class extends HttpApiGroup.make("userEncryptionKey
|
|
|
2719
2952
|
description: "Register and list end-to-end encryption recipient public keys"
|
|
2720
2953
|
})) {};
|
|
2721
2954
|
|
|
2955
|
+
//#endregion
|
|
2956
|
+
//#region ../../packages/api/src/domain/web-vault.ts
|
|
2957
|
+
/**
|
|
2958
|
+
* A WebAuthn authentication assertion, JSON-stringified by the browser
|
|
2959
|
+
* (`@better-auth/passkey/client` drives `navigator.credentials.get()`), carried
|
|
2960
|
+
* as a single string so the wire payload stays a plain object with no opaque
|
|
2961
|
+
* `unknown` fields. The step-up handler parses it and hands it to better-auth's
|
|
2962
|
+
* `verifyPasskeyAuthentication`; the exact inner shape is the plugin's contract,
|
|
2963
|
+
* not ours.
|
|
2964
|
+
*/
|
|
2965
|
+
const PasskeyStepUpBody = Schema.Struct({ assertionJson: Schema.String });
|
|
2966
|
+
/**
|
|
2967
|
+
* Result of a successful step-up: the ISO instant the server recorded, which the
|
|
2968
|
+
* env-vault gate now treats as the start of the step-up TTL window for this
|
|
2969
|
+
* session.
|
|
2970
|
+
*/
|
|
2971
|
+
const PasskeyStepUpResult = Schema.Struct({ verifiedAt: Schema.String });
|
|
2972
|
+
|
|
2973
|
+
//#endregion
|
|
2974
|
+
//#region ../../packages/api/src/groups/web-vault.ts
|
|
2975
|
+
/**
|
|
2976
|
+
* Web-vault step-up: the WebAuthn re-authentication a browser session performs
|
|
2977
|
+
* before it may read/write env values (the "2FA mandatory before web env access"
|
|
2978
|
+
* rule, spec §P4). The browser fetches a challenge from better-auth's
|
|
2979
|
+
* `generate-authenticate-options`, runs the passkey ceremony, then POSTs the
|
|
2980
|
+
* assertion here; the server verifies it via the passkey plugin and records a
|
|
2981
|
+
* fresh step-up for THIS session. The env-vault write gate
|
|
2982
|
+
* (assert-web-env-step-up) consults that record. CLI/CI (bearer) callers never
|
|
2983
|
+
* need this — they are exempt from the gate.
|
|
2984
|
+
*/
|
|
2985
|
+
var WebVaultGroup = class extends HttpApiGroup.make("webVault").add(HttpApiEndpoint.post("stepUp", "/api/web-vault/step-up").setPayload(PasskeyStepUpBody).addSuccess(PasskeyStepUpResult).annotateContext(OpenApi.annotations({
|
|
2986
|
+
title: "WebAuthn step-up",
|
|
2987
|
+
description: "Verify a fresh passkey assertion for the current browser session and record the step-up. Required before browser env-value reads/writes; cookie transport only."
|
|
2988
|
+
}))).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
|
|
2989
|
+
title: "Web Vault",
|
|
2990
|
+
description: "WebAuthn step-up for browser env-vault access"
|
|
2991
|
+
})) {};
|
|
2992
|
+
|
|
2722
2993
|
//#endregion
|
|
2723
2994
|
//#region ../../packages/api/src/domain/webhook.ts
|
|
2724
2995
|
const WebhookEventName = Schema.Literal("update.published", "build.completed");
|
|
@@ -2775,7 +3046,7 @@ var WebhooksGroup = class extends HttpApiGroup.make("webhooks").add(HttpApiEndpo
|
|
|
2775
3046
|
|
|
2776
3047
|
//#endregion
|
|
2777
3048
|
//#region ../../packages/api/src/api.ts
|
|
2778
|
-
var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(EnvironmentsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(RuntimesGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(ApplePushCertificatesGroup).add(ApplePayCertificatesGroup).add(ApplePassTypeCertificatesGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(MeGroup).add(WebhooksGroup).add(PoliciesGroup).add(GroupsGroup).add(PolicyAttachmentsGroup).add(ApiKeysGroup).add(InvitationsGroup).add(MembersGroup).add(OrganizationGroup).add(AdminGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
|
|
3049
|
+
var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(EnvironmentsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(RuntimesGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(ApplePushCertificatesGroup).add(ApplePayCertificatesGroup).add(ApplePassTypeCertificatesGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(AccountKeysGroup).add(EnvVaultGroup).add(WebVaultGroup).add(MeGroup).add(WebhooksGroup).add(PoliciesGroup).add(GroupsGroup).add(PolicyAttachmentsGroup).add(ApiKeysGroup).add(InvitationsGroup).add(MembersGroup).add(OrganizationGroup).add(AdminGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
|
|
2779
3050
|
title: "Better Update Management API",
|
|
2780
3051
|
version: "1.0.0",
|
|
2781
3052
|
description: "Management API for OTA update publishing, deployment, and analytics"
|
|
@@ -3731,8 +4002,16 @@ const VAULT_CACHE_TTL_MS = 900 * 1e3;
|
|
|
3731
4002
|
/** Bounds for a user-chosen TTL (`credentials unlock --duration`). */
|
|
3732
4003
|
const VAULT_CACHE_TTL_MIN_MS = 60 * 1e3;
|
|
3733
4004
|
const VAULT_CACHE_TTL_MAX_MS = 1440 * 60 * 1e3;
|
|
3734
|
-
/** Keychain service name; the account is the recipient's public key. */
|
|
4005
|
+
/** Keychain service name; the account is the recipient's public key (per {@link cacheAccount}). */
|
|
3735
4006
|
const KEYCHAIN_SERVICE = "better-update-vault";
|
|
4007
|
+
/**
|
|
4008
|
+
* The keychain account a recipient's cached key is stored under, namespaced by
|
|
4009
|
+
* vault kind so the credentials and env vaults cache independently. The
|
|
4010
|
+
* credentials vault keeps the bare public key — byte-identical to entries written
|
|
4011
|
+
* before the two-vault split, so an upgrade keeps any live unlock — while the env
|
|
4012
|
+
* vault is prefixed.
|
|
4013
|
+
*/
|
|
4014
|
+
const cacheAccount = (publicKey, vaultKind = "credentials") => vaultKind === "env" ? `env:${publicKey}` : publicKey;
|
|
3736
4015
|
const isCachedVaultEntry = (value) => isRecord$1(value) && typeof value["vaultKey"] === "string" && typeof value["vaultVersion"] === "number" && typeof value["keyId"] === "string" && typeof value["exp"] === "number";
|
|
3737
4016
|
/** Serialize an unlocked vault into a keychain blob, stamping a TTL from `now`. */
|
|
3738
4017
|
const encodeCacheEntry = (vault, now, ttlMs = VAULT_CACHE_TTL_MS) => JSON.stringify({
|
|
@@ -3765,28 +4044,30 @@ const VaultCacheLive = Layer.effect(VaultCache, Effect.gen(function* () {
|
|
|
3765
4044
|
const flag = yield* runtime.getEnv("BETTER_UPDATE_NO_CACHE");
|
|
3766
4045
|
return flag !== void 0 && flag.length > 0 && flag !== "0" && flag !== "false";
|
|
3767
4046
|
});
|
|
3768
|
-
const readRaw = (
|
|
3769
|
-
const writeRaw = (
|
|
3770
|
-
new Entry(KEYCHAIN_SERVICE,
|
|
4047
|
+
const readRaw = (account) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, account).getPassword()).pipe(Effect.orElseSucceed(() => null));
|
|
4048
|
+
const writeRaw = (account, blob) => Effect.try(() => {
|
|
4049
|
+
new Entry(KEYCHAIN_SERVICE, account).setPassword(blob);
|
|
3771
4050
|
}).pipe(Effect.ignore);
|
|
3772
|
-
const deleteRaw = (
|
|
4051
|
+
const deleteRaw = (account) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, account).deletePassword()).pipe(Effect.ignore);
|
|
3773
4052
|
return {
|
|
3774
|
-
get: (publicKey) => Effect.gen(function* () {
|
|
4053
|
+
get: (publicKey, vaultKind) => Effect.gen(function* () {
|
|
3775
4054
|
if (yield* cacheDisabled) return;
|
|
3776
|
-
const
|
|
4055
|
+
const account = cacheAccount(publicKey, vaultKind);
|
|
4056
|
+
const raw = yield* readRaw(account);
|
|
3777
4057
|
if (raw === null) return;
|
|
3778
4058
|
const decoded = decodeCacheEntry(raw, yield* Clock.currentTimeMillis);
|
|
3779
4059
|
if (decoded === void 0) {
|
|
3780
|
-
yield* deleteRaw(
|
|
4060
|
+
yield* deleteRaw(account);
|
|
3781
4061
|
return;
|
|
3782
4062
|
}
|
|
3783
4063
|
return decoded;
|
|
3784
4064
|
}),
|
|
3785
|
-
set: (publicKey, vault,
|
|
4065
|
+
set: (publicKey, vault, opts) => Effect.gen(function* () {
|
|
3786
4066
|
if (yield* cacheDisabled) return;
|
|
3787
|
-
|
|
4067
|
+
const now = yield* Clock.currentTimeMillis;
|
|
4068
|
+
yield* writeRaw(cacheAccount(publicKey, opts?.vaultKind), encodeCacheEntry(vault, now, opts?.ttlMs));
|
|
3788
4069
|
}),
|
|
3789
|
-
clear: (publicKey) => deleteRaw(publicKey)
|
|
4070
|
+
clear: (publicKey, vaultKind) => deleteRaw(cacheAccount(publicKey, vaultKind))
|
|
3790
4071
|
};
|
|
3791
4072
|
}));
|
|
3792
4073
|
|
|
@@ -5764,7 +6045,7 @@ const listCommand$9 = defineCommand({
|
|
|
5764
6045
|
]), "No branches found.");
|
|
5765
6046
|
}))
|
|
5766
6047
|
});
|
|
5767
|
-
const createCommand$
|
|
6048
|
+
const createCommand$6 = defineCommand({
|
|
5768
6049
|
meta: {
|
|
5769
6050
|
name: "create",
|
|
5770
6051
|
description: "Create a branch"
|
|
@@ -5872,7 +6153,7 @@ const branchesCommand = defineCommand({
|
|
|
5872
6153
|
subCommands: {
|
|
5873
6154
|
list: listCommand$9,
|
|
5874
6155
|
view: viewCommand$3,
|
|
5875
|
-
create: createCommand$
|
|
6156
|
+
create: createCommand$6,
|
|
5876
6157
|
rename: renameCommand$2,
|
|
5877
6158
|
delete: deleteCommand$7
|
|
5878
6159
|
}
|
|
@@ -9007,7 +9288,7 @@ const sha512 = /* @__PURE__ */ createHasher$1(() => new _SHA512(), /* @__PURE__
|
|
|
9007
9288
|
//#endregion
|
|
9008
9289
|
//#region ../../packages/credentials-crypto/src/aead.ts
|
|
9009
9290
|
const aead = managedNonce(xchacha20poly1305);
|
|
9010
|
-
const textEncoder$
|
|
9291
|
+
const textEncoder$3 = new TextEncoder();
|
|
9011
9292
|
const KEY_BYTES$1 = 32;
|
|
9012
9293
|
const LENGTH_PREFIX_BYTES = 4;
|
|
9013
9294
|
/** A fresh 32-byte symmetric key — an org vault key or a per-credential DEK. */
|
|
@@ -9029,7 +9310,7 @@ const aeadDecrypt = (key, sealed, aad) => aead(key, aad).decrypt(sealed);
|
|
|
9029
9310
|
* ever encode to the same bytes (prevents cross-binding confusion).
|
|
9030
9311
|
*/
|
|
9031
9312
|
const encodeAad = (domain, parts) => {
|
|
9032
|
-
const segments = [domain, ...parts.map(String)].map((text) => textEncoder$
|
|
9313
|
+
const segments = [domain, ...parts.map(String)].map((text) => textEncoder$3.encode(text));
|
|
9033
9314
|
const size = segments.reduce((total, seg) => total + LENGTH_PREFIX_BYTES + seg.length, 0);
|
|
9034
9315
|
const out = new Uint8Array(size);
|
|
9035
9316
|
const view = new DataView(out.buffer);
|
|
@@ -9041,7 +9322,7 @@ const encodeAad = (domain, parts) => {
|
|
|
9041
9322
|
return out;
|
|
9042
9323
|
};
|
|
9043
9324
|
/** SSH-style fingerprint of an age recipient string: `SHA256:<base64, no padding>`. */
|
|
9044
|
-
const fingerprint = (recipient) => `SHA256:${toBase64(sha256$1(textEncoder$
|
|
9325
|
+
const fingerprint = (recipient) => `SHA256:${toBase64(sha256$1(textEncoder$3.encode(recipient))).replace(/=+$/u, "")}`;
|
|
9045
9326
|
|
|
9046
9327
|
//#endregion
|
|
9047
9328
|
//#region ../../node_modules/.bun/@noble+hashes@2.2.0/node_modules/@noble/hashes/_blake.js
|
|
@@ -17823,6 +18104,191 @@ var PrimeEdwardsPoint = class {
|
|
|
17823
18104
|
return this;
|
|
17824
18105
|
}
|
|
17825
18106
|
};
|
|
18107
|
+
/**
|
|
18108
|
+
* Initializes EdDSA signatures over given Edwards curve.
|
|
18109
|
+
* @param Point - Edwards point constructor.
|
|
18110
|
+
* @param cHash - Hash function.
|
|
18111
|
+
* @param eddsaOpts - Optional signature helpers. See {@link EdDSAOpts}.
|
|
18112
|
+
* @returns EdDSA helper namespace.
|
|
18113
|
+
* @throws If the hash function, options, or derived point operations are invalid. {@link Error}
|
|
18114
|
+
* @example
|
|
18115
|
+
* Initializes EdDSA signatures over given Edwards curve.
|
|
18116
|
+
*
|
|
18117
|
+
* ```ts
|
|
18118
|
+
* import { eddsa } from '@noble/curves/abstract/edwards.js';
|
|
18119
|
+
* import { jubjub } from '@noble/curves/misc.js';
|
|
18120
|
+
* import { sha512 } from '@noble/hashes/sha2.js';
|
|
18121
|
+
* const sigs = eddsa(jubjub.Point, sha512);
|
|
18122
|
+
* const { secretKey, publicKey } = sigs.keygen();
|
|
18123
|
+
* const msg = new TextEncoder().encode('hello noble');
|
|
18124
|
+
* const sig = sigs.sign(msg, secretKey);
|
|
18125
|
+
* const isValid = sigs.verify(sig, msg, publicKey);
|
|
18126
|
+
* ```
|
|
18127
|
+
*/
|
|
18128
|
+
function eddsa(Point, cHash, eddsaOpts = {}) {
|
|
18129
|
+
if (typeof cHash !== "function") throw new Error("\"hash\" function param is required");
|
|
18130
|
+
const hash = cHash;
|
|
18131
|
+
const opts = eddsaOpts;
|
|
18132
|
+
validateObject(opts, {}, {
|
|
18133
|
+
adjustScalarBytes: "function",
|
|
18134
|
+
randomBytes: "function",
|
|
18135
|
+
domain: "function",
|
|
18136
|
+
prehash: "function",
|
|
18137
|
+
zip215: "boolean",
|
|
18138
|
+
mapToCurve: "function"
|
|
18139
|
+
});
|
|
18140
|
+
const { prehash } = opts;
|
|
18141
|
+
const { BASE, Fp, Fn } = Point;
|
|
18142
|
+
const outputLen = hash.outputLen;
|
|
18143
|
+
const expectedLen = 2 * Fp.BYTES;
|
|
18144
|
+
if (outputLen !== void 0) {
|
|
18145
|
+
asafenumber(outputLen, "hash.outputLen");
|
|
18146
|
+
if (outputLen !== expectedLen) throw new Error(`hash.outputLen must be ${expectedLen}, got ${outputLen}`);
|
|
18147
|
+
}
|
|
18148
|
+
const randomBytes = opts.randomBytes === void 0 ? randomBytes$1 : opts.randomBytes;
|
|
18149
|
+
const adjustScalarBytes = opts.adjustScalarBytes === void 0 ? (bytes) => bytes : opts.adjustScalarBytes;
|
|
18150
|
+
const domain = opts.domain === void 0 ? (data, ctx, phflag) => {
|
|
18151
|
+
abool(phflag, "phflag");
|
|
18152
|
+
if (ctx.length || phflag) throw new Error("Contexts/pre-hash are not supported");
|
|
18153
|
+
return data;
|
|
18154
|
+
} : opts.domain;
|
|
18155
|
+
function modN_LE(hash) {
|
|
18156
|
+
return Fn.create(bytesToNumberLE(hash));
|
|
18157
|
+
}
|
|
18158
|
+
function getPrivateScalar(key) {
|
|
18159
|
+
const len = lengths.secretKey;
|
|
18160
|
+
abytes(key, lengths.secretKey, "secretKey");
|
|
18161
|
+
const hashed = abytes(hash(key), 2 * len, "hashedSecretKey");
|
|
18162
|
+
const head = adjustScalarBytes(hashed.slice(0, len));
|
|
18163
|
+
return {
|
|
18164
|
+
head,
|
|
18165
|
+
prefix: hashed.slice(len, 2 * len),
|
|
18166
|
+
scalar: modN_LE(head)
|
|
18167
|
+
};
|
|
18168
|
+
}
|
|
18169
|
+
/** Convenience method that creates public key from scalar. RFC8032 5.1.5
|
|
18170
|
+
* Also exposes the derived scalar/prefix tuple and point form reused by sign().
|
|
18171
|
+
*/
|
|
18172
|
+
function getExtendedPublicKey(secretKey) {
|
|
18173
|
+
const { head, prefix, scalar } = getPrivateScalar(secretKey);
|
|
18174
|
+
const point = BASE.multiply(scalar);
|
|
18175
|
+
return {
|
|
18176
|
+
head,
|
|
18177
|
+
prefix,
|
|
18178
|
+
scalar,
|
|
18179
|
+
point,
|
|
18180
|
+
pointBytes: point.toBytes()
|
|
18181
|
+
};
|
|
18182
|
+
}
|
|
18183
|
+
/** Calculates EdDSA pub key. RFC8032 5.1.5. */
|
|
18184
|
+
function getPublicKey(secretKey) {
|
|
18185
|
+
return getExtendedPublicKey(secretKey).pointBytes;
|
|
18186
|
+
}
|
|
18187
|
+
function hashDomainToScalar(context = Uint8Array.of(), ...msgs) {
|
|
18188
|
+
return modN_LE(hash(domain(concatBytes(...msgs), abytes(context, void 0, "context"), !!prehash)));
|
|
18189
|
+
}
|
|
18190
|
+
/** Signs message with secret key. RFC8032 5.1.6 */
|
|
18191
|
+
function sign(msg, secretKey, options = {}) {
|
|
18192
|
+
msg = abytes(msg, void 0, "message");
|
|
18193
|
+
if (prehash) msg = prehash(msg);
|
|
18194
|
+
const { prefix, scalar, pointBytes } = getExtendedPublicKey(secretKey);
|
|
18195
|
+
const r = hashDomainToScalar(options.context, prefix, msg);
|
|
18196
|
+
const R = BASE.multiply(r).toBytes();
|
|
18197
|
+
const k = hashDomainToScalar(options.context, R, pointBytes, msg);
|
|
18198
|
+
const s = Fn.create(r + k * scalar);
|
|
18199
|
+
if (!Fn.isValid(s)) throw new Error("sign failed: invalid s");
|
|
18200
|
+
return abytes(concatBytes(R, Fn.toBytes(s)), lengths.signature, "result");
|
|
18201
|
+
}
|
|
18202
|
+
const verifyOpts = { zip215: opts.zip215 };
|
|
18203
|
+
/**
|
|
18204
|
+
* Verifies EdDSA signature against message and public key. RFC 8032 §§5.1.7 and 5.2.7.
|
|
18205
|
+
* A cofactored verification equation is checked.
|
|
18206
|
+
*/
|
|
18207
|
+
function verify(sig, msg, publicKey, options = verifyOpts) {
|
|
18208
|
+
const { context } = options;
|
|
18209
|
+
const zip215 = options.zip215 === void 0 ? !!verifyOpts.zip215 : options.zip215;
|
|
18210
|
+
const len = lengths.signature;
|
|
18211
|
+
sig = abytes(sig, len, "signature");
|
|
18212
|
+
msg = abytes(msg, void 0, "message");
|
|
18213
|
+
publicKey = abytes(publicKey, lengths.publicKey, "publicKey");
|
|
18214
|
+
if (zip215 !== void 0) abool(zip215, "zip215");
|
|
18215
|
+
if (prehash) msg = prehash(msg);
|
|
18216
|
+
const mid = len / 2;
|
|
18217
|
+
const r = sig.subarray(0, mid);
|
|
18218
|
+
const s = bytesToNumberLE(sig.subarray(mid, len));
|
|
18219
|
+
let A, R, SB;
|
|
18220
|
+
try {
|
|
18221
|
+
A = Point.fromBytes(publicKey, zip215);
|
|
18222
|
+
R = Point.fromBytes(r, zip215);
|
|
18223
|
+
SB = BASE.multiplyUnsafe(s);
|
|
18224
|
+
} catch (error) {
|
|
18225
|
+
return false;
|
|
18226
|
+
}
|
|
18227
|
+
if (!zip215 && A.isSmallOrder()) return false;
|
|
18228
|
+
const k = hashDomainToScalar(context, r, publicKey, msg);
|
|
18229
|
+
return R.add(A.multiplyUnsafe(k)).subtract(SB).clearCofactor().is0();
|
|
18230
|
+
}
|
|
18231
|
+
const _size = Fp.BYTES;
|
|
18232
|
+
const lengths = {
|
|
18233
|
+
secretKey: _size,
|
|
18234
|
+
publicKey: _size,
|
|
18235
|
+
signature: 2 * _size,
|
|
18236
|
+
seed: _size
|
|
18237
|
+
};
|
|
18238
|
+
function randomSecretKey(seed) {
|
|
18239
|
+
seed = seed === void 0 ? randomBytes(lengths.seed) : seed;
|
|
18240
|
+
return abytes(seed, lengths.seed, "seed");
|
|
18241
|
+
}
|
|
18242
|
+
function isValidSecretKey(key) {
|
|
18243
|
+
return isBytes(key) && key.length === lengths.secretKey;
|
|
18244
|
+
}
|
|
18245
|
+
function isValidPublicKey(key, zip215) {
|
|
18246
|
+
try {
|
|
18247
|
+
return !!Point.fromBytes(key, zip215 === void 0 ? verifyOpts.zip215 : zip215);
|
|
18248
|
+
} catch (error) {
|
|
18249
|
+
return false;
|
|
18250
|
+
}
|
|
18251
|
+
}
|
|
18252
|
+
const utils = {
|
|
18253
|
+
getExtendedPublicKey,
|
|
18254
|
+
randomSecretKey,
|
|
18255
|
+
isValidSecretKey,
|
|
18256
|
+
isValidPublicKey,
|
|
18257
|
+
/**
|
|
18258
|
+
* Converts ed public key to x public key. Uses formula:
|
|
18259
|
+
* - ed25519:
|
|
18260
|
+
* - `(u, v) = ((1+y)/(1-y), sqrt(-486664)*u/x)`
|
|
18261
|
+
* - `(x, y) = (sqrt(-486664)*u/v, (u-1)/(u+1))`
|
|
18262
|
+
* - ed448:
|
|
18263
|
+
* - `(u, v) = ((y-1)/(y+1), sqrt(156324)*u/x)`
|
|
18264
|
+
* - `(x, y) = (sqrt(156324)*u/v, (1+u)/(1-u))`
|
|
18265
|
+
*/
|
|
18266
|
+
toMontgomery(publicKey) {
|
|
18267
|
+
const { y } = Point.fromBytes(publicKey);
|
|
18268
|
+
const size = lengths.publicKey;
|
|
18269
|
+
const is25519 = size === 32;
|
|
18270
|
+
if (!is25519 && size !== 57) throw new Error("only defined for 25519 and 448");
|
|
18271
|
+
const u = is25519 ? Fp.div(_1n$2 + y, _1n$2 - y) : Fp.div(y - _1n$2, y + _1n$2);
|
|
18272
|
+
return Fp.toBytes(u);
|
|
18273
|
+
},
|
|
18274
|
+
toMontgomerySecret(secretKey) {
|
|
18275
|
+
const size = lengths.secretKey;
|
|
18276
|
+
abytes(secretKey, size);
|
|
18277
|
+
return adjustScalarBytes(hash(secretKey.subarray(0, size))).subarray(0, size);
|
|
18278
|
+
}
|
|
18279
|
+
};
|
|
18280
|
+
Object.freeze(lengths);
|
|
18281
|
+
Object.freeze(utils);
|
|
18282
|
+
return Object.freeze({
|
|
18283
|
+
keygen: createKeygen(randomSecretKey, getPublicKey),
|
|
18284
|
+
getPublicKey,
|
|
18285
|
+
sign,
|
|
18286
|
+
verify,
|
|
18287
|
+
utils,
|
|
18288
|
+
Point,
|
|
18289
|
+
lengths
|
|
18290
|
+
});
|
|
18291
|
+
}
|
|
17826
18292
|
|
|
17827
18293
|
//#endregion
|
|
17828
18294
|
//#region ../../node_modules/.bun/@noble+curves@2.2.0/node_modules/@noble/curves/abstract/montgomery.js
|
|
@@ -18033,6 +18499,31 @@ function uvRatio(u, v) {
|
|
|
18033
18499
|
const ed25519_Point = /* @__PURE__ */ edwards(ed25519_CURVE, { uvRatio });
|
|
18034
18500
|
const Fp = /* @__PURE__ */ (() => ed25519_Point.Fp)();
|
|
18035
18501
|
const Fn = /* @__PURE__ */ (() => ed25519_Point.Fn)();
|
|
18502
|
+
function ed(opts) {
|
|
18503
|
+
return eddsa(ed25519_Point, sha512, Object.assign({
|
|
18504
|
+
adjustScalarBytes,
|
|
18505
|
+
zip215: true
|
|
18506
|
+
}, opts));
|
|
18507
|
+
}
|
|
18508
|
+
/**
|
|
18509
|
+
* ed25519 curve with EdDSA signatures.
|
|
18510
|
+
* Seeded `keygen(seed)` / `utils.randomSecretKey(seed)` reuse the provided
|
|
18511
|
+
* 32-byte seed buffer instead of copying it.
|
|
18512
|
+
* @example
|
|
18513
|
+
* Generate one Ed25519 keypair, sign a message, and verify it.
|
|
18514
|
+
*
|
|
18515
|
+
* ```js
|
|
18516
|
+
* import { ed25519 } from '@noble/curves/ed25519.js';
|
|
18517
|
+
* const { secretKey, publicKey } = ed25519.keygen();
|
|
18518
|
+
* // const publicKey = ed25519.getPublicKey(secretKey);
|
|
18519
|
+
* const msg = new TextEncoder().encode('hello noble');
|
|
18520
|
+
* const sig = ed25519.sign(msg, secretKey);
|
|
18521
|
+
* const isValid = ed25519.verify(sig, msg, publicKey); // ZIP215
|
|
18522
|
+
* // RFC8032 / FIPS 186-5
|
|
18523
|
+
* const isValid2 = ed25519.verify(sig, msg, publicKey, { zip215: false });
|
|
18524
|
+
* ```
|
|
18525
|
+
*/
|
|
18526
|
+
const ed25519 = /* @__PURE__ */ ed({});
|
|
18036
18527
|
/**
|
|
18037
18528
|
* ECDH using curve25519 aka x25519.
|
|
18038
18529
|
* `getSharedSecret()` rejects low-order peer inputs by default, and seeded
|
|
@@ -19113,8 +19604,9 @@ function isCryptoKey(key) {
|
|
|
19113
19604
|
|
|
19114
19605
|
//#endregion
|
|
19115
19606
|
//#region ../../packages/credentials-crypto/src/identity.ts
|
|
19116
|
-
const textEncoder$
|
|
19117
|
-
const textDecoder$
|
|
19607
|
+
const textEncoder$2 = new TextEncoder();
|
|
19608
|
+
const textDecoder$2 = new TextDecoder();
|
|
19609
|
+
/** Salt length for the passphrase KDF (shared by the identity + account-key seals). */
|
|
19118
19610
|
const SALT_BYTES = 16;
|
|
19119
19611
|
const KEY_BYTES = 32;
|
|
19120
19612
|
/** OWASP-recommended Argon2id defaults (~64 MiB), tuned for ~250–500 ms. */
|
|
@@ -19135,6 +19627,7 @@ const generateIdentity = async () => {
|
|
|
19135
19627
|
};
|
|
19136
19628
|
/** Derive the age recipient (`age1...`) from an identity private key. */
|
|
19137
19629
|
const deriveRecipient = async (privateKey) => identityToRecipient(privateKey);
|
|
19630
|
+
/** Argon2id(passphrase, salt) → 32-byte KEK. Shared by the identity + account-key seals. */
|
|
19138
19631
|
const deriveKek = (passphrase, salt, params) => {
|
|
19139
19632
|
return argon2id(passphrase, salt, {
|
|
19140
19633
|
t: params.time,
|
|
@@ -19155,8 +19648,8 @@ const sealIdentity = async (args) => {
|
|
|
19155
19648
|
const publicKey = await identityToRecipient(args.privateKey);
|
|
19156
19649
|
const kdfParams = args.kdfParams ?? DEFAULT_ARGON2_PARAMS;
|
|
19157
19650
|
const fp = fingerprint(publicKey);
|
|
19158
|
-
const salt = randomBytes$5(
|
|
19159
|
-
const ct = aeadEncrypt(deriveKek(args.passphrase, salt, kdfParams), textEncoder$
|
|
19651
|
+
const salt = randomBytes$5(16);
|
|
19652
|
+
const ct = aeadEncrypt(deriveKek(args.passphrase, salt, kdfParams), textEncoder$2.encode(args.privateKey), sealAad({
|
|
19160
19653
|
publicKey,
|
|
19161
19654
|
fingerprint: fp,
|
|
19162
19655
|
kdfParams
|
|
@@ -19181,7 +19674,7 @@ const sealIdentity = async (args) => {
|
|
|
19181
19674
|
const openIdentity = async (args) => {
|
|
19182
19675
|
const { file } = args;
|
|
19183
19676
|
const kek = deriveKek(args.passphrase, fromBase64(file.salt), file.kdfParams);
|
|
19184
|
-
const privateKey = textDecoder$
|
|
19677
|
+
const privateKey = textDecoder$2.decode(aeadDecrypt(kek, fromBase64(file.ct), sealAad(file)));
|
|
19185
19678
|
const publicKey = await identityToRecipient(privateKey);
|
|
19186
19679
|
return {
|
|
19187
19680
|
privateKey,
|
|
@@ -19211,7 +19704,12 @@ const unwrapVaultKey = async (args) => {
|
|
|
19211
19704
|
decrypter.addIdentity(args.privateKey);
|
|
19212
19705
|
return decrypter.decrypt(args.wrapped);
|
|
19213
19706
|
};
|
|
19214
|
-
const dekAad = (binding) => encodeAad("better-update/dek", [
|
|
19707
|
+
const dekAad = (binding) => binding.vaultKind === "env" ? encodeAad("better-update/dek", [
|
|
19708
|
+
binding.orgId,
|
|
19709
|
+
binding.credentialId,
|
|
19710
|
+
binding.vaultVersion,
|
|
19711
|
+
"env"
|
|
19712
|
+
]) : encodeAad("better-update/dek", [
|
|
19215
19713
|
binding.orgId,
|
|
19216
19714
|
binding.credentialId,
|
|
19217
19715
|
binding.vaultVersion
|
|
@@ -19227,8 +19725,8 @@ const unwrapDek = (args) => aeadDecrypt(args.vaultKey, args.wrappedDek, dekAad(a
|
|
|
19227
19725
|
|
|
19228
19726
|
//#endregion
|
|
19229
19727
|
//#region ../../packages/credentials-crypto/src/credential.ts
|
|
19230
|
-
const textEncoder = new TextEncoder();
|
|
19231
|
-
const textDecoder = new TextDecoder();
|
|
19728
|
+
const textEncoder$1 = new TextEncoder();
|
|
19729
|
+
const textDecoder$1 = new TextDecoder();
|
|
19232
19730
|
const blobAad = (binding) => encodeAad("better-update/credential", [
|
|
19233
19731
|
binding.orgId,
|
|
19234
19732
|
binding.credentialId,
|
|
@@ -19236,7 +19734,7 @@ const blobAad = (binding) => encodeAad("better-update/credential", [
|
|
|
19236
19734
|
binding.schemaVersion
|
|
19237
19735
|
]);
|
|
19238
19736
|
/** Encrypt a credential payload with its DEK, binding its identity as AAD. */
|
|
19239
|
-
const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder.encode(JSON.stringify(args.payload)), blobAad(args.payload));
|
|
19737
|
+
const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder$1.encode(JSON.stringify(args.payload)), blobAad(args.payload));
|
|
19240
19738
|
/**
|
|
19241
19739
|
* Decrypt a credential blob with its DEK. The AAD binds the blob to `expect`, so
|
|
19242
19740
|
* a blob for another (org, credential, type, schema) fails the tag instead of
|
|
@@ -19246,7 +19744,99 @@ const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder.encode(JSON.s
|
|
|
19246
19744
|
*/
|
|
19247
19745
|
const openCredential = (args) => {
|
|
19248
19746
|
const plaintext = aeadDecrypt(args.dek, args.ciphertext, blobAad(args.expect));
|
|
19249
|
-
return JSON.parse(textDecoder.decode(plaintext));
|
|
19747
|
+
return JSON.parse(textDecoder$1.decode(plaintext));
|
|
19748
|
+
};
|
|
19749
|
+
|
|
19750
|
+
//#endregion
|
|
19751
|
+
//#region ../../packages/credentials-crypto/src/account-key.ts
|
|
19752
|
+
const textEncoder = new TextEncoder();
|
|
19753
|
+
const textDecoder = new TextDecoder();
|
|
19754
|
+
/**
|
|
19755
|
+
* Argon2id cost for the ACCOUNT escrow. Deliberately heavier than the on-disk
|
|
19756
|
+
* identity default (`DEFAULT_ARGON2_PARAMS`, ~64 MiB): the escrow blob is stored
|
|
19757
|
+
* server-side, so it is more exposed than a local `identity.json` and warrants a
|
|
19758
|
+
* costlier KDF. ~128 MiB. The params live in the envelope, so an enrollment can be
|
|
19759
|
+
* re-tuned without a format change — validate pure-JS Argon2id perf in the browser
|
|
19760
|
+
* before raising this. See docs/specs/build/11-two-vault-split-and-web-env-crud.md §3.2.
|
|
19761
|
+
*/
|
|
19762
|
+
const ACCOUNT_ARGON2_PARAMS = {
|
|
19763
|
+
time: 3,
|
|
19764
|
+
memory: 131072,
|
|
19765
|
+
parallelism: 1
|
|
19766
|
+
};
|
|
19767
|
+
/** Narrow a decrypted escrow payload to {@link SealedAccountSecret} without an unsafe cast. */
|
|
19768
|
+
const isSealedAccountSecret = (value) => typeof value === "object" && value !== null && "agePrivateKey" in value && typeof value.agePrivateKey === "string" && "ed25519PrivateKey" in value && typeof value.ed25519PrivateKey === "string";
|
|
19769
|
+
/** Generate a fresh account keypair: an age X25519 key plus an Ed25519 signing key. */
|
|
19770
|
+
const generateAccountKey = async () => {
|
|
19771
|
+
const agePrivateKey = await generateIdentity$1();
|
|
19772
|
+
const agePublicKey = await identityToRecipient(agePrivateKey);
|
|
19773
|
+
const ed = ed25519.keygen();
|
|
19774
|
+
return {
|
|
19775
|
+
agePrivateKey,
|
|
19776
|
+
agePublicKey,
|
|
19777
|
+
ed25519PrivateKey: toBase64(ed.secretKey),
|
|
19778
|
+
ed25519PublicKey: toBase64(ed.publicKey),
|
|
19779
|
+
fingerprint: fingerprint(agePublicKey)
|
|
19780
|
+
};
|
|
19781
|
+
};
|
|
19782
|
+
const escrowAad = (header) => encodeAad("better-update/account-key", [
|
|
19783
|
+
header.agePublicKey,
|
|
19784
|
+
header.ed25519PublicKey,
|
|
19785
|
+
header.fingerprint,
|
|
19786
|
+
header.kdfParams.time,
|
|
19787
|
+
header.kdfParams.memory,
|
|
19788
|
+
header.kdfParams.parallelism
|
|
19789
|
+
]);
|
|
19790
|
+
/** Seal an account keypair into its escrow envelope with a passphrase. */
|
|
19791
|
+
const sealAccountKey = (args) => {
|
|
19792
|
+
const kdfParams = args.kdfParams ?? ACCOUNT_ARGON2_PARAMS;
|
|
19793
|
+
const salt = randomBytes$5(16);
|
|
19794
|
+
const kek = deriveKek(args.passphrase, salt, kdfParams);
|
|
19795
|
+
const header = {
|
|
19796
|
+
agePublicKey: args.material.agePublicKey,
|
|
19797
|
+
ed25519PublicKey: args.material.ed25519PublicKey,
|
|
19798
|
+
fingerprint: args.material.fingerprint,
|
|
19799
|
+
kdfParams
|
|
19800
|
+
};
|
|
19801
|
+
const secret = {
|
|
19802
|
+
agePrivateKey: args.material.agePrivateKey,
|
|
19803
|
+
ed25519PrivateKey: args.material.ed25519PrivateKey
|
|
19804
|
+
};
|
|
19805
|
+
const ct = aeadEncrypt(kek, textEncoder.encode(JSON.stringify(secret)), escrowAad(header));
|
|
19806
|
+
return {
|
|
19807
|
+
version: 1,
|
|
19808
|
+
agePublicKey: header.agePublicKey,
|
|
19809
|
+
ed25519PublicKey: header.ed25519PublicKey,
|
|
19810
|
+
fingerprint: header.fingerprint,
|
|
19811
|
+
kdf: "argon2id",
|
|
19812
|
+
kdfParams,
|
|
19813
|
+
salt: toBase64(salt),
|
|
19814
|
+
cipher: "xchacha20poly1305",
|
|
19815
|
+
ct: toBase64(ct)
|
|
19816
|
+
};
|
|
19817
|
+
};
|
|
19818
|
+
/**
|
|
19819
|
+
* Open an account escrow envelope. Throws (propagated AEAD failure) on a wrong
|
|
19820
|
+
* passphrase or a tampered envelope — the seal binds both public keys, the
|
|
19821
|
+
* fingerprint, and the KDF params as AAD. The returned public halves are
|
|
19822
|
+
* **re-derived from the decrypted private keys**, so they always match the keys
|
|
19823
|
+
* they unlock (mirrors `openIdentity`).
|
|
19824
|
+
*/
|
|
19825
|
+
const openAccountKey = async (args) => {
|
|
19826
|
+
const { envelope } = args;
|
|
19827
|
+
const plaintext = aeadDecrypt(deriveKek(args.passphrase, fromBase64(envelope.salt), envelope.kdfParams), fromBase64(envelope.ct), escrowAad(envelope));
|
|
19828
|
+
const parsed = JSON.parse(textDecoder.decode(plaintext));
|
|
19829
|
+
if (!isSealedAccountSecret(parsed)) throw new Error("Account escrow payload has an unexpected shape.");
|
|
19830
|
+
const secret = parsed;
|
|
19831
|
+
const agePublicKey = await identityToRecipient(secret.agePrivateKey);
|
|
19832
|
+
const ed25519PublicKey = toBase64(ed25519.getPublicKey(fromBase64(secret.ed25519PrivateKey)));
|
|
19833
|
+
return {
|
|
19834
|
+
agePrivateKey: secret.agePrivateKey,
|
|
19835
|
+
agePublicKey,
|
|
19836
|
+
ed25519PrivateKey: secret.ed25519PrivateKey,
|
|
19837
|
+
ed25519PublicKey,
|
|
19838
|
+
fingerprint: fingerprint(agePublicKey)
|
|
19839
|
+
};
|
|
19250
19840
|
};
|
|
19251
19841
|
|
|
19252
19842
|
//#endregion
|
|
@@ -19423,7 +20013,7 @@ const unlockVaultKeyInteractive = (api, options) => Effect.gen(function* () {
|
|
|
19423
20013
|
const cached = yield* cache.get(recipient.publicKey);
|
|
19424
20014
|
if (cached !== void 0) return cached.vault;
|
|
19425
20015
|
const vault = yield* unlockVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
|
|
19426
|
-
yield* cache.set(recipient.publicKey, vault, options?.cacheTtlMs);
|
|
20016
|
+
yield* cache.set(recipient.publicKey, vault, { ttlMs: options?.cacheTtlMs });
|
|
19427
20017
|
return vault;
|
|
19428
20018
|
}).pipe(Effect.provide(VaultCacheLive));
|
|
19429
20019
|
/**
|
|
@@ -19484,7 +20074,8 @@ const getActiveOrgId = (api) => Effect.gen(function* () {
|
|
|
19484
20074
|
const openVaultSessionInteractive = (api) => Effect.gen(function* () {
|
|
19485
20075
|
return {
|
|
19486
20076
|
orgId: yield* getActiveOrgId(api),
|
|
19487
|
-
vault: yield* unlockVaultKeyInteractive(api)
|
|
20077
|
+
vault: yield* unlockVaultKeyInteractive(api),
|
|
20078
|
+
vaultKind: "credentials"
|
|
19488
20079
|
};
|
|
19489
20080
|
});
|
|
19490
20081
|
/** Reshape a sealed envelope into the `{ id, …opaque fields }` an upload body carries. */
|
|
@@ -19500,7 +20091,7 @@ const toUploadEnvelope = (envelope) => ({
|
|
|
19500
20091
|
* `(org, credentialId, type, schemaVersion)` so the server cannot mix envelopes.
|
|
19501
20092
|
*/
|
|
19502
20093
|
const sealForUpload = (args) => Effect.gen(function* () {
|
|
19503
|
-
const { orgId, vault } = args.session;
|
|
20094
|
+
const { orgId, vault, vaultKind } = args.session;
|
|
19504
20095
|
const credentialId = crypto.randomUUID();
|
|
19505
20096
|
const dek = generateDek();
|
|
19506
20097
|
const payload = {
|
|
@@ -19523,7 +20114,8 @@ const sealForUpload = (args) => Effect.gen(function* () {
|
|
|
19523
20114
|
binding: {
|
|
19524
20115
|
orgId,
|
|
19525
20116
|
credentialId,
|
|
19526
|
-
vaultVersion: vault.vaultVersion
|
|
20117
|
+
vaultVersion: vault.vaultVersion,
|
|
20118
|
+
vaultKind
|
|
19527
20119
|
}
|
|
19528
20120
|
})
|
|
19529
20121
|
}),
|
|
@@ -19542,7 +20134,7 @@ const sealForUpload = (args) => Effect.gen(function* () {
|
|
|
19542
20134
|
* also serves the build-resolve flow whose result carries the id out-of-band.
|
|
19543
20135
|
*/
|
|
19544
20136
|
const openEnvelope = (args) => Effect.gen(function* () {
|
|
19545
|
-
const { orgId, vault } = args.session;
|
|
20137
|
+
const { orgId, vault, vaultKind } = args.session;
|
|
19546
20138
|
const dek = yield* Effect.try({
|
|
19547
20139
|
try: () => unwrapDek({
|
|
19548
20140
|
wrappedDek: fromBase64(args.envelope.wrappedDek),
|
|
@@ -19550,7 +20142,8 @@ const openEnvelope = (args) => Effect.gen(function* () {
|
|
|
19550
20142
|
binding: {
|
|
19551
20143
|
orgId,
|
|
19552
20144
|
credentialId: args.credentialId,
|
|
19553
|
-
vaultVersion: args.envelope.vaultVersion
|
|
20145
|
+
vaultVersion: args.envelope.vaultVersion,
|
|
20146
|
+
vaultKind
|
|
19554
20147
|
}
|
|
19555
20148
|
}),
|
|
19556
20149
|
catch: () => new IdentityError({ message: "Could not unwrap this credential's key — vault access may have been rotated or revoked." })
|
|
@@ -19602,6 +20195,84 @@ const openFromDownload = (args) => {
|
|
|
19602
20195
|
});
|
|
19603
20196
|
};
|
|
19604
20197
|
|
|
20198
|
+
//#endregion
|
|
20199
|
+
//#region src/application/env-vault-access.ts
|
|
20200
|
+
/**
|
|
20201
|
+
* Guidance when this device holds no env-vault wrap. Post-cutover the env vault is
|
|
20202
|
+
* a SEPARATE key from the credentials vault, so even a device that can read
|
|
20203
|
+
* credentials may not be an env recipient yet — it self-links by enrolling an
|
|
20204
|
+
* account key, or an admin re-runs the env-vault migration / rotation to include
|
|
20205
|
+
* it.
|
|
20206
|
+
*/
|
|
20207
|
+
const ENV_VAULT_NOT_RECIPIENT_GUIDANCE = "This device isn't an env-vault recipient. Run `better-update credentials account create` to enroll, or ask an admin to re-run `better-update credentials env-vault rotate` to include it.";
|
|
20208
|
+
/**
|
|
20209
|
+
* Unlock the ENV-vault key for this device via its env wrap (post-cutover). The
|
|
20210
|
+
* env vault holds a DIFFERENT key from the credentials vault — wrapped to the same
|
|
20211
|
+
* device/recovery/machine recipients PLUS per-user account keys — so this mirrors
|
|
20212
|
+
* {@link unlockVaultKey} but reads the polymorphic `org_env_vault_key_wraps` row
|
|
20213
|
+
* keyed by this device's `(recipientKind, keyId)`.
|
|
20214
|
+
*/
|
|
20215
|
+
const unlockEnvVaultKey = (api, passphrase) => Effect.gen(function* () {
|
|
20216
|
+
const recipient = yield* activeRecipient;
|
|
20217
|
+
const privateKey = yield* unlockActivePrivateKey(passphrase);
|
|
20218
|
+
const { items } = yield* api.userEncryptionKeys.list();
|
|
20219
|
+
const own = items.find((key) => key.publicKey === recipient.publicKey);
|
|
20220
|
+
if (!own) return yield* new IdentityError({ message: "This device's encryption key is not registered. Run `better-update credentials identity register` first." });
|
|
20221
|
+
const wrap = yield* api.envVault.getWrap({ path: {
|
|
20222
|
+
recipientKind: recipientKind(recipient.source),
|
|
20223
|
+
recipientId: own.id
|
|
20224
|
+
} }).pipe(Effect.catchTag("NotFound", () => new IdentityError({ message: ENV_VAULT_NOT_RECIPIENT_GUIDANCE })));
|
|
20225
|
+
return {
|
|
20226
|
+
vaultKey: yield* Effect.tryPromise({
|
|
20227
|
+
try: async () => unwrapVaultKey({
|
|
20228
|
+
wrapped: fromBase64(wrap.wrappedKey),
|
|
20229
|
+
privateKey
|
|
20230
|
+
}),
|
|
20231
|
+
catch: () => new IdentityError({ message: "This device could not unwrap the env-vault key — its env access may have been revoked or rotated. Re-enroll or ask an admin to re-grant access." })
|
|
20232
|
+
}),
|
|
20233
|
+
vaultVersion: wrap.envVaultVersion,
|
|
20234
|
+
keyId: own.id
|
|
20235
|
+
};
|
|
20236
|
+
});
|
|
20237
|
+
/**
|
|
20238
|
+
* Cache-aware env-vault unlock, mirroring {@link unlockVaultKeyInteractive} but on
|
|
20239
|
+
* the `"env"` cache namespace so the credentials and env vaults cache (and lock)
|
|
20240
|
+
* independently. CI's `BETTER_UPDATE_IDENTITY` key is never cached.
|
|
20241
|
+
*/
|
|
20242
|
+
const unlockEnvVaultKeyInteractive = (api) => Effect.gen(function* () {
|
|
20243
|
+
const recipient = yield* activeRecipient;
|
|
20244
|
+
if (recipient.source !== "file") return yield* unlockEnvVaultKey(api, void 0);
|
|
20245
|
+
const cache = yield* VaultCache;
|
|
20246
|
+
const cached = yield* cache.get(recipient.publicKey, "env");
|
|
20247
|
+
if (cached !== void 0) return cached.vault;
|
|
20248
|
+
const vault = yield* unlockEnvVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
|
|
20249
|
+
yield* cache.set(recipient.publicKey, vault, { vaultKind: "env" });
|
|
20250
|
+
return vault;
|
|
20251
|
+
}).pipe(Effect.provide(VaultCacheLive));
|
|
20252
|
+
/** Forget this device's cached env-vault key — called after an env rotation re-keys it. */
|
|
20253
|
+
const forgetCachedEnvVaultKey = Effect.gen(function* () {
|
|
20254
|
+
const recipient = yield* activeRecipient;
|
|
20255
|
+
yield* (yield* VaultCache).clear(recipient.publicKey, "env");
|
|
20256
|
+
}).pipe(Effect.provide(VaultCacheLive));
|
|
20257
|
+
/**
|
|
20258
|
+
* Resolve the vault session env VALUES are sealed under, branched on the org's
|
|
20259
|
+
* cutover state. Pre-cutover (or no vault yet) env lives in the CREDENTIALS vault —
|
|
20260
|
+
* `openVaultSessionInteractive` returns a `"credentials"`-kind session, byte-for-
|
|
20261
|
+
* byte the pre-split behaviour. Once the org has cut over, env lives in its own
|
|
20262
|
+
* vault: unlock the env key and return an `"env"`-kind session so seal/open bind
|
|
20263
|
+
* the DEK to the env vault. Every env command (`set/get/pull/push/export/import`)
|
|
20264
|
+
* goes through this, so the cutover is transparent to them.
|
|
20265
|
+
*/
|
|
20266
|
+
const openEnvVaultSessionInteractive = (api) => Effect.gen(function* () {
|
|
20267
|
+
const vault = yield* api.orgVault.get().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
|
|
20268
|
+
if ((vault === null ? null : vault.envVaultCutoverAt) === null) return yield* openVaultSessionInteractive(api);
|
|
20269
|
+
return {
|
|
20270
|
+
orgId: yield* getActiveOrgId(api),
|
|
20271
|
+
vault: yield* unlockEnvVaultKeyInteractive(api),
|
|
20272
|
+
vaultKind: "env"
|
|
20273
|
+
};
|
|
20274
|
+
});
|
|
20275
|
+
|
|
19605
20276
|
//#endregion
|
|
19606
20277
|
//#region src/lib/credential-secret.ts
|
|
19607
20278
|
/**
|
|
@@ -19643,7 +20314,7 @@ const exportDecryptedEnvVars = (api, projectId, environment) => Effect.gen(funct
|
|
|
19643
20314
|
environment
|
|
19644
20315
|
} }).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Failed to export environment variables for "${environment}": ${String(cause)}` })));
|
|
19645
20316
|
if (result.items.length === 0) return [];
|
|
19646
|
-
return yield* decryptEnvVars(yield*
|
|
20317
|
+
return yield* decryptEnvVars(yield* openEnvVaultSessionInteractive(api).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Could not unlock the credential vault to decrypt environment variables: ${cause.message}` }))), result.items);
|
|
19647
20318
|
});
|
|
19648
20319
|
/**
|
|
19649
20320
|
* Pull + decrypt environment variables flattened into a key/value map for
|
|
@@ -19662,7 +20333,7 @@ const pullEnvVars = (api, { projectId, environment }) => Effect.gen(function* ()
|
|
|
19662
20333
|
* vault once; the server stores only the sealed envelopes (never the plaintext).
|
|
19663
20334
|
*/
|
|
19664
20335
|
const uploadEnvVars = (api, params) => Effect.gen(function* () {
|
|
19665
|
-
const session = yield*
|
|
20336
|
+
const session = yield* openEnvVaultSessionInteractive(api);
|
|
19666
20337
|
const pairs = params.environments.flatMap((environment) => params.entries.map((entry) => ({
|
|
19667
20338
|
...entry,
|
|
19668
20339
|
environment
|
|
@@ -19683,7 +20354,8 @@ const uploadEnvVars = (api, params) => Effect.gen(function* () {
|
|
|
19683
20354
|
id: envelope.id,
|
|
19684
20355
|
ciphertext: envelope.ciphertext,
|
|
19685
20356
|
wrappedDek: envelope.wrappedDek,
|
|
19686
|
-
vaultVersion: envelope.vaultVersion
|
|
20357
|
+
vaultVersion: envelope.vaultVersion,
|
|
20358
|
+
vaultKind: session.vaultKind
|
|
19687
20359
|
}
|
|
19688
20360
|
})), { concurrency: 8 });
|
|
19689
20361
|
return yield* api["env-vars"].bulkImport({ payload: {
|
|
@@ -21850,6 +22522,17 @@ const parseCert = (certDerBytes) => {
|
|
|
21850
22522
|
return forge.pki.certificateFromAsn1(asn1);
|
|
21851
22523
|
};
|
|
21852
22524
|
const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
|
|
22525
|
+
/**
|
|
22526
|
+
* Normalize an Apple certificate serial number for comparison.
|
|
22527
|
+
*
|
|
22528
|
+
* The serial read from a certificate's X.509 DER (via node-forge) keeps any
|
|
22529
|
+
* leading zero byte (e.g. `02C4E489…`), whereas the App Store Connect API
|
|
22530
|
+
* reports the same serial with leading zeros stripped (e.g. `2C4E489…`).
|
|
22531
|
+
* Comparing the two representations verbatim makes a present certificate look
|
|
22532
|
+
* absent ("not present on Apple Developer Portal"), so normalize both sides:
|
|
22533
|
+
* uppercase and drop leading zeros.
|
|
22534
|
+
*/
|
|
22535
|
+
const normalizeAppleSerial = (serial) => serial.toUpperCase().replace(/^0+/u, "");
|
|
21853
22536
|
const extractCertMetadata = (cert) => Effect.gen(function* () {
|
|
21854
22537
|
const appleTeamId = extractTeamId$1(cert);
|
|
21855
22538
|
if (appleTeamId === null) return yield* new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" });
|
|
@@ -22089,9 +22772,9 @@ const revokeLocalDistributionCertificate = (api, input) => Effect.gen(function*
|
|
|
22089
22772
|
issuerId: creds.issuerId,
|
|
22090
22773
|
p8Pem: creds.p8Pem
|
|
22091
22774
|
};
|
|
22092
|
-
const targetSerial = local.serialNumber
|
|
22775
|
+
const targetSerial = normalizeAppleSerial(local.serialNumber);
|
|
22093
22776
|
const matching = yield* Effect.all([listCertificates(ascCreds, { certificateType: "IOS_DISTRIBUTION" }), listCertificates(ascCreds, { certificateType: "IOS_DEVELOPMENT" })], { concurrency: 2 }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
|
|
22094
|
-
const ascMatch = [...matching[0], ...matching[1]].find((entry) => entry.serialNumber
|
|
22777
|
+
const ascMatch = [...matching[0], ...matching[1]].find((entry) => normalizeAppleSerial(entry.serialNumber) === targetSerial);
|
|
22095
22778
|
let revokedOnApple = false;
|
|
22096
22779
|
if (ascMatch !== void 0) {
|
|
22097
22780
|
yield* deleteCertificate(ascCreds, ascMatch.id).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
|
|
@@ -22118,7 +22801,9 @@ const listAppleCertificates = (api, input) => Effect.gen(function* () {
|
|
|
22118
22801
|
}, compact({ certificateType: input.certificateType })).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
|
|
22119
22802
|
});
|
|
22120
22803
|
const resolveCertAscId = (creds, serialNumber, certificateType) => Effect.gen(function* () {
|
|
22121
|
-
const
|
|
22804
|
+
const certs = yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
|
|
22805
|
+
const target = normalizeAppleSerial(serialNumber);
|
|
22806
|
+
const match = certs.find((entry) => normalizeAppleSerial(entry.serialNumber) === target);
|
|
22122
22807
|
if (match === void 0) return yield* new GenerateFailedError({
|
|
22123
22808
|
step: "match-apple-certificate",
|
|
22124
22809
|
message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
|
|
@@ -22682,6 +23367,7 @@ const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePli
|
|
|
22682
23367
|
|
|
22683
23368
|
//#endregion
|
|
22684
23369
|
//#region src/lib/update-channel-native.ts
|
|
23370
|
+
const { AndroidConfig } = configPlugins;
|
|
22685
23371
|
/**
|
|
22686
23372
|
* EAS parity: after `expo prebuild`, bake the build profile's `channel` into
|
|
22687
23373
|
* the generated native projects as the `expo-channel-name` request header —
|
|
@@ -23093,8 +23779,8 @@ const findOrCreateBundleId = (ctx, bundleIdentifier) => Effect.gen(function* ()
|
|
|
23093
23779
|
});
|
|
23094
23780
|
const findAscCertificateId = (ctx, serialNumber, certificateType) => Effect.gen(function* () {
|
|
23095
23781
|
const certs = yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType } } }));
|
|
23096
|
-
const
|
|
23097
|
-
const match = certs.find((entry) => entry.attributes.serialNumber
|
|
23782
|
+
const target = normalizeAppleSerial(serialNumber);
|
|
23783
|
+
const match = certs.find((entry) => normalizeAppleSerial(entry.attributes.serialNumber) === target);
|
|
23098
23784
|
if (match === void 0) return yield* new AppleIdGenerateFailedError({
|
|
23099
23785
|
step: "match-apple-certificate",
|
|
23100
23786
|
message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
|
|
@@ -26172,7 +26858,7 @@ const resolveNamedResourceId$1 = (params) => resolveNamedResourceId$2(params, (m
|
|
|
26172
26858
|
|
|
26173
26859
|
//#endregion
|
|
26174
26860
|
//#region src/commands/channels/create.ts
|
|
26175
|
-
const createCommand$
|
|
26861
|
+
const createCommand$5 = defineCommand({
|
|
26176
26862
|
meta: {
|
|
26177
26863
|
name: "create",
|
|
26178
26864
|
description: "Create a channel"
|
|
@@ -26390,7 +27076,7 @@ const completeCommand$1 = defineCommand({
|
|
|
26390
27076
|
|
|
26391
27077
|
//#endregion
|
|
26392
27078
|
//#region src/commands/channels/rollout/create.ts
|
|
26393
|
-
const createCommand$
|
|
27079
|
+
const createCommand$4 = defineCommand({
|
|
26394
27080
|
meta: {
|
|
26395
27081
|
name: "create",
|
|
26396
27082
|
description: "Start a branch rollout on a channel"
|
|
@@ -26510,7 +27196,7 @@ const rolloutCommand$1 = defineCommand({
|
|
|
26510
27196
|
description: "Manage channel branch rollouts"
|
|
26511
27197
|
},
|
|
26512
27198
|
subCommands: {
|
|
26513
|
-
create: createCommand$
|
|
27199
|
+
create: createCommand$4,
|
|
26514
27200
|
update: updateCommand$3,
|
|
26515
27201
|
complete: completeCommand$1,
|
|
26516
27202
|
revert: revertCommand$2
|
|
@@ -26624,7 +27310,7 @@ const channelsCommand = defineCommand({
|
|
|
26624
27310
|
subCommands: {
|
|
26625
27311
|
list: listCommand$7,
|
|
26626
27312
|
view: viewCommand$2,
|
|
26627
|
-
create: createCommand$
|
|
27313
|
+
create: createCommand$5,
|
|
26628
27314
|
update: updateCommand$2,
|
|
26629
27315
|
pause: pauseCommand,
|
|
26630
27316
|
resume: resumeCommand,
|
|
@@ -28223,7 +28909,8 @@ const rotateVaultTo = (args) => Effect.gen(function* () {
|
|
|
28223
28909
|
binding: {
|
|
28224
28910
|
orgId,
|
|
28225
28911
|
credentialId: dek.credentialId,
|
|
28226
|
-
vaultVersion: dek.vaultVersion
|
|
28912
|
+
vaultVersion: dek.vaultVersion,
|
|
28913
|
+
vaultKind: "credentials"
|
|
28227
28914
|
}
|
|
28228
28915
|
});
|
|
28229
28916
|
return {
|
|
@@ -28235,7 +28922,8 @@ const rotateVaultTo = (args) => Effect.gen(function* () {
|
|
|
28235
28922
|
binding: {
|
|
28236
28923
|
orgId,
|
|
28237
28924
|
credentialId: dek.credentialId,
|
|
28238
|
-
vaultVersion: newVersion
|
|
28925
|
+
vaultVersion: newVersion,
|
|
28926
|
+
vaultKind: "credentials"
|
|
28239
28927
|
}
|
|
28240
28928
|
}))
|
|
28241
28929
|
};
|
|
@@ -28381,7 +29069,7 @@ const grantCommand = defineCommand({
|
|
|
28381
29069
|
}), { json: "value" })
|
|
28382
29070
|
});
|
|
28383
29071
|
const confirmRecipients = (recipients, skip) => Effect.forEach(recipients, (recipient) => confirmFingerprint(recipient, skip), { discard: true });
|
|
28384
|
-
const rotateCommand = defineCommand({
|
|
29072
|
+
const rotateCommand$1 = defineCommand({
|
|
28385
29073
|
meta: {
|
|
28386
29074
|
name: "rotate",
|
|
28387
29075
|
description: "Rotate the vault key, re-wrapping every credential to the same recipients (admin)"
|
|
@@ -28541,7 +29229,7 @@ const accessCommand = defineCommand({
|
|
|
28541
29229
|
subCommands: {
|
|
28542
29230
|
list: listCommand$6,
|
|
28543
29231
|
grant: grantCommand,
|
|
28544
|
-
rotate: rotateCommand,
|
|
29232
|
+
rotate: rotateCommand$1,
|
|
28545
29233
|
revoke: revokeCommand$1,
|
|
28546
29234
|
recover: recoverCommand,
|
|
28547
29235
|
recovery: recoveryCommand
|
|
@@ -28549,6 +29237,251 @@ const accessCommand = defineCommand({
|
|
|
28549
29237
|
default: "list"
|
|
28550
29238
|
});
|
|
28551
29239
|
|
|
29240
|
+
//#endregion
|
|
29241
|
+
//#region src/application/passphrase-change.ts
|
|
29242
|
+
/** Rebuild the {@link AccountKeyEnvelope} from the server escrow view (escrowCt → ct). */
|
|
29243
|
+
const escrowToEnvelope = (escrow) => ({
|
|
29244
|
+
version: escrow.version,
|
|
29245
|
+
agePublicKey: escrow.agePublicKey,
|
|
29246
|
+
ed25519PublicKey: escrow.ed25519PublicKey,
|
|
29247
|
+
fingerprint: escrow.fingerprint,
|
|
29248
|
+
kdf: escrow.kdf,
|
|
29249
|
+
kdfParams: escrow.kdfParams,
|
|
29250
|
+
salt: escrow.salt,
|
|
29251
|
+
cipher: escrow.cipher,
|
|
29252
|
+
ct: escrow.escrowCt
|
|
29253
|
+
});
|
|
29254
|
+
/**
|
|
29255
|
+
* Re-seal the caller's account-key escrow under `newPassphrase`. BEST-EFFORT and
|
|
29256
|
+
* total: it never fails the surrounding flow — it reports an {@link
|
|
29257
|
+
* AccountResealOutcome} so the caller can warn. This matters because the account
|
|
29258
|
+
* escrow is a single per-USER secret shared across every device, while device
|
|
29259
|
+
* identities are per-device: another device may already have moved the escrow to a
|
|
29260
|
+
* different passphrase, so opening it with THIS device's old passphrase can
|
|
29261
|
+
* legitimately fail (`passphrase-mismatch`) without blocking the device change.
|
|
29262
|
+
*/
|
|
29263
|
+
const resealAccountKey = (api, oldPassphrase, newPassphrase) => Effect.gen(function* () {
|
|
29264
|
+
const escrowResult = yield* api.accountKeys.getMe().pipe(Effect.either);
|
|
29265
|
+
if (Either.isLeft(escrowResult)) return escrowResult.left._tag === "NotFound" ? "absent" : "error";
|
|
29266
|
+
const materialResult = yield* Effect.tryPromise(async () => openAccountKey({
|
|
29267
|
+
envelope: escrowToEnvelope(escrowResult.right),
|
|
29268
|
+
passphrase: oldPassphrase
|
|
29269
|
+
})).pipe(Effect.either);
|
|
29270
|
+
if (Either.isLeft(materialResult)) return "passphrase-mismatch";
|
|
29271
|
+
const next = sealAccountKey({
|
|
29272
|
+
material: materialResult.right,
|
|
29273
|
+
passphrase: newPassphrase
|
|
29274
|
+
});
|
|
29275
|
+
const resealResult = yield* api.accountKeys.reseal({ payload: {
|
|
29276
|
+
kdfParams: next.kdfParams,
|
|
29277
|
+
salt: next.salt,
|
|
29278
|
+
escrowCt: next.ct
|
|
29279
|
+
} }).pipe(Effect.either);
|
|
29280
|
+
return Either.isLeft(resealResult) ? "error" : "resealed";
|
|
29281
|
+
});
|
|
29282
|
+
/**
|
|
29283
|
+
* Change the device passphrase. The local device identity is the PRIMARY, and is
|
|
29284
|
+
* saved FIRST: it is purely local and authoritative for this device, so on success
|
|
29285
|
+
* the device is on the new passphrase regardless of what happens to the shared
|
|
29286
|
+
* account escrow. Ordering is deliberate — if the local save fails, nothing was
|
|
29287
|
+
* mutated (the server escrow is untouched); the account escrow re-seal then runs
|
|
29288
|
+
* best-effort and its {@link AccountResealOutcome} is returned for the caller to
|
|
29289
|
+
* surface, so a network/passphrase issue degrades to a warning, never a hard block
|
|
29290
|
+
* or a silent split. The vault keys are unchanged, so cached unlocks and every
|
|
29291
|
+
* wrap stay valid.
|
|
29292
|
+
*/
|
|
29293
|
+
const changePassphrase = (api, params) => Effect.gen(function* () {
|
|
29294
|
+
const store = yield* IdentityStore;
|
|
29295
|
+
const file = yield* loadIdentityFileOrFail;
|
|
29296
|
+
const identity = yield* Effect.tryPromise({
|
|
29297
|
+
try: async () => openIdentity({
|
|
29298
|
+
file,
|
|
29299
|
+
passphrase: params.oldPassphrase
|
|
29300
|
+
}),
|
|
29301
|
+
catch: () => new IdentityError({ message: "Wrong current passphrase — could not unlock this device's identity." })
|
|
29302
|
+
});
|
|
29303
|
+
const nextFile = yield* Effect.promise(async () => sealIdentity({
|
|
29304
|
+
privateKey: identity.privateKey,
|
|
29305
|
+
passphrase: params.newPassphrase
|
|
29306
|
+
}));
|
|
29307
|
+
yield* store.save(nextFile);
|
|
29308
|
+
return { account: yield* resealAccountKey(api, params.oldPassphrase, params.newPassphrase) };
|
|
29309
|
+
});
|
|
29310
|
+
|
|
29311
|
+
//#endregion
|
|
29312
|
+
//#region src/commands/credentials/account.ts
|
|
29313
|
+
/** `true` once the org has cut over to its separate env vault. */
|
|
29314
|
+
const orgHasCutOver = (api) => api.orgVault.get().pipe(Effect.map((vault) => vault.envVaultCutoverAt !== null), Effect.catchTag("NotFound", () => Effect.succeed(false)));
|
|
29315
|
+
/** The caller's live account key (public escrow view), or `null` if not enrolled. */
|
|
29316
|
+
const findOwnAccountKey = (api) => api.accountKeys.getMe().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
|
|
29317
|
+
/**
|
|
29318
|
+
* Wrap the env-vault key to the caller's OWN account key (self-link). Unlocks the
|
|
29319
|
+
* env vault via this device's env wrap, so it works for any member whose device is
|
|
29320
|
+
* already an env recipient — the account key inherits env access without an admin.
|
|
29321
|
+
* Requires the org to have cut over (there is no env vault before that).
|
|
29322
|
+
*/
|
|
29323
|
+
const linkAccountKeyToEnv = (api, params) => Effect.gen(function* () {
|
|
29324
|
+
const ev = yield* unlockEnvVaultKeyInteractive(api);
|
|
29325
|
+
const wrapped = yield* Effect.promise(async () => wrapVaultKey({
|
|
29326
|
+
vaultKey: ev.vaultKey,
|
|
29327
|
+
recipient: params.agePublicKey
|
|
29328
|
+
}));
|
|
29329
|
+
yield* api.envVault.addWrap({ payload: {
|
|
29330
|
+
envVaultVersion: ev.vaultVersion,
|
|
29331
|
+
wrap: {
|
|
29332
|
+
recipientKind: "account",
|
|
29333
|
+
recipientId: params.accountKeyId,
|
|
29334
|
+
wrappedKey: toBase64(wrapped)
|
|
29335
|
+
}
|
|
29336
|
+
} });
|
|
29337
|
+
});
|
|
29338
|
+
/**
|
|
29339
|
+
* Prompt for — and verify — the device passphrase, so the account escrow is sealed
|
|
29340
|
+
* under the SAME passphrase as the device identity (the "one passphrase" promise:
|
|
29341
|
+
* a later `passphrase change` re-seals both). Verifying via `openIdentity` also
|
|
29342
|
+
* stops a typo from minting an escrow no one can open.
|
|
29343
|
+
*/
|
|
29344
|
+
const promptVerifiedDevicePassphrase = Effect.gen(function* () {
|
|
29345
|
+
const file = yield* loadIdentityFileOrFail;
|
|
29346
|
+
const passphrase = yield* promptPassword("Passphrase for this device's identity (the account key uses the same one):");
|
|
29347
|
+
yield* Effect.tryPromise({
|
|
29348
|
+
try: async () => openIdentity({
|
|
29349
|
+
file,
|
|
29350
|
+
passphrase
|
|
29351
|
+
}),
|
|
29352
|
+
catch: () => new IdentityError({ message: "Wrong passphrase — could not unlock this device's identity." })
|
|
29353
|
+
});
|
|
29354
|
+
return passphrase;
|
|
29355
|
+
});
|
|
29356
|
+
const createCommand$3 = defineCommand({
|
|
29357
|
+
meta: {
|
|
29358
|
+
name: "create",
|
|
29359
|
+
description: "Enroll this user's account key — the env-vault recipient that unlocks env values from the browser"
|
|
29360
|
+
},
|
|
29361
|
+
run: async () => runEffect(Effect.gen(function* () {
|
|
29362
|
+
const api = yield* apiClient;
|
|
29363
|
+
if ((yield* findOwnAccountKey(api)) !== null) return yield* new IdentityError({ message: "An account key is already enrolled for this user. Use `better-update credentials passphrase change` to re-seal it, or `better-update credentials account link` to (re)grant it env access." });
|
|
29364
|
+
const passphrase = yield* promptVerifiedDevicePassphrase;
|
|
29365
|
+
const envelope = sealAccountKey({
|
|
29366
|
+
material: yield* Effect.promise(async () => generateAccountKey()),
|
|
29367
|
+
passphrase
|
|
29368
|
+
});
|
|
29369
|
+
const registered = yield* api.accountKeys.register({ payload: {
|
|
29370
|
+
agePublicKey: envelope.agePublicKey,
|
|
29371
|
+
ed25519PublicKey: envelope.ed25519PublicKey,
|
|
29372
|
+
fingerprint: envelope.fingerprint,
|
|
29373
|
+
kdfParams: envelope.kdfParams,
|
|
29374
|
+
salt: envelope.salt,
|
|
29375
|
+
escrowCt: envelope.ct
|
|
29376
|
+
} });
|
|
29377
|
+
const cutOver = yield* orgHasCutOver(api);
|
|
29378
|
+
if (cutOver) yield* linkAccountKeyToEnv(api, {
|
|
29379
|
+
accountKeyId: registered.id,
|
|
29380
|
+
agePublicKey: registered.agePublicKey
|
|
29381
|
+
});
|
|
29382
|
+
yield* printKeyValue([["Account key fingerprint", registered.fingerprint], ["Env access", cutOver ? "granted (self-linked)" : "pending env-vault migration"]]);
|
|
29383
|
+
yield* printHuman(cutOver ? "Account key enrolled. You can now unlock env values from the browser after a 2FA step-up." : "Account key enrolled. It gains env access once an admin runs `better-update credentials env-vault migrate`.");
|
|
29384
|
+
return {
|
|
29385
|
+
fingerprint: registered.fingerprint,
|
|
29386
|
+
envAccess: cutOver
|
|
29387
|
+
};
|
|
29388
|
+
}), { json: "value" })
|
|
29389
|
+
});
|
|
29390
|
+
const linkCommand$1 = defineCommand({
|
|
29391
|
+
meta: {
|
|
29392
|
+
name: "link",
|
|
29393
|
+
description: "Grant your already-enrolled account key access to the env vault (after a rotation)"
|
|
29394
|
+
},
|
|
29395
|
+
run: async () => runEffect(Effect.gen(function* () {
|
|
29396
|
+
const api = yield* apiClient;
|
|
29397
|
+
const own = yield* findOwnAccountKey(api);
|
|
29398
|
+
if (own === null) return yield* new IdentityError({ message: "No account key enrolled. Run `better-update credentials account create` first." });
|
|
29399
|
+
if (!(yield* orgHasCutOver(api))) return yield* new IdentityError({ message: "This organization has no env vault yet. An admin must run `better-update credentials env-vault migrate` first." });
|
|
29400
|
+
yield* linkAccountKeyToEnv(api, {
|
|
29401
|
+
accountKeyId: own.id,
|
|
29402
|
+
agePublicKey: own.agePublicKey
|
|
29403
|
+
});
|
|
29404
|
+
yield* printHuman(`Linked account key ${own.fingerprint} to the env vault.`);
|
|
29405
|
+
return {
|
|
29406
|
+
linked: true,
|
|
29407
|
+
fingerprint: own.fingerprint
|
|
29408
|
+
};
|
|
29409
|
+
}), { json: "value" })
|
|
29410
|
+
});
|
|
29411
|
+
const promptNewAccountPassphrase = Effect.gen(function* () {
|
|
29412
|
+
const first = yield* promptPassword("New passphrase (use this device's passphrase to keep them in sync):");
|
|
29413
|
+
if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
|
|
29414
|
+
if (first !== (yield* promptPassword("Confirm new passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
|
|
29415
|
+
return first;
|
|
29416
|
+
});
|
|
29417
|
+
const resealCommand = defineCommand({
|
|
29418
|
+
meta: {
|
|
29419
|
+
name: "reseal",
|
|
29420
|
+
description: "Re-seal your account key under a new passphrase — repair after a passphrase change on another device, or a reseal that failed mid-change"
|
|
29421
|
+
},
|
|
29422
|
+
run: async () => runEffect(Effect.gen(function* () {
|
|
29423
|
+
const api = yield* apiClient;
|
|
29424
|
+
const own = yield* findOwnAccountKey(api);
|
|
29425
|
+
if (own === null) return yield* new IdentityError({ message: "No account key enrolled. Run `better-update credentials account create` first." });
|
|
29426
|
+
const current = yield* promptPassword("Current account-key passphrase:");
|
|
29427
|
+
const sealed = sealAccountKey({
|
|
29428
|
+
material: yield* Effect.tryPromise({
|
|
29429
|
+
try: async () => openAccountKey({
|
|
29430
|
+
envelope: escrowToEnvelope(own),
|
|
29431
|
+
passphrase: current
|
|
29432
|
+
}),
|
|
29433
|
+
catch: () => new IdentityError({ message: "Wrong current account-key passphrase." })
|
|
29434
|
+
}),
|
|
29435
|
+
passphrase: yield* promptNewAccountPassphrase
|
|
29436
|
+
});
|
|
29437
|
+
yield* api.accountKeys.reseal({ payload: {
|
|
29438
|
+
kdfParams: sealed.kdfParams,
|
|
29439
|
+
salt: sealed.salt,
|
|
29440
|
+
escrowCt: sealed.ct
|
|
29441
|
+
} });
|
|
29442
|
+
yield* printHuman(`Re-sealed account key ${own.fingerprint} under the new passphrase.`);
|
|
29443
|
+
return {
|
|
29444
|
+
resealed: true,
|
|
29445
|
+
fingerprint: own.fingerprint
|
|
29446
|
+
};
|
|
29447
|
+
}), { json: "value" })
|
|
29448
|
+
});
|
|
29449
|
+
const showCommand$1 = defineCommand({
|
|
29450
|
+
meta: {
|
|
29451
|
+
name: "show",
|
|
29452
|
+
description: "Show this user's enrolled account key (fingerprint + status)"
|
|
29453
|
+
},
|
|
29454
|
+
run: async () => runEffect(Effect.gen(function* () {
|
|
29455
|
+
const own = yield* findOwnAccountKey(yield* apiClient);
|
|
29456
|
+
if (own === null) {
|
|
29457
|
+
yield* printHuman("No account key enrolled for this user. Run `better-update credentials account create` to enroll one.");
|
|
29458
|
+
return { enrolled: false };
|
|
29459
|
+
}
|
|
29460
|
+
yield* printKeyValue([
|
|
29461
|
+
["Account key fingerprint", own.fingerprint],
|
|
29462
|
+
["Age recipient (public)", own.agePublicKey],
|
|
29463
|
+
["Enrolled at", own.createdAt]
|
|
29464
|
+
]);
|
|
29465
|
+
return {
|
|
29466
|
+
enrolled: true,
|
|
29467
|
+
fingerprint: own.fingerprint
|
|
29468
|
+
};
|
|
29469
|
+
}), { json: "value" })
|
|
29470
|
+
});
|
|
29471
|
+
const accountCommand = defineCommand({
|
|
29472
|
+
meta: {
|
|
29473
|
+
name: "account",
|
|
29474
|
+
description: "Manage your per-user account key for browser-side env-vault access"
|
|
29475
|
+
},
|
|
29476
|
+
subCommands: {
|
|
29477
|
+
create: createCommand$3,
|
|
29478
|
+
link: linkCommand$1,
|
|
29479
|
+
reseal: resealCommand,
|
|
29480
|
+
show: showCommand$1
|
|
29481
|
+
},
|
|
29482
|
+
default: "show"
|
|
29483
|
+
});
|
|
29484
|
+
|
|
28552
29485
|
//#endregion
|
|
28553
29486
|
//#region src/commands/credentials/configure.ts
|
|
28554
29487
|
/**
|
|
@@ -29247,6 +30180,268 @@ const downloadCommand = defineCommand({
|
|
|
29247
30180
|
}), { json: "value" })
|
|
29248
30181
|
});
|
|
29249
30182
|
|
|
30183
|
+
//#endregion
|
|
30184
|
+
//#region src/application/env-vault-rekey.ts
|
|
30185
|
+
/**
|
|
30186
|
+
* Re-wrap a single env DEK from one vault key to another, rebinding it to the
|
|
30187
|
+
* destination vault version + kind. Pure crypto — throws (propagated AEAD failure)
|
|
30188
|
+
* if the source key/binding is wrong. Shared by the cutover (credentials→env) and
|
|
30189
|
+
* the env rotation (env→env).
|
|
30190
|
+
*/
|
|
30191
|
+
const rekeyEnvDek = (args) => {
|
|
30192
|
+
const raw = unwrapDek({
|
|
30193
|
+
wrappedDek: fromBase64(args.wrappedDek),
|
|
30194
|
+
vaultKey: args.from,
|
|
30195
|
+
binding: {
|
|
30196
|
+
orgId: args.orgId,
|
|
30197
|
+
credentialId: args.credentialId,
|
|
30198
|
+
vaultVersion: args.fromVersion,
|
|
30199
|
+
vaultKind: args.fromKind
|
|
30200
|
+
}
|
|
30201
|
+
});
|
|
30202
|
+
return {
|
|
30203
|
+
credentialId: args.credentialId,
|
|
30204
|
+
wrappedDek: toBase64(wrapDek({
|
|
30205
|
+
dek: raw,
|
|
30206
|
+
vaultKey: args.to,
|
|
30207
|
+
binding: {
|
|
30208
|
+
orgId: args.orgId,
|
|
30209
|
+
credentialId: args.credentialId,
|
|
30210
|
+
vaultVersion: args.toVersion,
|
|
30211
|
+
vaultKind: args.toKind
|
|
30212
|
+
}
|
|
30213
|
+
}))
|
|
30214
|
+
};
|
|
30215
|
+
};
|
|
30216
|
+
/** Seal the env key to each recipient, producing the wrap rows for a cutover / rotation. */
|
|
30217
|
+
const wrapEnvKeyToRecipients = (evKey, recipients) => Effect.forEach(recipients, (entry) => Effect.promise(async () => ({
|
|
30218
|
+
recipientKind: entry.recipientKind,
|
|
30219
|
+
recipientId: entry.recipientId,
|
|
30220
|
+
wrappedKey: toBase64(await wrapVaultKey({
|
|
30221
|
+
vaultKey: evKey,
|
|
30222
|
+
recipient: entry.recipient
|
|
30223
|
+
}))
|
|
30224
|
+
})), { concurrency: "unbounded" });
|
|
30225
|
+
|
|
30226
|
+
//#endregion
|
|
30227
|
+
//#region src/application/env-vault-cutover.ts
|
|
30228
|
+
/**
|
|
30229
|
+
* Every recipient the env key is wrapped to at cutover: the credentials vault's
|
|
30230
|
+
* device/recovery/machine recipients (so upgraded CLIs keep env access via their
|
|
30231
|
+
* device key) PLUS every member's account key (so the browser can unlock env). New
|
|
30232
|
+
* account keys enrolled later self-link via `credentials account create`.
|
|
30233
|
+
*/
|
|
30234
|
+
const collectEnvRecipients = (api) => Effect.gen(function* () {
|
|
30235
|
+
const cvRecipients = yield* currentRecipients(api);
|
|
30236
|
+
const { items: accounts } = yield* api.accountKeys.list();
|
|
30237
|
+
return [...cvRecipients.map((key) => ({
|
|
30238
|
+
recipientKind: key.kind,
|
|
30239
|
+
recipientId: key.id,
|
|
30240
|
+
recipient: key.publicKey
|
|
30241
|
+
})), ...accounts.map((account) => ({
|
|
30242
|
+
recipientKind: "account",
|
|
30243
|
+
recipientId: account.id,
|
|
30244
|
+
recipient: account.agePublicKey
|
|
30245
|
+
}))];
|
|
30246
|
+
});
|
|
30247
|
+
/**
|
|
30248
|
+
* Re-key every env DEK from the credentials vault key to the new env key. Env
|
|
30249
|
+
* values live in the credentials vault until cutover, so the source set comes from
|
|
30250
|
+
* `orgVault.listCredentialDeks` filtered to env rows (`credentialType` ===
|
|
30251
|
+
* `envVarValue`); each is unwrapped under the credentials key and re-wrapped under
|
|
30252
|
+
* the env key at version 1.
|
|
30253
|
+
*/
|
|
30254
|
+
const rekeyEnvDeksToEnvVault = (api, params) => Effect.gen(function* () {
|
|
30255
|
+
const { deks } = yield* api.orgVault.listCredentialDeks();
|
|
30256
|
+
return yield* Effect.forEach(deks.filter((dek) => dek.credentialType === "envVarValue"), (dek) => Effect.try({
|
|
30257
|
+
try: () => rekeyEnvDek({
|
|
30258
|
+
orgId: params.orgId,
|
|
30259
|
+
credentialId: dek.credentialId,
|
|
30260
|
+
wrappedDek: dek.wrappedDek,
|
|
30261
|
+
from: params.cvKey,
|
|
30262
|
+
fromVersion: dek.vaultVersion,
|
|
30263
|
+
fromKind: "credentials",
|
|
30264
|
+
to: params.evKey,
|
|
30265
|
+
toVersion: 1,
|
|
30266
|
+
toKind: "env"
|
|
30267
|
+
}),
|
|
30268
|
+
catch: () => new IdentityError({ message: "Failed to re-key an env value during the migration — re-unlock the vault and retry." })
|
|
30269
|
+
}), { concurrency: "unbounded" });
|
|
30270
|
+
});
|
|
30271
|
+
/**
|
|
30272
|
+
* One-shot cutover: fork the org's env values into a separate env vault. Generate
|
|
30273
|
+
* a fresh env key, wrap it to every recipient, re-key every env DEK from the
|
|
30274
|
+
* credentials key to the env key, and submit it all atomically. The server
|
|
30275
|
+
* compare-and-swaps on the cutover sentinel, so a re-run after a partial failure is
|
|
30276
|
+
* safe (a second cutover is rejected `Conflict`). The credentials vault — and
|
|
30277
|
+
* every signing credential — is untouched.
|
|
30278
|
+
*/
|
|
30279
|
+
const cutoverEnvVault = (api) => Effect.gen(function* () {
|
|
30280
|
+
const orgId = yield* getActiveOrgId(api);
|
|
30281
|
+
if ((yield* api.orgVault.get().pipe(Effect.catchTag("NotFound", () => new IdentityError({ message: "This organization has no credential vault yet. Run `better-update credentials identity init` first." })))).envVaultCutoverAt !== null) return yield* new IdentityError({ message: "This organization's env vault is already migrated." });
|
|
30282
|
+
const current = yield* unlockVaultKeyInteractive(api);
|
|
30283
|
+
const evKey = generateVaultKey();
|
|
30284
|
+
const wraps = yield* wrapEnvKeyToRecipients(evKey, yield* collectEnvRecipients(api));
|
|
30285
|
+
const envDeks = yield* rekeyEnvDeksToEnvVault(api, {
|
|
30286
|
+
orgId,
|
|
30287
|
+
cvKey: current.vaultKey,
|
|
30288
|
+
evKey
|
|
30289
|
+
});
|
|
30290
|
+
return yield* api.envVault.cutover({ payload: {
|
|
30291
|
+
wraps,
|
|
30292
|
+
envDeks
|
|
30293
|
+
} });
|
|
30294
|
+
});
|
|
30295
|
+
|
|
30296
|
+
//#endregion
|
|
30297
|
+
//#region src/application/env-vault-rotation.ts
|
|
30298
|
+
/**
|
|
30299
|
+
* Resolve the env vault's CURRENT recipients to the age recipients the new key is
|
|
30300
|
+
* wrapped to. The recipient set comes from `envVault.listWraps` (a member removal
|
|
30301
|
+
* already dropped the departing user's wraps server-side), and each id is resolved
|
|
30302
|
+
* to its public key: device/recovery/machine via `userEncryptionKeys`, account via
|
|
30303
|
+
* `accountKeys`. An id that no longer resolves (revoked) is dropped — the server
|
|
30304
|
+
* still enforces that a recovery recipient remains.
|
|
30305
|
+
*/
|
|
30306
|
+
const rewrapSurvivingRecipients = (api, evKey) => Effect.gen(function* () {
|
|
30307
|
+
const { recipients } = yield* api.envVault.listWraps();
|
|
30308
|
+
const [{ items: keys }, { items: accounts }] = yield* Effect.all([api.userEncryptionKeys.list(), api.accountKeys.list()]);
|
|
30309
|
+
const keyById = new Map(keys.map((key) => [key.id, key.publicKey]));
|
|
30310
|
+
const accountById = new Map(accounts.map((account) => [account.id, account.agePublicKey]));
|
|
30311
|
+
return yield* wrapEnvKeyToRecipients(evKey, recipients.flatMap((wrap) => {
|
|
30312
|
+
const recipient = wrap.recipientKind === "account" ? accountById.get(wrap.recipientId) : keyById.get(wrap.recipientId);
|
|
30313
|
+
return recipient === void 0 ? [] : [{
|
|
30314
|
+
recipientKind: wrap.recipientKind,
|
|
30315
|
+
recipientId: wrap.recipientId,
|
|
30316
|
+
recipient
|
|
30317
|
+
}];
|
|
30318
|
+
}));
|
|
30319
|
+
});
|
|
30320
|
+
/** Re-key every env DEK under the new env key, bumping it to the next env version. */
|
|
30321
|
+
const rekeyEnvDeksForRotation = (api, params) => Effect.gen(function* () {
|
|
30322
|
+
const { deks } = yield* api.envVault.listCredentialDeks();
|
|
30323
|
+
return yield* Effect.forEach(deks, (dek) => Effect.try({
|
|
30324
|
+
try: () => rekeyEnvDek({
|
|
30325
|
+
orgId: params.orgId,
|
|
30326
|
+
credentialId: dek.credentialId,
|
|
30327
|
+
wrappedDek: dek.wrappedDek,
|
|
30328
|
+
from: params.fromKey,
|
|
30329
|
+
fromVersion: dek.vaultVersion,
|
|
30330
|
+
fromKind: "env",
|
|
30331
|
+
to: params.toKey,
|
|
30332
|
+
toVersion: params.toVersion,
|
|
30333
|
+
toKind: "env"
|
|
30334
|
+
}),
|
|
30335
|
+
catch: () => new IdentityError({ message: "Failed to re-key an env value during rotation — re-unlock the env vault and retry." })
|
|
30336
|
+
}), { concurrency: "unbounded" });
|
|
30337
|
+
});
|
|
30338
|
+
/**
|
|
30339
|
+
* Rotate the env vault key: generate a new key at version+1, re-wrap it to the
|
|
30340
|
+
* current recipients, re-key every env DEK, and submit atomically (the server
|
|
30341
|
+
* compare-and-swaps on the env version). Clears the env rotation-pending flag a
|
|
30342
|
+
* member removal raised. Drops the now-stale cached env key afterwards.
|
|
30343
|
+
*/
|
|
30344
|
+
const rotateEnvVault = (api) => Effect.gen(function* () {
|
|
30345
|
+
const orgId = yield* getActiveOrgId(api);
|
|
30346
|
+
if ((yield* api.orgVault.get().pipe(Effect.catchTag("NotFound", () => new IdentityError({ message: "This organization has no credential vault yet." })))).envVaultCutoverAt === null) return yield* new IdentityError({ message: "This organization has no env vault yet — run `better-update credentials env-vault migrate` first." });
|
|
30347
|
+
const current = yield* unlockEnvVaultKeyInteractive(api);
|
|
30348
|
+
const toVersion = current.vaultVersion + 1;
|
|
30349
|
+
const newEvKey = generateVaultKey();
|
|
30350
|
+
const wraps = yield* rewrapSurvivingRecipients(api, newEvKey);
|
|
30351
|
+
const envDeks = yield* rekeyEnvDeksForRotation(api, {
|
|
30352
|
+
orgId,
|
|
30353
|
+
fromKey: current.vaultKey,
|
|
30354
|
+
toKey: newEvKey,
|
|
30355
|
+
toVersion
|
|
30356
|
+
});
|
|
30357
|
+
const rotated = yield* api.envVault.rotate({ payload: {
|
|
30358
|
+
fromVersion: current.vaultVersion,
|
|
30359
|
+
wraps,
|
|
30360
|
+
envDeks
|
|
30361
|
+
} });
|
|
30362
|
+
yield* forgetCachedEnvVaultKey;
|
|
30363
|
+
return rotated;
|
|
30364
|
+
});
|
|
30365
|
+
|
|
30366
|
+
//#endregion
|
|
30367
|
+
//#region src/commands/credentials/env-vault.ts
|
|
30368
|
+
const migrateCommand = defineCommand({
|
|
30369
|
+
meta: {
|
|
30370
|
+
name: "migrate",
|
|
30371
|
+
description: "Fork env values into a separate env vault so they can be unlocked from the browser (one-time, admin)"
|
|
30372
|
+
},
|
|
30373
|
+
args: { yes: {
|
|
30374
|
+
type: "boolean",
|
|
30375
|
+
description: "Skip the confirmation prompt"
|
|
30376
|
+
} },
|
|
30377
|
+
run: async ({ args }) => runEffect(Effect.gen(function* () {
|
|
30378
|
+
const api = yield* apiClient;
|
|
30379
|
+
yield* printHuman("Migrating splits env values into their own end-to-end-encrypted vault, wrapped to every device and account key.");
|
|
30380
|
+
yield* printHuman("⚠ This is one-shot per organization. Until they upgrade, older CLIs can no longer READ env values (credentials are unaffected).");
|
|
30381
|
+
yield* printHuman("⚠ Avoid env-var writes (set/push/import/update) elsewhere while this runs — a value written mid-migration may need re-setting afterwards.");
|
|
30382
|
+
if (!(args.yes === true || (yield* promptConfirm("Migrate this organization's env values to a separate vault now?", { initialValue: false })))) {
|
|
30383
|
+
yield* printHuman("Aborted — no changes made.");
|
|
30384
|
+
return { migrated: false };
|
|
30385
|
+
}
|
|
30386
|
+
const vault = yield* cutoverEnvVault(api);
|
|
30387
|
+
yield* printHuman(`✓ Env vault created (version ${String(vault.envVaultVersion)}).`);
|
|
30388
|
+
yield* printHuman("Members enroll browser access with `better-update credentials account create`.");
|
|
30389
|
+
return {
|
|
30390
|
+
migrated: true,
|
|
30391
|
+
envVaultVersion: vault.envVaultVersion
|
|
30392
|
+
};
|
|
30393
|
+
}), { json: "value" })
|
|
30394
|
+
});
|
|
30395
|
+
const rotateCommand = defineCommand({
|
|
30396
|
+
meta: {
|
|
30397
|
+
name: "rotate",
|
|
30398
|
+
description: "Rotate the env-vault key, re-wrapping to the current recipients — clears a pending flag after a member is removed (admin)"
|
|
30399
|
+
},
|
|
30400
|
+
run: async () => runEffect(Effect.gen(function* () {
|
|
30401
|
+
const vault = yield* rotateEnvVault(yield* apiClient);
|
|
30402
|
+
yield* printHuman(`Rotated the env vault to version ${String(vault.envVaultVersion)}.`);
|
|
30403
|
+
return { envVaultVersion: vault.envVaultVersion };
|
|
30404
|
+
}), { json: "value" })
|
|
30405
|
+
});
|
|
30406
|
+
const statusCommand$2 = defineCommand({
|
|
30407
|
+
meta: {
|
|
30408
|
+
name: "status",
|
|
30409
|
+
description: "Show whether the org has cut over to a separate env vault, and its version/state"
|
|
30410
|
+
},
|
|
30411
|
+
run: async () => runEffect(Effect.gen(function* () {
|
|
30412
|
+
const vault = yield* (yield* apiClient).orgVault.get().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
|
|
30413
|
+
if (vault === null) {
|
|
30414
|
+
yield* printHuman("This organization has no credential vault yet.");
|
|
30415
|
+
return { vaultExists: false };
|
|
30416
|
+
}
|
|
30417
|
+
const cutOver = vault.envVaultCutoverAt !== null;
|
|
30418
|
+
yield* printKeyValue([
|
|
30419
|
+
["Env vault", cutOver ? "separate (migrated)" : "shared with credentials vault"],
|
|
30420
|
+
["Env vault version", cutOver ? String(vault.envVaultVersion) : "—"],
|
|
30421
|
+
["Env rotation pending", vault.envRotationPending ? "yes" : "no"]
|
|
30422
|
+
]);
|
|
30423
|
+
if (vault.envRotationPending) yield* printHuman("⚠ Env rotation pending — run `better-update credentials env-vault rotate` to re-key and restore env access.");
|
|
30424
|
+
return {
|
|
30425
|
+
vaultExists: true,
|
|
30426
|
+
cutOver,
|
|
30427
|
+
envVaultVersion: cutOver ? vault.envVaultVersion : null,
|
|
30428
|
+
envRotationPending: vault.envRotationPending
|
|
30429
|
+
};
|
|
30430
|
+
}), { json: "value" })
|
|
30431
|
+
});
|
|
30432
|
+
const envVaultCommand = defineCommand({
|
|
30433
|
+
meta: {
|
|
30434
|
+
name: "env-vault",
|
|
30435
|
+
description: "Manage the organization's separate env-vault (migrate, rotate, status)"
|
|
30436
|
+
},
|
|
30437
|
+
subCommands: {
|
|
30438
|
+
migrate: migrateCommand,
|
|
30439
|
+
rotate: rotateCommand,
|
|
30440
|
+
status: statusCommand$2
|
|
30441
|
+
},
|
|
30442
|
+
default: "status"
|
|
30443
|
+
});
|
|
30444
|
+
|
|
29250
30445
|
//#endregion
|
|
29251
30446
|
//#region src/lib/credentials-generator-merchant.ts
|
|
29252
30447
|
/**
|
|
@@ -29803,7 +30998,7 @@ const resolveLabel = (flag) => Effect.gen(function* () {
|
|
|
29803
30998
|
if (flag && flag.trim().length > 0) return flag.trim();
|
|
29804
30999
|
return yield* promptText("Label for this device key", { defaultValue: yield* (yield* CliRuntime).userName });
|
|
29805
31000
|
});
|
|
29806
|
-
const promptNewPassphrase = Effect.gen(function* () {
|
|
31001
|
+
const promptNewPassphrase$1 = Effect.gen(function* () {
|
|
29807
31002
|
const first = yield* promptPassword("Choose a passphrase to protect this device key:");
|
|
29808
31003
|
if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
|
|
29809
31004
|
if (first !== (yield* promptPassword("Confirm passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
|
|
@@ -29825,7 +31020,7 @@ const createCommand$2 = defineCommand({
|
|
|
29825
31020
|
} },
|
|
29826
31021
|
run: async ({ args }) => runEffect(Effect.gen(function* () {
|
|
29827
31022
|
const label = yield* resolveLabel(args.label);
|
|
29828
|
-
const identity = yield* createLocalIdentity(yield* promptNewPassphrase);
|
|
31023
|
+
const identity = yield* createLocalIdentity(yield* promptNewPassphrase$1);
|
|
29829
31024
|
const api = yield* apiClient;
|
|
29830
31025
|
yield* printRecipient(yield* registerRecipient(api, {
|
|
29831
31026
|
kind: "device",
|
|
@@ -29958,6 +31153,47 @@ const listCommand$4 = defineCommand({
|
|
|
29958
31153
|
}))
|
|
29959
31154
|
});
|
|
29960
31155
|
|
|
31156
|
+
//#endregion
|
|
31157
|
+
//#region src/commands/credentials/passphrase.ts
|
|
31158
|
+
/** Human result for each account-escrow outcome of a device passphrase change. */
|
|
31159
|
+
const ACCOUNT_OUTCOME_MESSAGE = {
|
|
31160
|
+
resealed: "Passphrase changed — this device's identity and your account key were both re-sealed.",
|
|
31161
|
+
absent: "Passphrase changed — this device's identity was re-sealed (no account key enrolled).",
|
|
31162
|
+
"passphrase-mismatch": "Passphrase changed for this device — but your account key is sealed under a DIFFERENT passphrase (likely changed on another device) and was left unchanged. Run `better-update credentials account reseal` to bring it onto this passphrase.",
|
|
31163
|
+
error: "Passphrase changed for this device — but your account key could not be re-sealed (the server was unreachable). Run `better-update credentials account reseal` to retry."
|
|
31164
|
+
};
|
|
31165
|
+
const promptNewPassphrase = Effect.gen(function* () {
|
|
31166
|
+
const first = yield* promptPassword("Choose a new passphrase:");
|
|
31167
|
+
if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
|
|
31168
|
+
if (first !== (yield* promptPassword("Confirm new passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
|
|
31169
|
+
return first;
|
|
31170
|
+
});
|
|
31171
|
+
const changeCommand = defineCommand({
|
|
31172
|
+
meta: {
|
|
31173
|
+
name: "change",
|
|
31174
|
+
description: "Change this device's passphrase, re-sealing the device identity and (if enrolled) your account key under it"
|
|
31175
|
+
},
|
|
31176
|
+
run: async () => runEffect(Effect.gen(function* () {
|
|
31177
|
+
const { account } = yield* changePassphrase(yield* apiClient, {
|
|
31178
|
+
oldPassphrase: yield* promptPassword("Current passphrase:"),
|
|
31179
|
+
newPassphrase: yield* promptNewPassphrase
|
|
31180
|
+
});
|
|
31181
|
+
yield* printHuman(ACCOUNT_OUTCOME_MESSAGE[account]);
|
|
31182
|
+
return {
|
|
31183
|
+
changed: true,
|
|
31184
|
+
account
|
|
31185
|
+
};
|
|
31186
|
+
}), { json: "value" })
|
|
31187
|
+
});
|
|
31188
|
+
const passphraseCommand = defineCommand({
|
|
31189
|
+
meta: {
|
|
31190
|
+
name: "passphrase",
|
|
31191
|
+
description: "Manage this device's identity passphrase"
|
|
31192
|
+
},
|
|
31193
|
+
subCommands: { change: changeCommand },
|
|
31194
|
+
default: "change"
|
|
31195
|
+
});
|
|
31196
|
+
|
|
29961
31197
|
//#endregion
|
|
29962
31198
|
//#region src/commands/credentials/regenerate-profile.ts
|
|
29963
31199
|
const REGENERATE_EXIT_EXTRAS = {
|
|
@@ -31323,6 +32559,9 @@ const credentialsCommand = defineCommand({
|
|
|
31323
32559
|
manager: managerCommand,
|
|
31324
32560
|
identity: identityCommand,
|
|
31325
32561
|
access: accessCommand,
|
|
32562
|
+
account: accountCommand,
|
|
32563
|
+
"env-vault": envVaultCommand,
|
|
32564
|
+
passphrase: passphraseCommand,
|
|
31326
32565
|
device: deviceCommand,
|
|
31327
32566
|
unlock: unlockCommand,
|
|
31328
32567
|
lock: lockCommand,
|
|
@@ -32614,8 +33853,9 @@ const updateCommand$1 = defineCommand({
|
|
|
32614
33853
|
payload: compact({ visibility })
|
|
32615
33854
|
});
|
|
32616
33855
|
else {
|
|
33856
|
+
const session = yield* openEnvVaultSessionInteractive(api);
|
|
32617
33857
|
const envelope = yield* sealForUpload({
|
|
32618
|
-
session
|
|
33858
|
+
session,
|
|
32619
33859
|
credentialType: "envVarValue",
|
|
32620
33860
|
metadata: {
|
|
32621
33861
|
key,
|
|
@@ -32630,7 +33870,8 @@ const updateCommand$1 = defineCommand({
|
|
|
32630
33870
|
id: envelope.id,
|
|
32631
33871
|
ciphertext: envelope.ciphertext,
|
|
32632
33872
|
wrappedDek: envelope.wrappedDek,
|
|
32633
|
-
vaultVersion: envelope.vaultVersion
|
|
33873
|
+
vaultVersion: envelope.vaultVersion,
|
|
33874
|
+
vaultKind: session.vaultKind
|
|
32634
33875
|
},
|
|
32635
33876
|
...compact({ visibility })
|
|
32636
33877
|
}
|