Encrypted cross-device sync for Claude Code sessions
| Home | Architecture | Security |
When you run claude-sync init, an interactive wizard guides you through setup:
┌─────────────────────────────────────────────────────────┐
│ ? Choose your cloud storage provider: │
│ > Cloudflare R2 (recommended - free tier: 10GB) │
│ Amazon S3 │
│ Google Cloud Storage │
└─────────────────────────────────────────────────────────┘
Cloudflare R2:
┌─────────────────────────────────────────┐
│ Cloudflare R2 Setup │
├─────────────────────────────────────────┤
│ ? Account ID: abc123def │
│ ? Access Key ID: xxxxxxxxxx │
│ ? Secret Access Key: **************** │
│ ? Bucket name: claude-sync │
└─────────────────────────────────────────┘
Amazon S3:
┌─────────────────────────────────────────┐
│ Amazon S3 Setup │
├─────────────────────────────────────────┤
│ ? Access Key ID: AKIAXXXXXXX │
│ ? Secret Access Key: **************** │
│ ? AWS Region: us-east-1 │
│ ? Bucket name: claude-sync │
└─────────────────────────────────────────┘
Google Cloud Storage:
┌─────────────────────────────────────────┐
│ Google Cloud Storage Setup │
├─────────────────────────────────────────┤
│ ? GCP Project ID: my-project-123 │
│ ? Authentication method: │
│ > Application Default Credentials │
│ Service Account JSON file │
│ ? Bucket name: claude-sync │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ? Choose encryption key method: │
│ > Passphrase (recommended) - same key on all devices │
│ Random key - must copy key file to other devices │
└─────────────────────────────────────────────────────────┘
│
▼ (if passphrase)
┌─────────────────────────────────────────────────────────┐
│ ? Passphrase (min 8 chars): ******** │
│ ? Confirm passphrase: ******** │
└─────────────────────────────────────────────────────────┘
[3/3] Test Connection
✓ Connected to 'claude-sync'
Setup complete!
Run 'claude-sync push' to upload your sessions
Run 'claude-sync pull' on other devices to sync
When you choose passphrase-based encryption:
Passphrase: "my-secret-phrase"
│
▼
┌────────────────────────────────────────────────────────┐
│ Salt Generation │
│ salt = SHA256("claude-sync-v1") │
│ = fixed 32 bytes │
│ │
│ Why fixed salt? │
│ - Same passphrase on different devices = same key │
│ - No need to sync salt between devices │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Argon2id KDF │
│ │
│ Parameters: │
│ - Memory: 64 MB │
│ - Iterations: 3 │
│ - Parallelism: 4 threads │
│ - Output: 32 bytes │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Scalar Clamping (RFC 7748) │
│ │
│ key[0] &= 248 │
│ key[31] &= 127 │
│ key[31] |= 64 │
│ │
│ Required for X25519 security │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Bech32 Encoding │
│ Prefix: AGE-SECRET-KEY-1 │
│ Output: age-compatible secret key string │
│ │
│ Saved to: ~/.claude-sync/age-key.txt │
└────────────────────────────────────────────────────────┘
When you run claude-sync push:
for each path in SyncPaths {
if isDirectory(path) {
walkDirectory(path) // Recursively check files
} else if isFile(path) {
checkFile(path) // Compare with state
}
}
For each file:
┌─────────────────────────────────────┐
│ Read file content │
│ Calculate SHA256 hash │
│ Get file size and modification time │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Compare with SyncState │
│ │
│ if not in state → ADD │
│ if hash changed → MODIFY │
│ if in state but not on disk → DEL │
└─────────────────────────────────────┘
For each file to upload:
┌─────────────────────────────────────┐
│ Read local file │
│ path: ~/.claude/projects/foo/x.json │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Encrypt with age │
│ - Read identity from age-key.txt │
│ - Extract recipient (public key) │
│ - Encrypt content │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Upload via Storage interface │
│ storage.Upload("path.age", data) │
│ │
│ Provider-specific implementation: │
│ - R2: S3 PutObject to Cloudflare │
│ - S3: S3 PutObject to AWS │
│ - GCS: Objects.Insert │
└─────────────────────────────────────┘
↑ [1/5] projects/abc123/session.json (4.2 KB)
↑ [2/5] settings.json (512 B)
↑ [3/5] CLAUDE.md (1.1 KB)
✗ [4/5] history.jsonl (deleted)
↑ [5/5] agents/custom.json (2.3 KB)
✓ Push complete: 4 uploaded, 1 deleted
state.Files[path] = &FileState{
Path: path,
Hash: sha256Hash,
Size: fileSize,
ModTime: modificationTime,
Uploaded: time.Now(),
}
state.LastPush = time.Now()
state.Save() // Write to ~/.claude-sync/state.json
When you run claude-sync pull:
┌─────────────────────────────────────┐
│ storage.List("") │
│ │
│ Returns all objects in bucket: │
│ - Key (path.age) │
│ - Size │
│ - LastModified │
│ - ETag │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Build remote file map │
│ Strip .age extension │
│ "foo.json.age" → "foo.json" │
└─────────────────────────────────────┘
For each remote file:
┌─────────────────────────────────────────────────────────┐
│ localState = SyncState.Files[path] │
│ localFile = read ~/.claude/{path} │
│ │
│ if localFile not exists: │
│ → DOWNLOAD (new file) │
│ │
│ if localFile.hash == localState.hash: │
│ if remote.modTime > localState.uploaded: │
│ → DOWNLOAD (remote is newer) │
│ else: │
│ → SKIP (already synced) │
│ │
│ if localFile.hash != localState.hash: │
│ if remote.modTime > localState.uploaded: │
│ → CONFLICT (both changed) │
│ else: │
│ → SKIP (local is newer, push will upload) │
└─────────────────────────────────────────────────────────┘
When both local and remote have changed:
┌─────────────────────────────────────┐
│ Download remote file │
│ storage.Download("path.age") │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Decrypt content │
│ (age decryption) │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Save as conflict file: │
│ {path}.conflict.20260208-153045 │
│ │
│ Keep local file unchanged │
└─────────────────────────────────────┘
For non-conflict files:
┌─────────────────────────────────────┐
│ storage.Download("path.age") │
│ Returns encrypted bytes │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Decrypt with age │
│ - Read identity from age-key.txt │
│ - Decrypt bytes → plaintext │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Write to local file │
│ path: ~/.claude/{path} │
│ Create parent directories │
└─────────────────────────────────────┘
↓ [1/3] projects/xyz789/session.json (8.5 KB)
↓ [2/3] skills/custom-skill.json (1.2 KB)
⚠ Conflict: settings.json (saved as .conflict)
✓ Pull complete: 2 downloaded, 1 conflicts
Conflicts (both local and remote changed):
• settings.json
Local versions kept. Remote saved as .conflict files.
Run 'claude-sync conflicts' to review and resolve.
A conflict occurs when:
localHash != stateHash)remoteModTime > stateUploaded)Interactive mode (default):
claude-sync conflicts
Found 2 conflict(s):
1. settings.json
Conflict from: 20260208-153045
2. projects/abc123/session.json
Conflict from: 20260208-154512
For each conflict, choose how to resolve:
[l] Keep local [r] Keep remote [d] Show diff [s] Skip [q] Quit
[1/2] settings.json
Local: 512 B | Remote: 498 B | Conflict from: 20260208-153045
Resolve [l/r/d/s/q]: d
--- Local
+++ Remote (conflict)
@@ -1,5 +1,5 @@
{
- "theme": "dark",
+ "theme": "light",
"autoSave": true
}
Resolve [l/r/d/s/q]: l
✓ Kept local version
✓ Resolved 1 of 2 conflict(s)
Batch mode:
claude-sync conflicts --keep local # Keep all local versions
claude-sync conflicts --keep remote # Keep all remote versions
Keep Local:
1. Delete {path}.conflict.{timestamp}
2. No changes to local file
3. Next push will upload local version
Keep Remote:
1. mv {path}.conflict.{timestamp} → {path}
2. Delete conflict file
3. Update state with new hash
~/.claude-sync/state.json
{
"files": {
"projects/abc123/session.json": {
"path": "projects/abc123/session.json",
"hash": "sha256:a1b2c3d4e5f6...",
"size": 4096,
"modTime": "2026-02-08T10:30:00Z",
"uploaded": "2026-02-08T10:31:00Z"
},
"settings.json": {
"path": "settings.json",
"hash": "sha256:9f8e7d6c5b4a...",
"size": 512,
"modTime": "2026-02-07T15:00:00Z",
"uploaded": "2026-02-07T15:01:00Z"
}
},
"lastSync": "2026-02-08T10:31:00Z",
"deviceId": "macbook-pro.local",
"lastPush": "2026-02-08T10:31:00Z",
"lastPull": "2026-02-08T09:00:00Z"
}
claude-sync status works without networkWhen you run claude-sync update:
┌─────────────────────────────────────┐
│ Query GitHub API │
│ GET /repos/tawanorg/claude-sync/ │
│ releases/latest │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Compare versions │
│ Current: from build ldflags │
│ Latest: from GitHub release │
└─────────────────────────────────────┘
│
▼ (if update available)
┌─────────────────────────────────────┐
│ Download binary │
│ Based on GOOS/GOARCH: │
│ - darwin-arm64 │
│ - darwin-amd64 │
│ - linux-amd64 │
│ - linux-arm64 │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Replace binary │
│ 1. Write new binary as .new │
│ 2. Rename current → .old │
│ 3. Rename .new → current │
│ 4. Delete .old │
└─────────────────────────────────────┘
Output:
⋯ Checking for updates...
↑ New version available: v0.3.2 → v0.4.0
⋯ Downloading claude-sync-darwin-arm64...
⋯ Installing update...
✓ Updated to v0.4.0
Restart claude-sync to use the new version
For automatic syncing, add to your shell profile:
# ~/.zshrc or ~/.bashrc
# Auto-pull on shell start (background, quiet)
if command -v claude-sync &> /dev/null; then
claude-sync pull -q &
fi
# Auto-push on shell exit (quiet)
trap 'claude-sync push -q' EXIT
This ensures: