getmyenv 0.9.9 → 0.10.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/README.md CHANGED
@@ -23,7 +23,7 @@ npx getmyenv run staging -- npm start # or pick one
23
23
  - `export [-o file | --stdout] [--yes]`: for tools that need a file. The file is added to `.gitignore` and gets owner-only permissions on macOS and Linux.
24
24
  - `guest [--name <name>]`: a project without an account. It works for 30 days. Claim it within 60.
25
25
  - `claim`: claim a guest project. Reads `.getmyenv/claim.json`, or asks for the claim code and guest passphrase that `guest` printed once at a terminal.
26
- - `backup [-o file]`: encrypted backup of open contexts (default `<project>.getmyenv-backup`). It opens with the Vault password in effect when it was made.
26
+ - `backup [-o file]`: encrypted backup of open contexts (default `<project>.getmyenv-backup`). It opens with the Vault password or the recovery code in effect when it was made.
27
27
  - `restore <file> [--dry-run] [--yes]`: restore a backup into this folder's project. Values go into open contexts with the same slug and replace values with the same name. Read-only and missing contexts are skipped.
28
28
  - `start`: same as bare `getmyenv`. `start --template <id or link>` adds a template's variable names and asks for each value.
29
29
  - `lock`: forget every unlocked context key on this machine. `unlink [--yes]`, `help [command]`
@@ -35,7 +35,7 @@ Prompts and status lines go to stderr, so `export --stdout > .env` stays clean.
35
35
  Commit `.getmyenv/project.json`. `token.json` and `claim.json` stay local and are gitignored.
36
36
 
37
37
  <!-- cli:help -->
38
- Output of `npx getmyenv --help` (0.9.9):
38
+ Output of `npx getmyenv --help` (0.10.0):
39
39
 
40
40
  ```text
41
41
  Usage: getmyenv [options] [command]
@@ -2,6 +2,6 @@ type BackupOpts = {
2
2
  output?: string;
3
3
  apiUrl?: string;
4
4
  };
5
- /** Encrypted backup of open contexts. Opens offline with the current Vault password. */
5
+ /** Encrypted backup of open contexts. Opens offline with the current Vault password or recovery code. */
6
6
  export declare function backupCommand(opts: BackupOpts): Promise<void>;
7
7
  export {};
@@ -4,7 +4,7 @@ import { createBackupEnvelope, parsePayload, parseWrappedKey, serializeBackup, }
4
4
  import { slugify } from "@getmyenv/shared";
5
5
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
6
6
  import { writePrivateFile } from "../lib/fs.js";
7
- /** Encrypted backup of open contexts. Opens offline with the current Vault password. */
7
+ /** Encrypted backup of open contexts. Opens offline with the current Vault password or recovery code. */
8
8
  export async function backupCommand(opts) {
9
9
  const { client } = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
10
10
  const data = await client.request("GET", "/api/cli/backup");
@@ -15,6 +15,9 @@ export async function backupCommand(opts) {
15
15
  projectName: data.projectName,
16
16
  publicKey: data.publicKey,
17
17
  wrappedPrivateKey: parseWrappedKey(JSON.stringify(data.wrappedPrivateKey)),
18
+ recoveryWrappedKey: data.recoveryWrappedKey
19
+ ? parseWrappedKey(JSON.stringify(data.recoveryWrappedKey))
20
+ : null,
18
21
  contexts: data.contexts,
19
22
  keys: data.keys,
20
23
  tags: data.tags,
@@ -29,5 +32,7 @@ export async function backupCommand(opts) {
29
32
  path.join(process.cwd(), `${slugify(data.projectName) || "project"}.getmyenv-backup`);
30
33
  writePrivateFile(out, serializeBackup(envelope), { overwrite: false });
31
34
  console.log(`${pc.green("✓")} Encrypted backup: ${out} (${data.values.length} values, ${data.contexts.length} open contexts)`);
32
- console.log(pc.dim("It opens with your current Vault password. Read-only contexts are backed up from the dashboard."));
35
+ console.log(pc.dim(data.recoveryWrappedKey
36
+ ? "It opens with your current Vault password or recovery code. Read-only contexts are backed up from the dashboard."
37
+ : "It opens with your current Vault password. Read-only contexts are backed up from the dashboard."));
33
38
  }
@@ -1,15 +1,14 @@
1
1
  import pc from "picocolors";
2
2
  import { GUEST_GRACE_DAYS } from "@getmyenv/shared";
3
- import { createUserKeys, openSealedKey, sealContextKey, unwrapPrivateKey, wipe, } from "@getmyenv/crypto";
3
+ import { openSealedKey, sealContextKey, unwrapPrivateKey, wipe, } from "@getmyenv/crypto";
4
4
  import { clearClaimFile, ensureGitignoreHasEnv, promptHidden, readClaimFile, readLine, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
5
5
  import { readFolderToken, writeFolderToken } from "../lib/credentials.js";
6
6
  import { ApiClient, ApiError, resolveApiUrl } from "../lib/api.js";
7
7
  import { authorizeFolder, openBrowser, sleep } from "../lib/authorize.js";
8
- import { promptNewVaultPassword } from "../lib/guest-key.js";
9
- async function askNewVaultPassword() {
10
- console.error(pc.dim("Choose a Vault password. It stays on this machine. We store an encrypted key blob only.\n"));
8
+ import { createAccountKeys } from "../lib/recovery-code.js";
9
+ async function askNewAccountKeys() {
11
10
  try {
12
- return await promptNewVaultPassword();
11
+ return await createAccountKeys();
13
12
  }
14
13
  catch (err) {
15
14
  throw new Error(`${err instanceof Error ? err.message : "Vault password required."} Run claim again.`);
@@ -38,10 +37,10 @@ async function finishClaim(owned, claim) {
38
37
  let keys;
39
38
  try {
40
39
  if (!ownerPublicKey) {
41
- const created = await createUserKeys(await askNewVaultPassword());
40
+ const created = await askNewAccountKeys();
42
41
  wipe(created.privateKey);
43
42
  ownerPublicKey = created.publicKey;
44
- keys = { publicKey: created.publicKey, wrappedPrivateKey: created.wrapped };
43
+ keys = created.keys;
45
44
  }
46
45
  const shares = vault.claim.shares.map((s) => {
47
46
  const key = openSealedKey(s.sealedKey, guestKey, s.contextId);
@@ -63,7 +62,7 @@ async function finishClaim(owned, claim) {
63
62
  }
64
63
  clearClaimFile();
65
64
  ensureGitignoreHasEnv();
66
- console.error(`\n${pc.green("✓")} Claimed ${pc.bold(claim.projectName)}. ${keys ? "Vault password set." : "It opens with your Vault password."}`);
65
+ console.error(`\n${pc.green("✓")} Claimed ${pc.bold(claim.projectName)}. ${keys ? "Vault password and recovery code set." : "It opens with your Vault password."}`);
67
66
  console.error(pc.dim("claim.json was removed. Read-only contexts like Production are now available in the dashboard.\n"));
68
67
  }
69
68
  /** A signed-in folder token for the claimed project: reuse this folder's, or sign in. */
@@ -5,7 +5,7 @@ type RestoreOpts = {
5
5
  };
6
6
  /**
7
7
  * Restore a backup into this folder's project. The backup opens locally with
8
- * the Vault password it was made with. Values are encrypted again with this
8
+ * the Vault password or recovery code it was made with. Values are encrypted again with this
9
9
  * project's context keys. Read-only and unknown contexts are skipped.
10
10
  */
11
11
  export declare function restoreCommand(file: string, opts: RestoreOpts): Promise<void>;
@@ -8,7 +8,7 @@ import { resolveVaultPassword } from "../lib/guest-key.js";
8
8
  import { writeKey } from "../lib/vault.js";
9
9
  /**
10
10
  * Restore a backup into this folder's project. The backup opens locally with
11
- * the Vault password it was made with. Values are encrypted again with this
11
+ * the Vault password or recovery code it was made with. Values are encrypted again with this
12
12
  * project's context keys. Read-only and unknown contexts are skipped.
13
13
  */
14
14
  export async function restoreCommand(file, opts) {
@@ -32,16 +32,23 @@ export async function restoreCommand(file, opts) {
32
32
  if (who.token.kind !== "folder") {
33
33
  throw new Error("Restore needs a signed-in folder token. Guest and server tokens cannot restore.");
34
34
  }
35
- const password = await resolveVaultPassword({
35
+ const secret = await resolveVaultPassword({
36
36
  isGuest: false,
37
- prompt: "Vault password for this backup (hidden): ",
37
+ prompt: envelope.recoveryWrappedKey
38
+ ? "Vault password or recovery code for this backup (hidden): "
39
+ : "Vault password for this backup (hidden): ",
38
40
  });
39
41
  let opened;
40
42
  try {
41
- opened = await restoreValuesFromBackup(password, envelope);
43
+ opened = await restoreValuesFromBackup(secret, envelope);
42
44
  }
43
- catch {
44
- throw new Error("Could not open the backup. Check the Vault password.");
45
+ catch (err) {
46
+ const msg = err instanceof Error ? err.message : "";
47
+ if (/^(That recovery code|This backup|A recovery code)/.test(msg))
48
+ throw new Error(msg);
49
+ throw new Error(envelope.recoveryWrappedKey
50
+ ? "Could not open the backup. Check the Vault password or recovery code."
51
+ : "Could not open the backup. Check the Vault password.");
45
52
  }
46
53
  console.error(`Backup checked: ${opened.values.length} values in ${envelope.contexts.length} contexts.\n`);
47
54
  const targets = new Map();
@@ -0,0 +1,19 @@
1
+ import { type WrappedPrivateKey } from "@getmyenv/crypto";
2
+ export declare const NO_TTY_FOR_VAULT = "No terminal to set a Vault password. Set it in the dashboard, Settings, or run this command in a terminal.";
3
+ export type NewAccountKeys = {
4
+ publicKey: string;
5
+ /** Caller wipes it. */
6
+ privateKey: Uint8Array;
7
+ keys: {
8
+ publicKey: string;
9
+ wrappedPrivateKey: WrappedPrivateKey;
10
+ recoveryWrappedKey: WrappedPrivateKey;
11
+ };
12
+ };
13
+ /** The user types the code back. Nothing is saved until it matches. */
14
+ export declare function confirmRecoveryCode(code: string, ask?: (question: string) => Promise<string>): Promise<void>;
15
+ /**
16
+ * New account keys: a Vault password typed twice, then a recovery code shown
17
+ * once and typed back. Needs a terminal. Checks both wraps open the same key.
18
+ */
19
+ export declare function createAccountKeys(): Promise<NewAccountKeys>;
@@ -0,0 +1,57 @@
1
+ import pc from "picocolors";
2
+ import { assertRecoveryWrap, createRecoveryWrap, createUserKeys, formatRecoveryCode, recoveryCodesMatch, wipe, } from "@getmyenv/crypto";
3
+ import { FACTS } from "@getmyenv/shared";
4
+ import { readLine } from "./fs.js";
5
+ import { promptNewVaultPassword } from "./guest-key.js";
6
+ export const NO_TTY_FOR_VAULT = "No terminal to set a Vault password. Set it in the dashboard, Settings, or run this command in a terminal.";
7
+ const TYPE_BACK_ATTEMPTS = 3;
8
+ function printRecoveryCode(code) {
9
+ const shown = formatRecoveryCode(code);
10
+ const width = shown.length + 4;
11
+ console.error("");
12
+ console.error(pc.bold("Your recovery code"));
13
+ console.error(`┌${"─".repeat(width)}┐`);
14
+ console.error(`│ ${pc.bold(shown)} │`);
15
+ console.error(`└${"─".repeat(width)}┘`);
16
+ console.error(pc.dim(`${FACTS.recoveryCode} Write it down or save it in a password manager.\n`));
17
+ }
18
+ /** The user types the code back. Nothing is saved until it matches. */
19
+ export async function confirmRecoveryCode(code, ask = readLine) {
20
+ for (let attempt = 1; attempt <= TYPE_BACK_ATTEMPTS; attempt++) {
21
+ const typed = await ask("Type the recovery code to confirm: ");
22
+ if (recoveryCodesMatch(code, typed))
23
+ return;
24
+ if (attempt < TYPE_BACK_ATTEMPTS)
25
+ console.error(pc.red("That does not match. Try again.\n"));
26
+ }
27
+ throw new Error("The recovery code did not match. Nothing was saved. Run the command again.");
28
+ }
29
+ /**
30
+ * New account keys: a Vault password typed twice, then a recovery code shown
31
+ * once and typed back. Needs a terminal. Checks both wraps open the same key.
32
+ */
33
+ export async function createAccountKeys() {
34
+ if (!process.stdin.isTTY)
35
+ throw new Error(NO_TTY_FOR_VAULT);
36
+ console.error(pc.dim("Choose a Vault password. It stays on this machine. We store an encrypted key blob only.\n"));
37
+ const created = await createUserKeys(await promptNewVaultPassword());
38
+ try {
39
+ const recovery = await createRecoveryWrap(created.privateKey);
40
+ printRecoveryCode(recovery.code);
41
+ await confirmRecoveryCode(recovery.code);
42
+ await assertRecoveryWrap(recovery.code, recovery.wrapped, created.publicKey);
43
+ return {
44
+ publicKey: created.publicKey,
45
+ privateKey: created.privateKey,
46
+ keys: {
47
+ publicKey: created.publicKey,
48
+ wrappedPrivateKey: created.wrapped,
49
+ recoveryWrappedKey: recovery.wrapped,
50
+ },
51
+ };
52
+ }
53
+ catch (err) {
54
+ wipe(created.privateKey);
55
+ throw err;
56
+ }
57
+ }
package/dist/lib/vault.js CHANGED
@@ -4,6 +4,7 @@ import { VAULT_UNLOCK_DURATION } from "@getmyenv/shared";
4
4
  import { ApiError } from "./api.js";
5
5
  import { resolveVaultPassword } from "./guest-key.js";
6
6
  import { keychainAccount, keychainEnabled, readCachedContextKey, storeCachedContextKeys, } from "./keychain.js";
7
+ import { createAccountKeys } from "./recovery-code.js";
7
8
  /** One unlock per process: several contexts in one command prompt once. */
8
9
  const memo = new Map();
9
10
  /** Key version of each memo entry opened from the vault response. Keys from the keychain have none. */
@@ -152,22 +153,16 @@ async function holderPrivateKey(linked, vault, opts) {
152
153
  if (!opts.create) {
153
154
  throw new Error("No Vault password yet. Set it in the dashboard, or set a value: npx getmyenv set KEY");
154
155
  }
155
- if (!isGuest) {
156
- console.error(pc.dim("Choose a Vault password. It stays on this machine. We store an encrypted key blob only.\n"));
157
- }
158
- const password = await resolveVaultPassword({ isGuest, create: true });
159
- const created = await createUserKeys(password);
156
+ const created = isGuest ? await createGuestKeys() : await createAccountKeys();
160
157
  try {
161
- await linked.client.request("POST", "/api/cli/vault", {
162
- keys: { publicKey: created.publicKey, wrappedPrivateKey: created.wrapped },
163
- });
158
+ await linked.client.request("POST", "/api/cli/vault", { keys: created.keys });
164
159
  }
165
160
  catch (err) {
166
161
  wipe(created.privateKey);
167
162
  throw err;
168
163
  }
169
164
  vault.publicKey = created.publicKey;
170
- vault.wrappedPrivateKey = created.wrapped;
165
+ vault.wrappedPrivateKey = created.keys.wrappedPrivateKey;
171
166
  return created.privateKey;
172
167
  }
173
168
  const interactive = process.stdin.isTTY && !isGuest;
@@ -185,10 +180,19 @@ async function holderPrivateKey(linked, vault, opts) {
185
180
  console.error(pc.red("Wrong Vault password. Try again.\n"));
186
181
  continue;
187
182
  }
188
- throw new Error("Could not unlock. Check your Vault password. Forgot it? Reset the vault in the dashboard, Settings.");
183
+ throw new Error("Could not unlock. Check your Vault password. Forgot it? Use your recovery code in the dashboard, Settings.");
189
184
  }
190
185
  }
191
186
  }
187
+ /** Guest keys wrap with the claim.json passphrase. No recovery code. */
188
+ async function createGuestKeys() {
189
+ const created = await createUserKeys(await resolveVaultPassword({ isGuest: true, create: true }));
190
+ return {
191
+ publicKey: created.publicKey,
192
+ privateKey: created.privateKey,
193
+ keys: { publicKey: created.publicKey, wrappedPrivateKey: created.wrapped },
194
+ };
195
+ }
192
196
  /** Unlock with the Vault password and return the private key. Caller wipes it. */
193
197
  export async function unlockPrivateKey(linked, opts = {}) {
194
198
  const vault = await fetchVault(linked);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getmyenv",
3
- "version": "0.9.9",
3
+ "version": "0.10.0",
4
4
  "description": "CLI for getmyenv, an encrypted store for environment variables and secrets. We store ciphertext only.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,8 +33,8 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@getmyenv/crypto": "0.4.1",
37
- "@getmyenv/shared": "0.8.5",
36
+ "@getmyenv/crypto": "0.5.0",
37
+ "@getmyenv/shared": "0.9.0",
38
38
  "@napi-rs/keyring": "^2.1.0",
39
39
  "commander": "^15.0.0",
40
40
  "dotenv": "^16.4.7",