Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ movez show --tool=claude-code --id=<session-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 <command> --help` for details.
Expand Down Expand Up @@ -146,7 +148,13 @@ Sessions are written under `~/.claude/projects/<encoded-path>/`. On Windows they
export MOVEZ_SERVER_URL=https://sync.example.com
export MOVEZ_TOKEN=<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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/extension-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
8 changes: 7 additions & 1 deletion docs/sync-server-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ export MOVEZ_SERVER_URL=https://sync.example.com
export MOVEZ_TOKEN=<token from 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)
Expand Down
2 changes: 1 addition & 1 deletion movez/app/Commands/DoctorCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions movez/app/Commands/Key/KeyExportCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);

namespace App\Commands\Key;

use App\Services\Encryptor;
use LaravelZero\Framework\Commands\Command;

class KeyExportCommand extends Command
{
protected $signature = 'key:export
{--output= : Write the key to this file (created with 0600 permissions) instead of printing it}';

protected $description = 'Print the encryption key so it can be imported on another machine';

public function handle(Encryptor $encryptor): int
{
$created = !$encryptor->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("<comment>No key existed yet — created a new one at {$encryptor->keyPath()}</comment>");
}

if (is_string($output) && $output !== '') {
touch($output);
chmod($output, 0600);
file_put_contents($output, $key . PHP_EOL);
$stderr->writeln("<info>Key written to {$output}</info>");
} else {
$this->line($key);
}

$stderr->writeln(
"<comment>Fingerprint {$encryptor->fingerprint()}. Treat this key like a password: anyone with it can decrypt your sessions.</comment>"
);

return self::SUCCESS;
}
}
27 changes: 27 additions & 0 deletions movez/app/Commands/Key/KeyFingerprintCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);

namespace App\Commands\Key;

use App\Services\Encryptor;
use LaravelZero\Framework\Commands\Command;

class KeyFingerprintCommand extends Command
{
protected $signature = 'key:fingerprint';

protected $description = 'Show a non-secret fingerprint of the encryption key, to compare machines';

public function handle(Encryptor $encryptor): int
{
$fingerprint = $encryptor->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;
}
}
45 changes: 45 additions & 0 deletions movez/app/Commands/Key/KeyImportCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);

namespace App\Commands\Key;

use App\Services\Encryptor;
use LaravelZero\Framework\Commands\Command;

class KeyImportCommand extends Command
{
protected $signature = 'key:import
{--file= : Read the key from this file}
{--force : Replace an existing, different key}';

protected $description = 'Install an encryption key exported with key:export on another machine';

public function handle(Encryptor $encryptor): int
{
// Never accept the key as an argument: arguments are visible in process lists
$file = $this->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;
}
}
2 changes: 1 addition & 1 deletion movez/app/Commands/Sync/SyncPushCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
53 changes: 52 additions & 1 deletion movez/app/Services/Encryptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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."
);
}

Expand All @@ -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));
Expand Down
93 changes: 93 additions & 0 deletions movez/tests/Feature/Commands/KeyCommandsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);

use App\Services\Encryptor;

function useKeyAt(string $path): Encryptor
{
config(['movez.key_path' => $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()));
});
});
2 changes: 1 addition & 1 deletion vscode-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading