Skip to content

Commit 1d14e5c

Browse files
committed
feat: implement encryption key rotation and enhance security for credential syncing
1 parent 260748a commit 1d14e5c

13 files changed

Lines changed: 284 additions & 53 deletions

File tree

docs/DEV-SETUP.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,24 @@ After first run, the plugin creates `~/.config/opencode/opencode-sync.json`:
150150
}
151151
```
152152

153+
## Encryption Key Rotation
154+
155+
To rotate encryption keys without losing access to credentials:
156+
157+
1. Add both keys to config on ALL machines:
158+
```json
159+
{
160+
"encryptionKey": "new-key-here",
161+
"oldEncryptionKey": "old-key-here"
162+
}
163+
```
164+
165+
2. Restart OpenCode on each machine - it will:
166+
- Decrypt using `oldEncryptionKey` (fallback)
167+
- Re-encrypt using `encryptionKey` on next push
168+
169+
3. After all machines have synced, remove `oldEncryptionKey`
170+
153171
## Troubleshooting
154172

155173
### Plugin not loading

docs/LLM-INSTALL.md

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,35 @@ Guide the user step by step:
4141
8. IMPORTANT: Tell user to copy the token immediately (starts with "github_pat_")
4242
9. Ask user: "Please paste your token here" - Save this as TOKEN
4343
44-
### Step 4: Generate Encryption Key
45-
Tell the user:
46-
"I'll generate a secure encryption key for you. This encrypts your data before uploading to GitHub."
44+
### Step 4: Encryption Key (Optional)
45+
Ask the user:
46+
"Do you want to encrypt your credentials (auth tokens) before syncing? This adds extra security but requires the same key on all machines."
4747
48-
Generate a random 32-character alphanumeric string for the user, or ask them to provide their own (minimum 16 characters).
49-
Save this as ENCRYPTION_KEY.
48+
If YES:
49+
- Generate a random 32-character alphanumeric string, or let user provide their own (minimum 16 characters)
50+
- Save this as ENCRYPTION_KEY
51+
- IMPORTANT: Tell user to save this key securely. If lost, encrypted credentials cannot be recovered.
52+
- WARNING: All machines must use the same key. Adding encryption later will break sync on machines without the key.
5053
51-
IMPORTANT: Tell user to save this key securely. If lost, synced data cannot be decrypted.
54+
If NO:
55+
- Skip this step. Credentials will sync in plain text (still protected by GitHub's private repo authentication).
5256
5357
### Step 5: Create Configuration
5458
Create or update the OpenCode config file with these values:
5559
60+
Without encryption:
61+
{
62+
"plugins": {
63+
"oc-sync": {
64+
"token": "<TOKEN from Step 3>",
65+
"owner": "<OWNER from Step 2>",
66+
"repo": "<REPO from Step 2>",
67+
"branch": "main"
68+
}
69+
}
70+
}
71+
72+
With encryption:
5673
{
5774
"plugins": {
5875
"oc-sync": {
@@ -79,13 +96,38 @@ If user reports errors:
7996
- "401 Unauthorized": Token is invalid or expired. Generate a new one.
8097
- "403 Forbidden": Token lacks required permissions. Recreate with Contents read/write.
8198
- "Encryption error": Key is less than 16 characters or contains invalid characters.
99+
- "Decryption failed": Wrong encryption key or data was synced with a different key.
100+
101+
### Key Rotation (Advanced)
102+
If user needs to change their encryption key:
103+
104+
1. On ALL machines, update config to include BOTH keys:
105+
{
106+
"plugins": {
107+
"oc-sync": {
108+
"token": "...",
109+
"owner": "...",
110+
"repo": "...",
111+
"encryptionKey": "<NEW_KEY>",
112+
"oldEncryptionKey": "<OLD_KEY>"
113+
}
114+
}
115+
}
116+
117+
2. Restart OpenCode on each machine. The plugin will:
118+
- Decrypt using old key (fallback)
119+
- Re-encrypt using new key on push
120+
121+
3. After ALL machines have synced with the new key, remove oldEncryptionKey from config.
122+
123+
IMPORTANT: Do NOT remove oldEncryptionKey until all machines have synced at least once with the new configuration.
82124
83125
### Summary
84126
After setup, confirm with user:
85127
- GitHub username: [OWNER]
86128
- Repository: [REPO] (private)
87129
- Token: configured (never display full token)
88-
- Encryption: enabled with [length]-character key
130+
- Encryption: enabled/disabled
89131
- Status: Ready to sync
90132
```
91133

src/plugin/state-manager.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import type { PathConfig } from '../types/paths.js';
88
import { DEFAULT_CONFIG, type SyncConfig, type SyncCategory } from '../types/index.js';
99
import { SyncEngine } from '../sync/engine/index.js';
10-
import { RepoStorageBackend } from '../storage/index.js';
10+
import { RepoStorageBackend, type RepoClientConfig } from '../storage/index.js';
1111
import { createFileWatcher } from '../sync/watcher/index.js';
1212
import { loadConfig, saveConfig, loadLocalState, generateMachineId } from '../data/index.js';
1313
import type { PluginState } from './types.js';
@@ -37,18 +37,35 @@ export function initializeEngine(): void {
3737
if (!state.config) return;
3838
if (!state.config.repoOwner || !state.config.repoName) return;
3939

40-
const backend = new RepoStorageBackend({
40+
const backendConfig: RepoClientConfig = {
4141
token: state.config.token,
4242
owner: state.config.repoOwner,
4343
repo: state.config.repoName,
44-
});
45-
46-
state.engine = new SyncEngine({
47-
config: state.config,
48-
backend,
49-
localState: state.localState,
50-
passphrase: state.passphrase ?? undefined,
51-
});
44+
};
45+
if (state.config.branch) {
46+
backendConfig.branch = state.config.branch;
47+
}
48+
const backend = new RepoStorageBackend(backendConfig);
49+
50+
// Support key rotation: oldEncryptionKey from config is used as fallback for decryption
51+
const oldKey = state.oldPassphrase ?? state.config.oldEncryptionKey;
52+
53+
state.engine = new SyncEngine(
54+
oldKey
55+
? {
56+
config: state.config,
57+
backend,
58+
localState: state.localState,
59+
passphrase: state.passphrase ?? undefined,
60+
oldPassphrase: oldKey,
61+
}
62+
: {
63+
config: state.config,
64+
backend,
65+
localState: state.localState,
66+
passphrase: state.passphrase ?? undefined,
67+
}
68+
);
5269

5370
state.isInitialized = true;
5471
}

src/plugin/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface PluginState {
1414
engine: SyncEngine | null;
1515
watcher: FileWatcher | null;
1616
passphrase: string | null;
17+
/** Previous encryption key for key rotation */
18+
oldPassphrase: string | null;
1719
isInitialized: boolean;
1820
}
1921

@@ -24,6 +26,7 @@ export function createInitialState(): PluginState {
2426
engine: null,
2527
watcher: null,
2628
passphrase: null,
29+
oldPassphrase: null,
2730
isInitialized: false,
2831
};
2932
}

src/storage/repo/repo-client.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface RepoClientConfig {
2020
token: string;
2121
owner: string;
2222
repo: string;
23+
/** Branch to use for sync (auto-detected if not specified, created if missing) */
24+
branch?: string;
2325
maxRetries?: number;
2426
retryDelayMs?: number;
2527
}
@@ -61,7 +63,8 @@ export class RepoStorageBackend implements StorageBackend {
6163
private readonly baseUrl: string;
6264
private readonly maxRetries: number;
6365
private readonly retryDelayMs: number;
64-
private detectedBranch: 'main' | 'master' | null = null;
66+
private readonly configuredBranch: string | undefined;
67+
private detectedBranch: string | null = null;
6568

6669
constructor(config: RepoClientConfig) {
6770
this.token = config.token;
@@ -70,6 +73,7 @@ export class RepoStorageBackend implements StorageBackend {
7073
this.baseUrl = `https://api.github.com/repos/${config.owner}/${config.repo}`;
7174
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
7275
this.retryDelayMs = config.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
76+
this.configuredBranch = config.branch;
7377
}
7478

7579
public async exists(): Promise<boolean> {
@@ -94,7 +98,7 @@ export class RepoStorageBackend implements StorageBackend {
9498

9599
// For files >1MB, GitHub returns empty content and provides download_url
96100
if (!data.content && data.size > 1000000) {
97-
const branch = await this.getDefaultBranch();
101+
const branch = await this.getBranch();
98102
const downloadUrl = `https://raw.githubusercontent.com/${this.owner}/${this.repo}/${branch}/${fullPath}`;
99103
const downloadRes = await fetchWithRetry(
100104
downloadUrl,
@@ -187,9 +191,20 @@ export class RepoStorageBackend implements StorageBackend {
187191
}
188192
}
189193

190-
private async getDefaultBranch(): Promise<'main' | 'master'> {
194+
private async getBranch(): Promise<string> {
191195
if (this.detectedBranch) return this.detectedBranch;
192196

197+
// If branch is configured, use it (create if missing)
198+
if (this.configuredBranch) {
199+
const branchExists = await this.branchExists(this.configuredBranch);
200+
if (!branchExists) {
201+
await this.createBranch(this.configuredBranch);
202+
}
203+
this.detectedBranch = this.configuredBranch;
204+
return this.configuredBranch;
205+
}
206+
207+
// Auto-detect: try main first, then master
193208
const res = await this.fetch('/git/ref/heads/main');
194209
if (res.ok) {
195210
this.detectedBranch = 'main';
@@ -206,8 +221,54 @@ export class RepoStorageBackend implements StorageBackend {
206221
return 'main';
207222
}
208223

224+
private async branchExists(branch: string): Promise<boolean> {
225+
const res = await this.fetch(`/git/ref/heads/${branch}`);
226+
return res.ok;
227+
}
228+
229+
private async createBranch(branch: string): Promise<void> {
230+
// Get SHA from default branch (main or master)
231+
const defaultBranch = await this.detectDefaultBranch();
232+
const defaultRes = await this.fetch(`/git/ref/heads/${defaultBranch}`);
233+
if (!defaultRes.ok) {
234+
throw new RepoApiError(
235+
`Cannot find default branch (${defaultBranch}) to create new branch from`,
236+
404
237+
);
238+
}
239+
const defaultRef = (await defaultRes.json()) as GitRef;
240+
const baseSha = defaultRef.object.sha;
241+
242+
// Create new branch ref
243+
const body = JSON.stringify({
244+
ref: `refs/heads/${branch}`,
245+
sha: baseSha,
246+
});
247+
248+
const res = await this.fetch('/git/refs', { method: 'POST', body });
249+
if (!res.ok) {
250+
const errBody = (await res.json().catch(() => ({}))) as { message?: string };
251+
throw new RepoApiError(
252+
errBody.message ?? `Failed to create branch: ${branch}`,
253+
res.status,
254+
errBody
255+
);
256+
}
257+
}
258+
259+
/** Detect default branch without creating - used as base for new branches */
260+
private async detectDefaultBranch(): Promise<'main' | 'master'> {
261+
const res = await this.fetch('/git/ref/heads/main');
262+
if (res.ok) return 'main';
263+
264+
const masterRes = await this.fetch('/git/ref/heads/master');
265+
if (masterRes.ok) return 'master';
266+
267+
return 'main';
268+
}
269+
209270
private async getHeadSha(): Promise<string> {
210-
const branch = await this.getDefaultBranch();
271+
const branch = await this.getBranch();
211272
const res = await this.fetch(`/git/ref/heads/${branch}`);
212273
if (!res.ok) {
213274
throw new RepoApiError(`Cannot find ${branch} branch`, 404);
@@ -314,7 +375,7 @@ export class RepoStorageBackend implements StorageBackend {
314375
private async updateRef(commitSha: string): Promise<void> {
315376
// force: false ensures proper CAS - GitHub will reject if HEAD moved
316377
const body = JSON.stringify({ sha: commitSha, force: false });
317-
const branch = await this.getDefaultBranch();
378+
const branch = await this.getBranch();
318379

319380
const res = await this.fetch(`/git/refs/heads/${branch}`, { method: 'PATCH', body });
320381

src/sync/engine/sync-engine.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,22 @@ export class SyncEngine {
3535
private readonly config: SyncEngineOptions['config'];
3636
private localState: LocalSyncState | null;
3737
private readonly passphrase: string | undefined;
38+
private readonly oldPassphrase: string | undefined;
3839

3940
constructor(options: SyncEngineOptions) {
4041
this.backend = options.backend;
4142
this.config = options.config;
4243
this.localState = options.localState;
4344
this.passphrase = options.passphrase;
45+
this.oldPassphrase = options.oldPassphrase;
46+
}
47+
48+
/** Get crypto options for encryption/decryption with key rotation support */
49+
private getCryptoOptions(): { passphrase?: string; oldPassphrase?: string } {
50+
const result: { passphrase?: string; oldPassphrase?: string } = {};
51+
if (this.passphrase) result.passphrase = this.passphrase;
52+
if (this.oldPassphrase) result.oldPassphrase = this.oldPassphrase;
53+
return result;
4454
}
4555

4656
public async sync(localData: CategoryData[]): Promise<SyncResult> {
@@ -144,7 +154,7 @@ export class SyncEngine {
144154
localData,
145155
this.config,
146156
this.localState,
147-
this.passphrase,
157+
this.getCryptoOptions(),
148158
existingFilenames
149159
);
150160
const storageFiles: Record<string, string | null> = {};
@@ -169,7 +179,7 @@ export class SyncEngine {
169179
manifest,
170180
storageFiles,
171181
this.config.sync,
172-
this.passphrase,
182+
this.getCryptoOptions(),
173183
this.backend
174184
);
175185
this.localState = buildLocalState(
@@ -191,7 +201,7 @@ export class SyncEngine {
191201
remoteManifest,
192202
storageFiles,
193203
localState: this.localState,
194-
passphrase: this.passphrase,
204+
passphrase: this.getCryptoOptions(),
195205
machineId: this.config.machineId,
196206
backend: this.backend,
197207
});

src/sync/engine/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,26 @@
66

77
import type { SyncConfig, LocalSyncState } from '../../types/index.js';
88
import type { StorageBackend } from '../../storage/index.js';
9+
import type { CryptoOptions } from '../operations/types.js';
910

1011
export interface SyncEngineOptions {
1112
config: SyncConfig;
1213
backend: StorageBackend;
1314
localState: LocalSyncState | null;
1415
passphrase: string | undefined;
16+
/** Previous encryption key for key rotation */
17+
oldPassphrase?: string;
18+
}
19+
20+
/** Get crypto options from engine options */
21+
export function getCryptoOptions(options: {
22+
passphrase?: string;
23+
oldPassphrase?: string;
24+
}): CryptoOptions {
25+
const result: CryptoOptions = {};
26+
if (options.passphrase) result.passphrase = options.passphrase;
27+
if (options.oldPassphrase) result.oldPassphrase = options.oldPassphrase;
28+
return result;
1529
}
1630

1731
export const MANIFEST_FILENAME = 'manifest.json';

0 commit comments

Comments
 (0)