From 6fc84285b19ce8d0e5f905899a0c613df4a7e21c Mon Sep 17 00:00:00 2001 From: KeyVan Date: Fri, 9 Oct 2026 17:06:36 -0400 Subject: [PATCH] Add key:export, key:import and key:fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync and encrypted bundles need the same ~/.movez/key on every machine, which meant finding and copying a hidden file by hand. - key:export prints the key (only the key goes to stdout, so it can be piped over SSH) or writes it to a 0600 file with --output - key:import reads from --file, stdin, or a hidden prompt — never from a command-line argument — and refuses to replace a different key without --force, since that would make existing bundles unreadable - key:fingerprint prints a non-secret fingerprint to compare machines; doctor and sync:push show it, and the missing-key error points at the new commands - Docs replace "copy ~/.movez/key" with the new workflow Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++- docs/extension-setup.md | 2 +- docs/sync-server-setup.md | 8 +- movez/app/Commands/DoctorCommand.php | 2 +- movez/app/Commands/Key/KeyExportCommand.php | 45 +++++++++ .../Commands/Key/KeyFingerprintCommand.php | 27 ++++++ movez/app/Commands/Key/KeyImportCommand.php | 45 +++++++++ movez/app/Commands/Sync/SyncPushCommand.php | 2 +- movez/app/Services/Encryptor.php | 53 ++++++++++- .../Feature/Commands/KeyCommandsTest.php | 93 +++++++++++++++++++ vscode-extension/README.md | 2 +- 11 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 movez/app/Commands/Key/KeyExportCommand.php create mode 100644 movez/app/Commands/Key/KeyFingerprintCommand.php create mode 100644 movez/app/Commands/Key/KeyImportCommand.php create mode 100644 movez/tests/Feature/Commands/KeyCommandsTest.php diff --git a/README.md b/README.md index 19b0352..f869ec1 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ movez show --tool=claude-code --id= | `unpack` | Extract a `.cbz` into one JSON file per session | `--input`, `--output` | | `sync:push` | Encrypt and upload sessions to your server | `--server`, `--tool`, `--project` | | `sync:pull` | Download, decrypt and import sessions | `--server`, `--tool`, `--project`, `--from-path`, `--to-path` | +| `key:export` / `key:import` | Move the encryption key to another machine | `--output` / `--file`, `--force` | +| `key:fingerprint` | Non-secret key fingerprint, to compare machines | | | `doctor` | Check requirements and detected tools | | Run `movez --help` for details. @@ -146,7 +148,13 @@ Sessions are written under `~/.claude/projects//`. On Windows they export MOVEZ_SERVER_URL=https://sync.example.com export MOVEZ_TOKEN= # or save it in ~/.movez/token ``` -3. **Share the encryption key.** Sessions are encrypted with `~/.movez/key`, which is created on your first encrypted export or push. **Copy that file to every machine you pull on.** Without it, nothing can be decrypted, including by the server. +3. **Share the encryption key.** Sessions are encrypted with `~/.movez/key`, which is created on your first encrypted export or push. **Every machine you pull on needs the same key.** Without it, nothing can be decrypted, including by the server. + ```bash + movez key:export > movez.key # machine A — treat the output like a password + movez key:import --file=movez.key # machine B (or paste it when prompted) + movez key:export | ssh machine-b movez key:import # or in one step over SSH + movez key:fingerprint # run on both; the fingerprints must match + ``` 4. **Push and pull:** ```bash movez sync:push --tool=claude-code # machine A @@ -245,7 +253,7 @@ cd electron-app && npm run typecheck # desktop app type check ## Security - Sessions are encrypted client-side with AES-256-GCM before export or sync. The server stores only ciphertext. -- The key lives at `~/.movez/key` (0600 on macOS/Linux) and never leaves your machines unless you copy it. +- The key lives at `~/.movez/key` (0600 on macOS/Linux) and only leaves your machine when you run `movez key:export`. `key:import` never accepts the key as a command-line argument. - API tokens are stored server-side as SHA-256 hashes. - No telemetry. MoveZ only talks to the sync server you configure. diff --git a/docs/extension-setup.md b/docs/extension-setup.md index 36ba42d..863730b 100644 --- a/docs/extension-setup.md +++ b/docs/extension-setup.md @@ -64,7 +64,7 @@ Click the MoveZ icon in the activity bar. The view lists sessions belonging to t | **MoveZ: Set Sync Token** | Saves (or clears, if left empty) the sync API token. | | **MoveZ: Refresh Sessions** | Reloads the Sessions view. | -Sync needs the same `~/.movez/key` on every machine. See [sync-server-setup.md](sync-server-setup.md). +Sync needs the same encryption key on every machine: run `movez key:export` on one and `movez key:import` on the others. See [sync-server-setup.md](sync-server-setup.md). --- diff --git a/docs/sync-server-setup.md b/docs/sync-server-setup.md index 72999e8..30ba29d 100644 --- a/docs/sync-server-setup.md +++ b/docs/sync-server-setup.md @@ -111,7 +111,13 @@ export MOVEZ_SERVER_URL=https://sync.example.com export MOVEZ_TOKEN= # or save it in ~/.movez/token ``` -Copy `~/.movez/key` from the first machine to the others. **Without the same key, pulled sessions can't be decrypted.** +Every machine needs the same encryption key. **Without it, pulled sessions can't be decrypted.** + +```bash +movez key:export > movez.key # first machine +movez key:import --file=movez.key # every other machine +movez key:fingerprint # must print the same value everywhere +``` ```bash movez sync:push --tool=claude-code # upload (replaces the previous upload) diff --git a/movez/app/Commands/DoctorCommand.php b/movez/app/Commands/DoctorCommand.php index 3c000b4..4e88692 100644 --- a/movez/app/Commands/DoctorCommand.php +++ b/movez/app/Commands/DoctorCommand.php @@ -37,7 +37,7 @@ public function handle(ToolDetector $detector, Encryptor $encryptor): int $this->line(''); $this->line($encryptor->hasKey() - ? " Encryption key: {$keyPath}" + ? " Encryption key: {$keyPath} (fingerprint {$encryptor->fingerprint()})" : " Encryption key: not created yet (it is generated on first encrypted export or sync:push)"); // Detected tools diff --git a/movez/app/Commands/Key/KeyExportCommand.php b/movez/app/Commands/Key/KeyExportCommand.php new file mode 100644 index 0000000..0ab53ce --- /dev/null +++ b/movez/app/Commands/Key/KeyExportCommand.php @@ -0,0 +1,45 @@ +hasKey(); + $key = 'movez-key:' . $encryptor->exportKey(); + $output = $this->option('output'); + + // Everything except the key goes to stderr, so stdout can be piped: + // movez key:export | ssh other-machine movez key:import + $stderr = $this->getOutput()->getErrorStyle(); + + if ($created) { + $stderr->writeln("No key existed yet — created a new one at {$encryptor->keyPath()}"); + } + + if (is_string($output) && $output !== '') { + touch($output); + chmod($output, 0600); + file_put_contents($output, $key . PHP_EOL); + $stderr->writeln("Key written to {$output}"); + } else { + $this->line($key); + } + + $stderr->writeln( + "Fingerprint {$encryptor->fingerprint()}. Treat this key like a password: anyone with it can decrypt your sessions." + ); + + return self::SUCCESS; + } +} diff --git a/movez/app/Commands/Key/KeyFingerprintCommand.php b/movez/app/Commands/Key/KeyFingerprintCommand.php new file mode 100644 index 0000000..9d400b5 --- /dev/null +++ b/movez/app/Commands/Key/KeyFingerprintCommand.php @@ -0,0 +1,27 @@ +fingerprint(); + + if ($fingerprint === null) { + $this->warn("No encryption key at {$encryptor->keyPath()} yet. Create one with an encrypted export, or run `movez key:import`."); + return self::FAILURE; + } + + $this->line($fingerprint); + return self::SUCCESS; + } +} diff --git a/movez/app/Commands/Key/KeyImportCommand.php b/movez/app/Commands/Key/KeyImportCommand.php new file mode 100644 index 0000000..5ac63fd --- /dev/null +++ b/movez/app/Commands/Key/KeyImportCommand.php @@ -0,0 +1,45 @@ +option('file'); + if (is_string($file) && $file !== '') { + if (!is_readable($file)) { + $this->error("Cannot read {$file}"); + return self::FAILURE; + } + $key = (string) file_get_contents($file); + } elseif (!stream_isatty(STDIN)) { + $key = (string) stream_get_contents(STDIN); // piped: movez key:export | movez key:import + } else { + $key = (string) $this->secret('Paste the key printed by `movez key:export`'); + } + + try { + $encryptor->importKey($key, (bool) $this->option('force')); + } catch (\RuntimeException $e) { + $this->error($e->getMessage()); + return self::FAILURE; + } + + $this->info("Key installed at {$encryptor->keyPath()}"); + $this->line("Fingerprint {$encryptor->fingerprint()} — it should match `movez key:fingerprint` on the other machine."); + + return self::SUCCESS; + } +} diff --git a/movez/app/Commands/Sync/SyncPushCommand.php b/movez/app/Commands/Sync/SyncPushCommand.php index 642597a..571f9f7 100644 --- a/movez/app/Commands/Sync/SyncPushCommand.php +++ b/movez/app/Commands/Sync/SyncPushCommand.php @@ -52,7 +52,7 @@ public function handle(ToolDetector $detector, Encryptor $encryptor): int } $this->info("Pushed {$sessions->count()} session(s) from {$toolName} to {$baseUrl}"); - $this->line("Pull on another machine needs the same key: copy {$encryptor->keyPath()} there."); + $this->line("Pulling on another machine needs the same key: run `movez key:export` here and `movez key:import` there (fingerprint {$encryptor->fingerprint()})."); return self::SUCCESS; } } diff --git a/movez/app/Services/Encryptor.php b/movez/app/Services/Encryptor.php index 7b6c4a7..c89a02b 100644 --- a/movez/app/Services/Encryptor.php +++ b/movez/app/Services/Encryptor.php @@ -59,7 +59,7 @@ public function decrypt(string $encoded): string // would hide the real problem (key not copied from the source machine). if (!$this->hasKey()) { throw new RuntimeException( - "No encryption key found at {$this->keyPath}. Copy ~/.movez/key from the machine that encrypted the data." + "No encryption key found at {$this->keyPath}. Run `movez key:export` on the machine that encrypted the data, then `movez key:import` here." ); } @@ -83,6 +83,57 @@ public function decrypt(string $encoded): string return $pt; } + /** The key as 64 hex characters, for moving it to another machine. */ + public function exportKey(): string + { + return bin2hex($this->getOrCreateKey()); + } + + /** + * Short, non-secret identifier of the key. Two machines can compare + * fingerprints to check they hold the same key without revealing it. + */ + public function fingerprint(): ?string + { + if (!$this->hasKey()) { + return null; + } + + return implode(':', str_split(substr(hash('sha256', 'movez-key-fingerprint:' . $this->readKey()), 0, 16), 4)); + } + + /** + * Install a key exported from another machine. + * + * @throws RuntimeException when the key is malformed, or a different key exists and $force is false + */ + public function importKey(string $hex, bool $force = false): void + { + $hex = strtolower(trim($hex)); + if (str_starts_with($hex, 'movez-key:')) { + $hex = substr($hex, strlen('movez-key:')); + } + + if (strlen($hex) !== 64 || !ctype_xdigit($hex)) { + throw new RuntimeException('Invalid key: expected 64 hex characters (as printed by `movez key:export`)'); + } + + if ($this->hasKey() && !$force && !hash_equals(bin2hex($this->readKey()), $hex)) { + throw new RuntimeException( + "A different key already exists at {$this->keyPath}. Bundles encrypted with it can't be read after replacing it. Use --force to replace it." + ); + } + + $dir = dirname($this->keyPath); + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + + touch($this->keyPath); + chmod($this->keyPath, 0600); + file_put_contents($this->keyPath, $hex); + } + private function readKey(): string { $hex = trim((string) file_get_contents($this->keyPath)); diff --git a/movez/tests/Feature/Commands/KeyCommandsTest.php b/movez/tests/Feature/Commands/KeyCommandsTest.php new file mode 100644 index 0000000..422d6b6 --- /dev/null +++ b/movez/tests/Feature/Commands/KeyCommandsTest.php @@ -0,0 +1,93 @@ + $path]); + app()->forgetInstance(Encryptor::class); + + return app(Encryptor::class); +} + +it('moves a key between machines so the second one can decrypt', function (): void { + withTempDir(function (string $dir): void { + // Machine A encrypts something and exports its key + $machineA = useKeyAt($dir . '/a/key'); + $ciphertext = $machineA->encrypt('secret session'); + + $this->artisan('key:export', ['--output' => $dir . '/transfer.key'])->assertExitCode(0); + expect(file_get_contents($dir . '/transfer.key'))->toStartWith('movez-key:'); + + // Machine B imports it and can decrypt + useKeyAt($dir . '/b/key'); + $this->artisan('key:import', ['--file' => $dir . '/transfer.key']) + ->expectsOutputToContain('Key installed') + ->assertExitCode(0); + + $machineB = app(Encryptor::class); + expect($machineB->decrypt($ciphertext))->toBe('secret session') + ->and($machineB->fingerprint())->toBe($machineA->fingerprint()); + }); +}); + +it('refuses to replace a different key without --force', function (): void { + withTempDir(function (string $dir): void { + $other = new Encryptor($dir . '/other'); + file_put_contents($dir . '/other.key', $other->exportKey()); + + $mine = useKeyAt($dir . '/mine'); + $mine->getOrCreateKey(); + $original = $mine->fingerprint(); + + $this->artisan('key:import', ['--file' => $dir . '/other.key']) + ->expectsOutputToContain('--force') + ->assertExitCode(1); + expect(app(Encryptor::class)->fingerprint())->toBe($original); + + $this->artisan('key:import', ['--file' => $dir . '/other.key', '--force' => true])->assertExitCode(0); + expect(app(Encryptor::class)->fingerprint())->toBe($other->fingerprint()); + }); +}); + +it('re-importing the same key is a no-op success', function (): void { + withTempDir(function (string $dir): void { + $enc = useKeyAt($dir . '/key'); + file_put_contents($dir . '/same.key', 'movez-key:' . $enc->exportKey()); + + $this->artisan('key:import', ['--file' => $dir . '/same.key'])->assertExitCode(0); + }); +}); + +it('rejects malformed keys', function (): void { + withTempDir(function (string $dir): void { + useKeyAt($dir . '/key'); + file_put_contents($dir . '/bad.key', 'not-a-key'); + + $this->artisan('key:import', ['--file' => $dir . '/bad.key']) + ->expectsOutputToContain('Invalid key') + ->assertExitCode(1); + + expect(file_exists($dir . '/key'))->toBeFalse(); + }); +}); + +it('key:fingerprint reports a missing key', function (): void { + withTempDir(function (string $dir): void { + useKeyAt($dir . '/none/key'); + + $this->artisan('key:fingerprint')->assertExitCode(1); + }); +}); + +it('fingerprints are stable and do not reveal the key', function (): void { + withTempDir(function (string $dir): void { + $enc = new Encryptor($dir . '/key'); + $enc->getOrCreateKey(); + + expect($enc->fingerprint())->toMatch('/^[0-9a-f]{4}(:[0-9a-f]{4}){3}$/') + ->and($enc->fingerprint())->toBe($enc->fingerprint()) + ->and($enc->exportKey())->not->toContain(str_replace(':', '', (string) $enc->fingerprint())); + }); +}); diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 24d7c02..4fb24d0 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -32,7 +32,7 @@ CLI output and errors are logged to the **MoveZ** output channel. ## Sync between machines -Bundles and sync payloads are encrypted with the key at `~/.movez/key`. Copy that file to every machine you want to pull on, or decryption will fail. +Bundles and sync payloads are encrypted with the key at `~/.movez/key`. Every machine you pull on needs the same key: run `movez key:export` on one machine and `movez key:import` on the others, or decryption will fail. ## Install from VSIX