@workbench-kit/electron-shell 0.0.2-prototype.0.2.33 → 0.0.2-prototype.0.2.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/secrets/encrypted-secret-vault.js +180 -0
- package/dist/wallpaper/wallpaper-crop.js +106 -0
- package/package.json +19 -1
- package/src/index.ts +6 -0
- package/src/secrets/encrypted-secret-vault.ts +166 -48
- package/src/wallpaper/wallpaper-crop.ts +106 -10
package/README.md
CHANGED
|
@@ -34,6 +34,11 @@ import { openAllowlistedExternalLink } from '@workbench-kit/electron-shell/exter
|
|
|
34
34
|
import { createApplicationQuitGuard } from '@workbench-kit/electron-shell/application-quit-guard';
|
|
35
35
|
import { registerPrivilegedAssetProtocolScheme } from '@workbench-kit/electron-shell/asset-protocol';
|
|
36
36
|
import { requireOwnedWindowForSender } from '@workbench-kit/electron-shell/sender-security';
|
|
37
|
+
import { createEncryptedSecretVault } from '@workbench-kit/electron-shell/secret-vault';
|
|
38
|
+
import {
|
|
39
|
+
createWin32RegistryStringReader,
|
|
40
|
+
resolveWallpaperCropRect,
|
|
41
|
+
} from '@workbench-kit/electron-shell/wallpaper';
|
|
37
42
|
import {
|
|
38
43
|
createWindowControlsBridge,
|
|
39
44
|
registerWindowControlIpc,
|
|
@@ -48,6 +53,19 @@ maximized state returned by the main handler.
|
|
|
48
53
|
asset privileges. Call it before app readiness; the host retains its scheme,
|
|
49
54
|
URL parsing, cache policy, responses, and post-ready `protocol.handle` wiring.
|
|
50
55
|
|
|
56
|
+
`createEncryptedSecretVault` encrypts the whole document so persisted bytes do
|
|
57
|
+
not reveal secret ids. Bulk reads/writes and single-key operations share one FIFO
|
|
58
|
+
queue. `documentCodec` lets the host retain its existing plaintext envelope, and
|
|
59
|
+
`writeVault` receives sorted secret-id metadata for an opaque reference or empty-
|
|
60
|
+
vault deletion policy. Hosts retain atomic storage, namespace/path selection,
|
|
61
|
+
references, and backup/restore policy. Legacy version 1 entry-encrypted documents
|
|
62
|
+
are migrated on the next mutation.
|
|
63
|
+
|
|
64
|
+
`resolveWallpaperCropRect` uses one aspect-ratio-preserving cover rule for a
|
|
65
|
+
spanned virtual desktop. The same focused entry provides a bounded, shell-free
|
|
66
|
+
Win32 registry reader and a path resolver with host-injected existence checks and
|
|
67
|
+
fallback path.
|
|
68
|
+
|
|
51
69
|
## Application quit guard (`./application-quit-guard`)
|
|
52
70
|
|
|
53
71
|
Electron's `before-quit` event must be vetoed synchronously, even when checking
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EncryptionUnavailableError = void 0;
|
|
4
|
+
exports.createEncryptedSecretVault = createEncryptedSecretVault;
|
|
5
|
+
class EncryptionUnavailableError extends Error {
|
|
6
|
+
constructor(message = 'OS-backed encryption is unavailable; refusing plaintext vault.') {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = 'encryption_unavailable';
|
|
9
|
+
this.name = 'EncryptionUnavailableError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.EncryptionUnavailableError = EncryptionUnavailableError;
|
|
13
|
+
const textDecoder = new TextDecoder();
|
|
14
|
+
function fromBase64(value) {
|
|
15
|
+
const binary = atob(value);
|
|
16
|
+
const bytes = new Uint8Array(binary.length);
|
|
17
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
18
|
+
bytes[index] = binary.charCodeAt(index);
|
|
19
|
+
}
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
function parseSecretRecord(value) {
|
|
23
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
24
|
+
throw new Error('Secret vault document is malformed.');
|
|
25
|
+
}
|
|
26
|
+
const secrets = createSecretRecord();
|
|
27
|
+
for (const [id, secret] of Object.entries(value)) {
|
|
28
|
+
if (typeof secret !== 'string') {
|
|
29
|
+
throw new Error('Secret vault document is malformed.');
|
|
30
|
+
}
|
|
31
|
+
secrets[id] = secret;
|
|
32
|
+
}
|
|
33
|
+
return secrets;
|
|
34
|
+
}
|
|
35
|
+
function createSecretRecord(entries = []) {
|
|
36
|
+
const secrets = Object.create(null);
|
|
37
|
+
for (const [id, secret] of entries) {
|
|
38
|
+
secrets[id] = secret;
|
|
39
|
+
}
|
|
40
|
+
return secrets;
|
|
41
|
+
}
|
|
42
|
+
function hasOwnSecret(secrets, id) {
|
|
43
|
+
return Object.prototype.hasOwnProperty.call(secrets, id);
|
|
44
|
+
}
|
|
45
|
+
function parseDefaultVaultPlaintext(plaintext) {
|
|
46
|
+
const parsed = JSON.parse(plaintext);
|
|
47
|
+
if (parsed.version !== 2) {
|
|
48
|
+
throw new Error('Secret vault document is malformed.');
|
|
49
|
+
}
|
|
50
|
+
return parseSecretRecord(parsed.secrets);
|
|
51
|
+
}
|
|
52
|
+
const defaultDocumentCodec = {
|
|
53
|
+
parse: parseDefaultVaultPlaintext,
|
|
54
|
+
serialize: (secrets) => JSON.stringify({ version: 2, secrets }),
|
|
55
|
+
};
|
|
56
|
+
function parseLegacyVault(bytes) {
|
|
57
|
+
const parsed = JSON.parse(textDecoder.decode(bytes));
|
|
58
|
+
if (parsed.version !== 1) {
|
|
59
|
+
throw new Error('Secret vault document is malformed.');
|
|
60
|
+
}
|
|
61
|
+
return { version: 1, secrets: parseSecretRecord(parsed.secrets) };
|
|
62
|
+
}
|
|
63
|
+
function assertEncryptionAvailable(cipher) {
|
|
64
|
+
if (!cipher.isEncryptionAvailable()) {
|
|
65
|
+
throw new EncryptionUnavailableError();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function decryptVault(cipher, bytes, documentCodec) {
|
|
69
|
+
try {
|
|
70
|
+
return {
|
|
71
|
+
version: 2,
|
|
72
|
+
secrets: parseSecretRecord(documentCodec.parse(cipher.decryptString(bytes))),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch (encryptedDocumentError) {
|
|
76
|
+
try {
|
|
77
|
+
const legacy = parseLegacyVault(bytes);
|
|
78
|
+
const secrets = createSecretRecord();
|
|
79
|
+
for (const [id, encoded] of Object.entries(legacy.secrets)) {
|
|
80
|
+
secrets[id] = cipher.decryptString(fromBase64(encoded));
|
|
81
|
+
}
|
|
82
|
+
return { version: 2, secrets };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw encryptedDocumentError;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Opaque whole-document secret vault using an injected OS-backed cipher.
|
|
91
|
+
*
|
|
92
|
+
* The encrypted payload hides secret identifiers as well as values. Operations are
|
|
93
|
+
* processed in invocation order so reads observe earlier pending writes. Version 1
|
|
94
|
+
* entry-encrypted documents remain readable and are rewritten by the next mutation.
|
|
95
|
+
* Hosts own the plaintext envelope through an optional codec, atomic persistence,
|
|
96
|
+
* references derived from commit metadata, and multi-instance coordination.
|
|
97
|
+
*/
|
|
98
|
+
function createEncryptedSecretVault(options) {
|
|
99
|
+
const { cipher, readVault, writeVault } = options;
|
|
100
|
+
const documentCodec = options.documentCodec ?? defaultDocumentCodec;
|
|
101
|
+
let operationQueue = Promise.resolve();
|
|
102
|
+
const load = async () => {
|
|
103
|
+
assertEncryptionAvailable(cipher);
|
|
104
|
+
const bytes = await readVault();
|
|
105
|
+
if (bytes === null || bytes.byteLength === 0) {
|
|
106
|
+
return { version: 2, secrets: createSecretRecord() };
|
|
107
|
+
}
|
|
108
|
+
return decryptVault(cipher, bytes, documentCodec);
|
|
109
|
+
};
|
|
110
|
+
const save = async (document) => {
|
|
111
|
+
assertEncryptionAvailable(cipher);
|
|
112
|
+
const secretIds = Object.keys(document.secrets).sort((left, right) => left.localeCompare(right));
|
|
113
|
+
await writeVault(cipher.encryptString(documentCodec.serialize(document.secrets)), {
|
|
114
|
+
secretIds,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
const runOperation = (operation) => {
|
|
118
|
+
const result = operationQueue.then(operation);
|
|
119
|
+
operationQueue = result.then(() => undefined, () => undefined);
|
|
120
|
+
return result;
|
|
121
|
+
};
|
|
122
|
+
const mutateVault = (mutation) => runOperation(async () => {
|
|
123
|
+
const document = await load();
|
|
124
|
+
const nextDocument = mutation(document);
|
|
125
|
+
if (nextDocument !== null) {
|
|
126
|
+
await save(nextDocument);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
getSecret(id) {
|
|
131
|
+
return runOperation(async () => {
|
|
132
|
+
const document = await load();
|
|
133
|
+
return hasOwnSecret(document.secrets, id) ? document.secrets[id] : null;
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
getSecrets(ids) {
|
|
137
|
+
const requestedIds = [...ids];
|
|
138
|
+
return runOperation(async () => {
|
|
139
|
+
const document = await load();
|
|
140
|
+
const result = new Map();
|
|
141
|
+
for (const id of requestedIds) {
|
|
142
|
+
if (hasOwnSecret(document.secrets, id)) {
|
|
143
|
+
result.set(id, document.secrets[id]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
hasSecret(id) {
|
|
150
|
+
return runOperation(async () => {
|
|
151
|
+
const document = await load();
|
|
152
|
+
return hasOwnSecret(document.secrets, id);
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
setSecret(id, value) {
|
|
156
|
+
return mutateVault((document) => {
|
|
157
|
+
const secrets = createSecretRecord(Object.entries(document.secrets));
|
|
158
|
+
secrets[id] = value;
|
|
159
|
+
return { version: 2, secrets };
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
setSecrets(values) {
|
|
163
|
+
const snapshot = [...values];
|
|
164
|
+
return mutateVault((document) => ({
|
|
165
|
+
version: 2,
|
|
166
|
+
secrets: createSecretRecord([...Object.entries(document.secrets), ...snapshot]),
|
|
167
|
+
}));
|
|
168
|
+
},
|
|
169
|
+
deleteSecret(id) {
|
|
170
|
+
return mutateVault((document) => {
|
|
171
|
+
if (!hasOwnSecret(document.secrets, id)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const nextSecrets = createSecretRecord(Object.entries(document.secrets));
|
|
175
|
+
delete nextSecrets[id];
|
|
176
|
+
return { version: 2, secrets: nextSecrets };
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveWallpaperCropRect = resolveWallpaperCropRect;
|
|
4
|
+
exports.createWin32RegistryStringReader = createWin32RegistryStringReader;
|
|
5
|
+
exports.createWin32WallpaperPathResolver = createWin32WallpaperPathResolver;
|
|
6
|
+
function isFinitePositive(value) {
|
|
7
|
+
return Number.isFinite(value) && value > 0;
|
|
8
|
+
}
|
|
9
|
+
function hasFiniteOrigin(rect) {
|
|
10
|
+
return Number.isFinite(rect.x) && Number.isFinite(rect.y);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Compute the source crop rectangle on a wallpaper image for a monitor when the
|
|
14
|
+
* desktop wallpaper is spanned across the virtual desktop.
|
|
15
|
+
*
|
|
16
|
+
* Maps monitor bounds from virtual-desktop coordinates into image pixel space
|
|
17
|
+
* using the image's cover of the full virtual desktop (uniform scale, centered).
|
|
18
|
+
*/
|
|
19
|
+
function resolveWallpaperCropRect(imageSize, virtualDesktop, monitor) {
|
|
20
|
+
if (!isFinitePositive(imageSize.width) || !isFinitePositive(imageSize.height)) {
|
|
21
|
+
throw new Error('Wallpaper image size must be finite and positive.');
|
|
22
|
+
}
|
|
23
|
+
if (!hasFiniteOrigin(virtualDesktop) ||
|
|
24
|
+
!isFinitePositive(virtualDesktop.width) ||
|
|
25
|
+
!isFinitePositive(virtualDesktop.height)) {
|
|
26
|
+
throw new Error('Virtual desktop bounds must be finite with a positive size.');
|
|
27
|
+
}
|
|
28
|
+
if (!hasFiniteOrigin(monitor) ||
|
|
29
|
+
!isFinitePositive(monitor.width) ||
|
|
30
|
+
!isFinitePositive(monitor.height)) {
|
|
31
|
+
throw new Error('Monitor bounds must be finite with a positive size.');
|
|
32
|
+
}
|
|
33
|
+
const scale = Math.max(virtualDesktop.width / imageSize.width, virtualDesktop.height / imageSize.height);
|
|
34
|
+
const drawnWidth = imageSize.width * scale;
|
|
35
|
+
const drawnHeight = imageSize.height * scale;
|
|
36
|
+
const offsetX = virtualDesktop.x - (drawnWidth - virtualDesktop.width) / 2;
|
|
37
|
+
const offsetY = virtualDesktop.y - (drawnHeight - virtualDesktop.height) / 2;
|
|
38
|
+
const cropX = (monitor.x - offsetX) / scale;
|
|
39
|
+
const cropY = (monitor.y - offsetY) / scale;
|
|
40
|
+
const cropWidth = monitor.width / scale;
|
|
41
|
+
const cropHeight = monitor.height / scale;
|
|
42
|
+
const x = Math.max(0, Math.min(imageSize.width, cropX));
|
|
43
|
+
const y = Math.max(0, Math.min(imageSize.height, cropY));
|
|
44
|
+
const maxWidth = imageSize.width - x;
|
|
45
|
+
const maxHeight = imageSize.height - y;
|
|
46
|
+
return {
|
|
47
|
+
x: Math.round(x),
|
|
48
|
+
y: Math.round(y),
|
|
49
|
+
width: Math.max(0, Math.round(Math.min(cropWidth, maxWidth))),
|
|
50
|
+
height: Math.max(0, Math.round(Math.min(cropHeight, maxHeight))),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES = 64 * 1024;
|
|
54
|
+
const WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS = 2000;
|
|
55
|
+
function escapeRegularExpression(value) {
|
|
56
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
57
|
+
}
|
|
58
|
+
/** Create a bounded, shell-free `reg query` string reader. */
|
|
59
|
+
function createWin32RegistryStringReader(options) {
|
|
60
|
+
const maxBuffer = options.maxBufferBytes ?? WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES;
|
|
61
|
+
const timeout = options.timeoutMs ?? WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS;
|
|
62
|
+
if (!Number.isFinite(maxBuffer) || maxBuffer <= 0) {
|
|
63
|
+
throw new Error('maxBufferBytes must be a finite positive number.');
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
66
|
+
throw new Error('timeoutMs must be a finite positive number.');
|
|
67
|
+
}
|
|
68
|
+
return (keyPath, valueName) => new Promise((resolve) => {
|
|
69
|
+
try {
|
|
70
|
+
options.execFile('reg', ['query', keyPath, '/v', valueName], { encoding: 'utf8', maxBuffer, shell: false, timeout, windowsHide: true }, (error, stdout) => {
|
|
71
|
+
if (error) {
|
|
72
|
+
resolve(null);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pattern = new RegExp(`^\\s*${escapeRegularExpression(valueName)}\\s+REG_\\w+\\s+(.+)$`, 'imu');
|
|
76
|
+
const value = pattern.exec(stdout)?.[1]?.trim();
|
|
77
|
+
resolve(value && value.length > 0 ? value : null);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
resolve(null);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Win32 wallpaper path resolver behind an injected registry reader.
|
|
87
|
+
* Other platforms should inject a resolver that returns null until implemented.
|
|
88
|
+
*/
|
|
89
|
+
function createWin32WallpaperPathResolver(options) {
|
|
90
|
+
return {
|
|
91
|
+
async resolveWallpaperPath() {
|
|
92
|
+
const value = await options.readRegistryString('HKCU\\Control Panel\\Desktop', 'WallPaper');
|
|
93
|
+
const registryPath = value?.trim() || null;
|
|
94
|
+
if (registryPath !== null &&
|
|
95
|
+
(!options.pathExists || (await options.pathExists(registryPath)))) {
|
|
96
|
+
return registryPath;
|
|
97
|
+
}
|
|
98
|
+
const fallbackPath = options.fallbackPath?.trim() || null;
|
|
99
|
+
if (fallbackPath !== null &&
|
|
100
|
+
(!options.pathExists || (await options.pathExists(fallbackPath)))) {
|
|
101
|
+
return fallbackPath;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workbench-kit/electron-shell",
|
|
3
|
-
"version": "0.0.2-prototype.0.2.
|
|
3
|
+
"version": "0.0.2-prototype.0.2.35",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -18,9 +18,15 @@
|
|
|
18
18
|
"preload": [
|
|
19
19
|
"src/preload/index.ts"
|
|
20
20
|
],
|
|
21
|
+
"secret-vault": [
|
|
22
|
+
"src/secrets/encrypted-secret-vault.ts"
|
|
23
|
+
],
|
|
21
24
|
"sender-security": [
|
|
22
25
|
"src/security/require-owned-window-for-sender.ts"
|
|
23
26
|
],
|
|
27
|
+
"wallpaper": [
|
|
28
|
+
"src/wallpaper/wallpaper-crop.ts"
|
|
29
|
+
],
|
|
24
30
|
"window-controls": [
|
|
25
31
|
"src/window/window-controls.ts"
|
|
26
32
|
]
|
|
@@ -47,12 +53,24 @@
|
|
|
47
53
|
"default": "./src/security/open-allowlisted-external-link.ts"
|
|
48
54
|
},
|
|
49
55
|
"./preload": "./src/preload/index.ts",
|
|
56
|
+
"./secret-vault": {
|
|
57
|
+
"types": "./src/secrets/encrypted-secret-vault.ts",
|
|
58
|
+
"require": "./dist/secrets/encrypted-secret-vault.js",
|
|
59
|
+
"import": "./src/secrets/encrypted-secret-vault.ts",
|
|
60
|
+
"default": "./src/secrets/encrypted-secret-vault.ts"
|
|
61
|
+
},
|
|
50
62
|
"./sender-security": {
|
|
51
63
|
"types": "./src/security/require-owned-window-for-sender.ts",
|
|
52
64
|
"require": "./dist/security/require-owned-window-for-sender.js",
|
|
53
65
|
"import": "./src/security/require-owned-window-for-sender.ts",
|
|
54
66
|
"default": "./src/security/require-owned-window-for-sender.ts"
|
|
55
67
|
},
|
|
68
|
+
"./wallpaper": {
|
|
69
|
+
"types": "./src/wallpaper/wallpaper-crop.ts",
|
|
70
|
+
"require": "./dist/wallpaper/wallpaper-crop.js",
|
|
71
|
+
"import": "./src/wallpaper/wallpaper-crop.ts",
|
|
72
|
+
"default": "./src/wallpaper/wallpaper-crop.ts"
|
|
73
|
+
},
|
|
56
74
|
"./window-controls": {
|
|
57
75
|
"types": "./src/window/window-controls.ts",
|
|
58
76
|
"require": "./dist/window/window-controls.js",
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
type CreateEncryptedSecretVaultOptions,
|
|
14
14
|
type EncryptedSecretVault,
|
|
15
15
|
type SafeStorageCipher,
|
|
16
|
+
type SecretVaultCommitMetadata,
|
|
17
|
+
type SecretVaultDocumentCodec,
|
|
16
18
|
} from './secrets/encrypted-secret-vault.js';
|
|
17
19
|
export {
|
|
18
20
|
InvalidExternalLinkUrlError,
|
|
@@ -33,11 +35,15 @@ export {
|
|
|
33
35
|
type RegisterPrivilegedAssetProtocolSchemeOptions,
|
|
34
36
|
} from './assets/privileged-asset-protocol.js';
|
|
35
37
|
export {
|
|
38
|
+
createWin32RegistryStringReader,
|
|
36
39
|
createWin32WallpaperPathResolver,
|
|
37
40
|
resolveWallpaperCropRect,
|
|
41
|
+
type CreateWin32RegistryStringReaderOptions,
|
|
38
42
|
type RectLike,
|
|
39
43
|
type SizeLike,
|
|
40
44
|
type WallpaperPathResolver,
|
|
45
|
+
type Win32RegistryExecFile,
|
|
46
|
+
type Win32RegistryExecFileOptions,
|
|
41
47
|
} from './wallpaper/wallpaper-crop.js';
|
|
42
48
|
export {
|
|
43
49
|
createWindowControlsBridge,
|
|
@@ -6,14 +6,27 @@ export interface SafeStorageCipher {
|
|
|
6
6
|
|
|
7
7
|
export interface EncryptedSecretVault {
|
|
8
8
|
getSecret(id: string): Promise<string | null>;
|
|
9
|
+
getSecrets(ids: readonly string[]): Promise<ReadonlyMap<string, string>>;
|
|
10
|
+
hasSecret(id: string): Promise<boolean>;
|
|
9
11
|
setSecret(id: string, value: string): Promise<void>;
|
|
12
|
+
setSecrets(values: ReadonlyMap<string, string>): Promise<void>;
|
|
10
13
|
deleteSecret(id: string): Promise<void>;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export interface CreateEncryptedSecretVaultOptions {
|
|
14
17
|
readonly cipher: SafeStorageCipher;
|
|
18
|
+
readonly documentCodec?: SecretVaultDocumentCodec;
|
|
15
19
|
readonly readVault: () => Promise<Uint8Array | null>;
|
|
16
|
-
readonly writeVault: (bytes: Uint8Array) => Promise<void>;
|
|
20
|
+
readonly writeVault: (bytes: Uint8Array, metadata: SecretVaultCommitMetadata) => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SecretVaultDocumentCodec {
|
|
24
|
+
parse(plaintext: string): Readonly<Record<string, string>>;
|
|
25
|
+
serialize(secrets: Readonly<Record<string, string>>): string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SecretVaultCommitMetadata {
|
|
29
|
+
readonly secretIds: readonly string[];
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
export class EncryptionUnavailableError extends Error {
|
|
@@ -26,23 +39,19 @@ export class EncryptionUnavailableError extends Error {
|
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
interface VaultDocument {
|
|
42
|
+
readonly version: 2;
|
|
43
|
+
readonly secrets: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface LegacyVaultDocument {
|
|
29
47
|
readonly version: 1;
|
|
30
48
|
readonly secrets: Record<string, string>;
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
type VaultMutation = (document: VaultDocument) => VaultDocument | null;
|
|
34
52
|
|
|
35
|
-
const textEncoder = new TextEncoder();
|
|
36
53
|
const textDecoder = new TextDecoder();
|
|
37
54
|
|
|
38
|
-
function toBase64(bytes: Uint8Array): string {
|
|
39
|
-
let binary = '';
|
|
40
|
-
for (const byte of bytes) {
|
|
41
|
-
binary += String.fromCharCode(byte);
|
|
42
|
-
}
|
|
43
|
-
return btoa(binary);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
55
|
function fromBase64(value: string): Uint8Array {
|
|
47
56
|
const binary = atob(value);
|
|
48
57
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -52,19 +61,59 @@ function fromBase64(value: string): Uint8Array {
|
|
|
52
61
|
return bytes;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
|
-
function
|
|
56
|
-
if (
|
|
57
|
-
|
|
64
|
+
function parseSecretRecord(value: unknown): Record<string, string> {
|
|
65
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
66
|
+
throw new Error('Secret vault document is malformed.');
|
|
67
|
+
}
|
|
68
|
+
const secrets = createSecretRecord();
|
|
69
|
+
for (const [id, secret] of Object.entries(value)) {
|
|
70
|
+
if (typeof secret !== 'string') {
|
|
71
|
+
throw new Error('Secret vault document is malformed.');
|
|
72
|
+
}
|
|
73
|
+
secrets[id] = secret;
|
|
74
|
+
}
|
|
75
|
+
return secrets;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createSecretRecord(
|
|
79
|
+
entries: Iterable<readonly [string, string]> = [],
|
|
80
|
+
): Record<string, string> {
|
|
81
|
+
const secrets = Object.create(null) as Record<string, string>;
|
|
82
|
+
for (const [id, secret] of entries) {
|
|
83
|
+
secrets[id] = secret;
|
|
58
84
|
}
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
return secrets;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasOwnSecret(secrets: Readonly<Record<string, string>>, id: string): boolean {
|
|
89
|
+
return Object.prototype.hasOwnProperty.call(secrets, id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseDefaultVaultPlaintext(plaintext: string): Record<string, string> {
|
|
93
|
+
const parsed = JSON.parse(plaintext) as {
|
|
94
|
+
readonly version?: unknown;
|
|
95
|
+
readonly secrets?: unknown;
|
|
96
|
+
};
|
|
97
|
+
if (parsed.version !== 2) {
|
|
61
98
|
throw new Error('Secret vault document is malformed.');
|
|
62
99
|
}
|
|
63
|
-
return
|
|
100
|
+
return parseSecretRecord(parsed.secrets);
|
|
64
101
|
}
|
|
65
102
|
|
|
66
|
-
|
|
67
|
-
|
|
103
|
+
const defaultDocumentCodec: SecretVaultDocumentCodec = {
|
|
104
|
+
parse: parseDefaultVaultPlaintext,
|
|
105
|
+
serialize: (secrets) => JSON.stringify({ version: 2, secrets }),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function parseLegacyVault(bytes: Uint8Array): LegacyVaultDocument {
|
|
109
|
+
const parsed = JSON.parse(textDecoder.decode(bytes)) as {
|
|
110
|
+
readonly version?: unknown;
|
|
111
|
+
readonly secrets?: unknown;
|
|
112
|
+
};
|
|
113
|
+
if (parsed.version !== 1) {
|
|
114
|
+
throw new Error('Secret vault document is malformed.');
|
|
115
|
+
}
|
|
116
|
+
return { version: 1, secrets: parseSecretRecord(parsed.secrets) };
|
|
68
117
|
}
|
|
69
118
|
|
|
70
119
|
function assertEncryptionAvailable(cipher: SafeStorageCipher): void {
|
|
@@ -73,67 +122,136 @@ function assertEncryptionAvailable(cipher: SafeStorageCipher): void {
|
|
|
73
122
|
}
|
|
74
123
|
}
|
|
75
124
|
|
|
125
|
+
function decryptVault(
|
|
126
|
+
cipher: SafeStorageCipher,
|
|
127
|
+
bytes: Uint8Array,
|
|
128
|
+
documentCodec: SecretVaultDocumentCodec,
|
|
129
|
+
): VaultDocument {
|
|
130
|
+
try {
|
|
131
|
+
return {
|
|
132
|
+
version: 2,
|
|
133
|
+
secrets: parseSecretRecord(documentCodec.parse(cipher.decryptString(bytes))),
|
|
134
|
+
};
|
|
135
|
+
} catch (encryptedDocumentError) {
|
|
136
|
+
try {
|
|
137
|
+
const legacy = parseLegacyVault(bytes);
|
|
138
|
+
const secrets = createSecretRecord();
|
|
139
|
+
for (const [id, encoded] of Object.entries(legacy.secrets)) {
|
|
140
|
+
secrets[id] = cipher.decryptString(fromBase64(encoded));
|
|
141
|
+
}
|
|
142
|
+
return { version: 2, secrets };
|
|
143
|
+
} catch {
|
|
144
|
+
throw encryptedDocumentError;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
76
149
|
/**
|
|
77
|
-
* Opaque secret vault using an injected OS-backed cipher.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
150
|
+
* Opaque whole-document secret vault using an injected OS-backed cipher.
|
|
151
|
+
*
|
|
152
|
+
* The encrypted payload hides secret identifiers as well as values. Operations are
|
|
153
|
+
* processed in invocation order so reads observe earlier pending writes. Version 1
|
|
154
|
+
* entry-encrypted documents remain readable and are rewritten by the next mutation.
|
|
155
|
+
* Hosts own the plaintext envelope through an optional codec, atomic persistence,
|
|
156
|
+
* references derived from commit metadata, and multi-instance coordination.
|
|
80
157
|
*/
|
|
81
158
|
export function createEncryptedSecretVault(
|
|
82
159
|
options: CreateEncryptedSecretVaultOptions,
|
|
83
160
|
): EncryptedSecretVault {
|
|
84
161
|
const { cipher, readVault, writeVault } = options;
|
|
85
|
-
|
|
162
|
+
const documentCodec = options.documentCodec ?? defaultDocumentCodec;
|
|
163
|
+
let operationQueue: Promise<void> = Promise.resolve();
|
|
86
164
|
|
|
87
165
|
const load = async (): Promise<VaultDocument> => {
|
|
88
166
|
assertEncryptionAvailable(cipher);
|
|
89
|
-
|
|
167
|
+
const bytes = await readVault();
|
|
168
|
+
if (bytes === null || bytes.byteLength === 0) {
|
|
169
|
+
return { version: 2, secrets: createSecretRecord() };
|
|
170
|
+
}
|
|
171
|
+
return decryptVault(cipher, bytes, documentCodec);
|
|
90
172
|
};
|
|
91
173
|
|
|
92
174
|
const save = async (document: VaultDocument): Promise<void> => {
|
|
93
175
|
assertEncryptionAvailable(cipher);
|
|
94
|
-
|
|
176
|
+
const secretIds = Object.keys(document.secrets).sort((left, right) =>
|
|
177
|
+
left.localeCompare(right),
|
|
178
|
+
);
|
|
179
|
+
await writeVault(cipher.encryptString(documentCodec.serialize(document.secrets)), {
|
|
180
|
+
secretIds,
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const runOperation = <TResult>(operation: () => Promise<TResult>): Promise<TResult> => {
|
|
185
|
+
const result = operationQueue.then(operation);
|
|
186
|
+
operationQueue = result.then(
|
|
187
|
+
() => undefined,
|
|
188
|
+
() => undefined,
|
|
189
|
+
);
|
|
190
|
+
return result;
|
|
95
191
|
};
|
|
96
192
|
|
|
97
|
-
const mutateVault = (mutation: VaultMutation): Promise<void> =>
|
|
98
|
-
|
|
193
|
+
const mutateVault = (mutation: VaultMutation): Promise<void> =>
|
|
194
|
+
runOperation(async () => {
|
|
99
195
|
const document = await load();
|
|
100
196
|
const nextDocument = mutation(document);
|
|
101
197
|
if (nextDocument !== null) {
|
|
102
198
|
await save(nextDocument);
|
|
103
199
|
}
|
|
104
200
|
});
|
|
105
|
-
mutationQueue = result.catch(() => undefined);
|
|
106
|
-
return result;
|
|
107
|
-
};
|
|
108
201
|
|
|
109
202
|
return {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
203
|
+
getSecret(id: string): Promise<string | null> {
|
|
204
|
+
return runOperation(async () => {
|
|
205
|
+
const document = await load();
|
|
206
|
+
return hasOwnSecret(document.secrets, id) ? document.secrets[id]! : null;
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
getSecrets(ids: readonly string[]): Promise<ReadonlyMap<string, string>> {
|
|
211
|
+
const requestedIds = [...ids];
|
|
212
|
+
return runOperation(async () => {
|
|
213
|
+
const document = await load();
|
|
214
|
+
const result = new Map<string, string>();
|
|
215
|
+
for (const id of requestedIds) {
|
|
216
|
+
if (hasOwnSecret(document.secrets, id)) {
|
|
217
|
+
result.set(id, document.secrets[id]!);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
hasSecret(id: string): Promise<boolean> {
|
|
225
|
+
return runOperation(async () => {
|
|
226
|
+
const document = await load();
|
|
227
|
+
return hasOwnSecret(document.secrets, id);
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
setSecret(id: string, value: string): Promise<void> {
|
|
232
|
+
return mutateVault((document) => {
|
|
233
|
+
const secrets = createSecretRecord(Object.entries(document.secrets));
|
|
234
|
+
secrets[id] = value;
|
|
235
|
+
return { version: 2, secrets };
|
|
236
|
+
});
|
|
117
237
|
},
|
|
118
238
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
[id]: toBase64(cipher.encryptString(value)),
|
|
125
|
-
},
|
|
239
|
+
setSecrets(values: ReadonlyMap<string, string>): Promise<void> {
|
|
240
|
+
const snapshot = [...values];
|
|
241
|
+
return mutateVault((document) => ({
|
|
242
|
+
version: 2,
|
|
243
|
+
secrets: createSecretRecord([...Object.entries(document.secrets), ...snapshot]),
|
|
126
244
|
}));
|
|
127
245
|
},
|
|
128
246
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!(
|
|
247
|
+
deleteSecret(id: string): Promise<void> {
|
|
248
|
+
return mutateVault((document) => {
|
|
249
|
+
if (!hasOwnSecret(document.secrets, id)) {
|
|
132
250
|
return null;
|
|
133
251
|
}
|
|
134
|
-
const nextSecrets =
|
|
252
|
+
const nextSecrets = createSecretRecord(Object.entries(document.secrets));
|
|
135
253
|
delete nextSecrets[id];
|
|
136
|
-
return { version:
|
|
254
|
+
return { version: 2, secrets: nextSecrets };
|
|
137
255
|
});
|
|
138
256
|
},
|
|
139
257
|
};
|
|
@@ -10,6 +10,14 @@ export interface RectLike {
|
|
|
10
10
|
readonly height: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function isFinitePositive(value: number): boolean {
|
|
14
|
+
return Number.isFinite(value) && value > 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasFiniteOrigin(rect: Pick<RectLike, 'x' | 'y'>): boolean {
|
|
18
|
+
return Number.isFinite(rect.x) && Number.isFinite(rect.y);
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
/**
|
|
14
22
|
* Compute the source crop rectangle on a wallpaper image for a monitor when the
|
|
15
23
|
* desktop wallpaper is spanned across the virtual desktop.
|
|
@@ -22,14 +30,22 @@ export function resolveWallpaperCropRect(
|
|
|
22
30
|
virtualDesktop: RectLike,
|
|
23
31
|
monitor: RectLike,
|
|
24
32
|
): RectLike {
|
|
25
|
-
if (imageSize.width
|
|
26
|
-
throw new Error('Wallpaper image size must be positive.');
|
|
33
|
+
if (!isFinitePositive(imageSize.width) || !isFinitePositive(imageSize.height)) {
|
|
34
|
+
throw new Error('Wallpaper image size must be finite and positive.');
|
|
27
35
|
}
|
|
28
|
-
if (
|
|
29
|
-
|
|
36
|
+
if (
|
|
37
|
+
!hasFiniteOrigin(virtualDesktop) ||
|
|
38
|
+
!isFinitePositive(virtualDesktop.width) ||
|
|
39
|
+
!isFinitePositive(virtualDesktop.height)
|
|
40
|
+
) {
|
|
41
|
+
throw new Error('Virtual desktop bounds must be finite with a positive size.');
|
|
30
42
|
}
|
|
31
|
-
if (
|
|
32
|
-
|
|
43
|
+
if (
|
|
44
|
+
!hasFiniteOrigin(monitor) ||
|
|
45
|
+
!isFinitePositive(monitor.width) ||
|
|
46
|
+
!isFinitePositive(monitor.height)
|
|
47
|
+
) {
|
|
48
|
+
throw new Error('Monitor bounds must be finite with a positive size.');
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
const scale = Math.max(
|
|
@@ -63,21 +79,101 @@ export interface WallpaperPathResolver {
|
|
|
63
79
|
resolveWallpaperPath(): Promise<string | null>;
|
|
64
80
|
}
|
|
65
81
|
|
|
82
|
+
export interface Win32RegistryExecFileOptions {
|
|
83
|
+
readonly encoding: 'utf8';
|
|
84
|
+
readonly maxBuffer: number;
|
|
85
|
+
readonly shell: false;
|
|
86
|
+
readonly timeout: number;
|
|
87
|
+
readonly windowsHide: true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type Win32RegistryExecFile = (
|
|
91
|
+
file: string,
|
|
92
|
+
args: readonly string[],
|
|
93
|
+
options: Win32RegistryExecFileOptions,
|
|
94
|
+
callback: (error: Error | null, stdout: string) => void,
|
|
95
|
+
) => void;
|
|
96
|
+
|
|
97
|
+
export interface CreateWin32RegistryStringReaderOptions {
|
|
98
|
+
readonly execFile: Win32RegistryExecFile;
|
|
99
|
+
readonly maxBufferBytes?: number;
|
|
100
|
+
readonly timeoutMs?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES = 64 * 1024;
|
|
104
|
+
const WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS = 2_000;
|
|
105
|
+
|
|
106
|
+
function escapeRegularExpression(value: string): string {
|
|
107
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Create a bounded, shell-free `reg query` string reader. */
|
|
111
|
+
export function createWin32RegistryStringReader(
|
|
112
|
+
options: CreateWin32RegistryStringReaderOptions,
|
|
113
|
+
): (keyPath: string, valueName: string) => Promise<string | null> {
|
|
114
|
+
const maxBuffer = options.maxBufferBytes ?? WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES;
|
|
115
|
+
const timeout = options.timeoutMs ?? WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS;
|
|
116
|
+
if (!Number.isFinite(maxBuffer) || maxBuffer <= 0) {
|
|
117
|
+
throw new Error('maxBufferBytes must be a finite positive number.');
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
120
|
+
throw new Error('timeoutMs must be a finite positive number.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (keyPath, valueName) =>
|
|
124
|
+
new Promise((resolve) => {
|
|
125
|
+
try {
|
|
126
|
+
options.execFile(
|
|
127
|
+
'reg',
|
|
128
|
+
['query', keyPath, '/v', valueName],
|
|
129
|
+
{ encoding: 'utf8', maxBuffer, shell: false, timeout, windowsHide: true },
|
|
130
|
+
(error, stdout) => {
|
|
131
|
+
if (error) {
|
|
132
|
+
resolve(null);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const pattern = new RegExp(
|
|
136
|
+
`^\\s*${escapeRegularExpression(valueName)}\\s+REG_\\w+\\s+(.+)$`,
|
|
137
|
+
'imu',
|
|
138
|
+
);
|
|
139
|
+
const value = pattern.exec(stdout)?.[1]?.trim();
|
|
140
|
+
resolve(value && value.length > 0 ? value : null);
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
resolve(null);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
66
149
|
/**
|
|
67
150
|
* Win32 wallpaper path resolver behind an injected registry reader.
|
|
68
151
|
* Other platforms should inject a resolver that returns null until implemented.
|
|
69
152
|
*/
|
|
70
153
|
export function createWin32WallpaperPathResolver(options: {
|
|
71
154
|
readonly readRegistryString: (keyPath: string, valueName: string) => Promise<string | null>;
|
|
155
|
+
readonly fallbackPath?: string | null;
|
|
156
|
+
readonly pathExists?: (filePath: string) => Promise<boolean>;
|
|
72
157
|
}): WallpaperPathResolver {
|
|
73
158
|
return {
|
|
74
159
|
async resolveWallpaperPath(): Promise<string | null> {
|
|
75
160
|
const value = await options.readRegistryString('HKCU\\Control Panel\\Desktop', 'WallPaper');
|
|
76
|
-
|
|
77
|
-
|
|
161
|
+
const registryPath = value?.trim() || null;
|
|
162
|
+
if (
|
|
163
|
+
registryPath !== null &&
|
|
164
|
+
(!options.pathExists || (await options.pathExists(registryPath)))
|
|
165
|
+
) {
|
|
166
|
+
return registryPath;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const fallbackPath = options.fallbackPath?.trim() || null;
|
|
170
|
+
if (
|
|
171
|
+
fallbackPath !== null &&
|
|
172
|
+
(!options.pathExists || (await options.pathExists(fallbackPath)))
|
|
173
|
+
) {
|
|
174
|
+
return fallbackPath;
|
|
78
175
|
}
|
|
79
|
-
|
|
80
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
176
|
+
return null;
|
|
81
177
|
},
|
|
82
178
|
};
|
|
83
179
|
}
|