Skip to content

Commit 7d84e92

Browse files
benibenjCopilot
andcommitted
Offer prompted migration of legacy PAT credentials
Look up the previous PAT only when an interactive command needs a publisher missing from the current native store. Require explicit consent before copying and verifying that publisher's credential, leaving legacy entries untouched. Isolate the flow in a migration-aware store decorator, serialize native writes, and cover the prompted behavior with unit and Windows/Linux credential-store tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 75cae44 commit 7d84e92

12 files changed

Lines changed: 1491 additions & 7 deletions

‎.github/workflows/ci.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,25 @@ jobs:
3030

3131
- name: Run tests
3232
run: npm test
33+
34+
- name: Install Linux credential-test prerequisites
35+
if: runner.os == 'Linux'
36+
run: sudo apt-get update && sudo apt-get install -y dbus-x11 gnome-keyring libsecret-tools
37+
38+
- name: Test Linux credential migration in an isolated keyring
39+
if: runner.os == 'Linux'
40+
shell: bash
41+
run: |
42+
test_home="$(mktemp -d)"
43+
trap 'rm -rf "$test_home"' EXIT
44+
export HOME="$test_home"
45+
export XDG_DATA_HOME="$test_home/.local/share"
46+
export XDG_RUNTIME_DIR="$test_home/run"
47+
mkdir -p "$XDG_RUNTIME_DIR"
48+
chmod 700 "$XDG_RUNTIME_DIR"
49+
export VSCE_TEST_KEYTAR_HOME="$test_home"
50+
export VSCE_TEST_KEYTAR_LINUX=1
51+
dbus-run-session -- bash -e -c '
52+
printf "%s" "vsce-test-keyring" | gnome-keyring-daemon --unlock --components=secrets
53+
npm test -- --grep "Native keytar migration \(Linux\)"
54+
'

‎README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ Read the [**Documentation**](https://code.visualstudio.com/api/working-with-exte
1717

1818
In order to save credentials safely, this project uses [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring), which uses the system Secret Service and falls back to the Linux kernel keyring. Setting the `VSCE_STORE=file` environment variable will revert back to the file credential store. Using the `VSCE_PAT` environment variable will also avoid using the system credential store.
1919

20+
### Upgrading saved PATs
21+
22+
When a command needs a saved Personal Access Token and the publisher has none in the current native credential store, `vsce` checks for that publisher's old `keytar` credential. If one is found in an interactive terminal, it offers:
23+
24+
```text
25+
A saved PAT for publisher 'your-publisher' was found in the previous credential store. Copy it to the new store? [y/N]
26+
```
27+
28+
Enter `Y` to copy just that publisher's PAT and continue without re-entering it. Enter `N` (or press Enter) to continue to the normal PAT prompt without copying anything. Migration is skipped in non-interactive runs such as CI.
29+
30+
- **Windows:** reads the requested Windows Credential Manager entry using the built-in Windows PowerShell. No `keytar` installation is needed.
31+
- **Linux:** requires `secret-tool` (`libsecret-tools` on Debian/Ubuntu) and access to the same desktop Secret Service used previously. You may be prompted to unlock the keyring.
32+
- **macOS:** existing Keychain entries are already compatible; no copying is necessary.
33+
34+
Existing PATs in the new store take precedence; migration does not run while listing publishers or logging out. Each copied PAT is read back before it is used. **Old keytar entries are never modified or deleted**, so older `vsce` versions can still use them. Declining or logging out does not permanently suppress the offer: the old PAT can be offered again when needed, but copying always requires fresh consent. Logout removes only the new entry and does not revoke the PAT.
35+
36+
Credential writes are serialized across `vsce` processes, and the destination is checked again after confirmation to avoid overwriting a newer PAT. The non-secret lock lives under `~/.vsce-keytar-migration`; no PATs or consent decisions are stored there. A command waits up to 30 seconds for the lock; a lock abandoned by a crashed process can be recovered after two minutes.
37+
38+
If legacy lookup or copying fails, `vsce` displays a warning and continues to the normal PAT prompt. Install the required helper/unlock the keyring and retry, or enter a PAT to save it normally. Migration helpers time out after 30 seconds. Using `VSCE_STORE=file`, `VSCE_PAT`, or `--pat` does not trigger this native-store migration; the existing plaintext-file migration is unchanged.
39+
2040
## Usage
2141

2242
```console

‎package-lock.json‎

Lines changed: 45 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"mime": "^1.6.0",
5757
"minimatch": "^10.2.6",
5858
"parse5": "^8.0.1",
59+
"proper-lockfile": "^4.1.2",
5960
"read": "^1.0.7",
6061
"semver": "^7.8.5",
6162
"tinyglobby": "^0.2.17",
@@ -72,6 +73,7 @@
7273
"@types/mocha": "^7.0.2",
7374
"@types/node": "^22.20.1",
7475
"@types/picomatch": "^4.0.3",
76+
"@types/proper-lockfile": "^4.1.4",
7577
"@types/read": "^0.0.28",
7678
"@types/semver": "^6.2.7",
7779
"@types/url-join": "^4.0.3",

‎src/keytarMigration.ts‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { createHash } from 'crypto';
2+
import * as fs from 'fs';
3+
import { homedir } from 'os';
4+
import * as path from 'path';
5+
import { lock } from 'proper-lockfile';
6+
import { LegacyMigrationError, readLegacyCredential } from './legacyCredentials';
7+
import type { IPublisher, IStore } from './store';
8+
import { log, read } from './util';
9+
import { validatePublisher } from './validation';
10+
11+
export interface ILegacyMigrationOptions {
12+
readonly serviceName?: string;
13+
readonly lockPath?: string;
14+
readonly platform?: NodeJS.Platform;
15+
readonly interactive?: boolean;
16+
readonly prompt?: (question: string) => Promise<string>;
17+
readonly readCredential?: (serviceName: string, publisherName: string) => Promise<IPublisher | undefined>;
18+
}
19+
20+
// Decorate the native store so migration policy and write synchronization stay
21+
// separate from its ordinary credential operations.
22+
export class LegacyCredentialMigration implements IStore {
23+
static wrap(store: IStore, openStore: () => Promise<IStore>, options: ILegacyMigrationOptions = {}): IStore {
24+
const platform = options.platform ?? process.platform;
25+
return platform === 'win32' || platform === 'linux'
26+
? new LegacyCredentialMigration(store, openStore, options)
27+
: store;
28+
}
29+
30+
private readonly serviceName: string;
31+
private readonly platform: NodeJS.Platform;
32+
private readonly lockPath: string;
33+
private readonly interactive: boolean;
34+
private readonly prompt: (question: string) => Promise<string>;
35+
private readonly readCredential: (service: string, name: string) => Promise<IPublisher | undefined>;
36+
37+
constructor(
38+
private store: IStore,
39+
private readonly openStore: () => Promise<IStore>,
40+
options: ILegacyMigrationOptions = {}
41+
) {
42+
this.serviceName = options.serviceName ?? 'vscode-vsce';
43+
this.platform = options.platform ?? process.platform;
44+
this.lockPath = options.lockPath ?? path.join(homedir(), '.vsce-keytar-migration',
45+
createHash('sha256').update(`${this.platform}:${this.serviceName}`).digest('hex'));
46+
// read() otherwise answers "y" in tests and non-interactive processes.
47+
this.interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.VSCE_TESTS);
48+
this.prompt = options.prompt ?? read;
49+
this.readCredential = options.readCredential
50+
?? ((service, name) => readLegacyCredential(service, name, { platform: this.platform }));
51+
}
52+
53+
get size(): number {
54+
return this.store.size;
55+
}
56+
57+
get(name: string): IPublisher | undefined {
58+
return this.findPublisher(this.store, name);
59+
}
60+
61+
async add(publisher: IPublisher): Promise<void> {
62+
await this.withLock(() => this.store.add({
63+
name: this.get(publisher.name)?.name ?? publisher.name,
64+
pat: publisher.pat,
65+
}));
66+
}
67+
68+
async delete(name: string): Promise<void> {
69+
await this.withLock(() => this.store.delete(this.get(name)?.name ?? name));
70+
}
71+
72+
[Symbol.iterator](): Iterator<IPublisher> {
73+
return this.store[Symbol.iterator]();
74+
}
75+
76+
async tryMigratePublisher(name: string): Promise<IPublisher | undefined> {
77+
validatePublisher(name);
78+
const existing = this.get(name);
79+
if (existing || !this.interactive || (this.platform !== 'win32' && this.platform !== 'linux')) {
80+
return existing;
81+
}
82+
83+
try {
84+
const legacy = await this.readCredential(this.serviceName, name);
85+
if (!legacy) {
86+
return undefined;
87+
}
88+
if (!this.sameAccount(legacy.name, name) || !legacy.pat) {
89+
throw new LegacyMigrationError('The legacy credential reader returned an invalid credential.');
90+
}
91+
const answer = await this.prompt(
92+
`A saved PAT for publisher '${name}' was found in the previous credential store. Copy it to the new store? [y/N] `
93+
);
94+
if (!/^y$/i.test(answer.trim())) {
95+
return undefined;
96+
}
97+
98+
// Do not hold a cross-process lock while waiting for the user's answer.
99+
return await this.withLock(() => this.copyAndVerify({ name, pat: legacy.pat }));
100+
} catch (error) {
101+
if (!(error instanceof LegacyMigrationError)) {
102+
throw error;
103+
}
104+
log.warn(`${error.message} The previous credential was not changed. `
105+
+ (this.platform === 'linux' ? 'Legacy lookup requires secret-tool (libsecret-tools on Debian/Ubuntu) and an accessible desktop keyring. ' : '')
106+
+ 'Enter a PAT to continue, or retry after resolving the credential-store problem.');
107+
return undefined;
108+
}
109+
}
110+
111+
private async copyAndVerify(publisher: IPublisher): Promise<IPublisher> {
112+
let verified: IStore;
113+
try {
114+
const destination = await this.openStore();
115+
const current = this.findPublisher(destination, publisher.name);
116+
if (current) {
117+
this.store = destination;
118+
return current;
119+
}
120+
await destination.add(publisher);
121+
verified = await this.openStore();
122+
const saved = this.findPublisher(verified, publisher.name);
123+
if (saved?.pat !== publisher.pat) {
124+
throw new LegacyMigrationError('The copied PAT could not be verified.');
125+
}
126+
} catch (error) {
127+
if (!(error instanceof Error)) {
128+
throw error;
129+
}
130+
// Native failures must not include secret values in CLI diagnostics.
131+
throw new LegacyMigrationError(`Could not copy and verify the previous PAT for publisher '${publisher.name}'.`);
132+
}
133+
this.store = verified;
134+
log.info(`Copied the saved PAT for publisher '${publisher.name}'. The previous credential was not changed.`);
135+
return publisher;
136+
}
137+
138+
private sameAccount(a: string, b: string): boolean {
139+
return this.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
140+
}
141+
142+
private findPublisher(store: IStore, name: string): IPublisher | undefined {
143+
return [...store].find(publisher => this.sameAccount(publisher.name, name));
144+
}
145+
146+
private async withLock<T>(operation: () => Promise<T>): Promise<T> {
147+
let release: () => Promise<void>;
148+
try {
149+
await fs.promises.mkdir(this.lockPath, { recursive: true, mode: 0o700 });
150+
release = await lock(this.lockPath, {
151+
stale: 120_000,
152+
update: 5_000,
153+
retries: { retries: 120, factor: 1, minTimeout: 250, maxTimeout: 250 },
154+
});
155+
} catch (error) {
156+
if (error instanceof Error && 'code' in error) {
157+
throw new LegacyMigrationError('Could not lock the credential store. Another vsce command may still be using it.');
158+
}
159+
throw error;
160+
}
161+
try {
162+
return await operation();
163+
} finally {
164+
await release();
165+
}
166+
}
167+
}

0 commit comments

Comments
 (0)