@kokuin/electron 0.3.0 → 0.4.0

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 CHANGED
@@ -5,3 +5,20 @@
5
5
  ```sh
6
6
  npm install @kokuin/electron
7
7
  ```
8
+
9
+ ## Cross-process locking
10
+
11
+ `ElectronKeyStore.open({ name, lockPath })` is opt-in. Without it, two processes racing to
12
+ generate a key for a fresh keyID both create one, and only the in-process lock applies — the
13
+ result is unsafe: silent key loss on backends that upsert unconditionally (e.g. Linux/
14
+ libsecret), or a thrown duplicate-item error on macOS Keychain. With `lockPath` set,
15
+ `provideAsync()` serializes the create through a file lock (`@sozai/lock`) so both processes
16
+ converge on the same key.
17
+
18
+ `lockPath` must point to a **local** filesystem — `link()` is not atomic on NFS — and names a
19
+ file, not a directory: one coarse lock per store, not one per keyID. Acquisition is bounded and
20
+ throws on timeout rather than proceeding unlocked.
21
+
22
+ The synchronous `entry.provide()` cannot hold a cross-process lock (a file lock can't be
23
+ acquired synchronously), so it throws when the store was opened with a `lockPath`; use
24
+ `provideAsync()` instead.
package/lib/entry.d.ts CHANGED
@@ -1,15 +1,26 @@
1
- import type { KeyEntry } from '@kokuin/token';
1
+ import type { MutableKeyEntry } from '@kokuin/token';
2
2
  import type { KeyStorage } from './types.js';
3
- export declare class ElectronKeyEntry implements KeyEntry<string> {
3
+ export type ElectronKeyEntryParams = {
4
+ storage: KeyStorage;
5
+ keyID: string;
6
+ key?: Uint8Array;
7
+ allowInsecureStorage?: boolean;
8
+ lockPath?: string;
9
+ };
10
+ export declare class ElectronKeyEntry implements MutableKeyEntry<Uint8Array> {
4
11
  #private;
5
- constructor(storage: KeyStorage, keyID: string, key?: string, allowInsecureStorage?: boolean);
12
+ constructor(params: ElectronKeyEntryParams);
6
13
  get keyID(): string;
7
- getAsync(): Promise<string | null>;
8
- get(): string | null;
9
- setAsync(key: string): Promise<void>;
10
- set(key: string): void;
11
- provideAsync(): Promise<string>;
12
- provide(): string;
13
- removeAsync(): Promise<void>;
14
+ get(): Uint8Array | null;
15
+ getAsync(): Promise<Uint8Array | null>;
16
+ set(privateKey: Uint8Array): void;
17
+ setAsync(privateKey: Uint8Array): Promise<void>;
18
+ /**
19
+ * The stored key, generating one if absent. Synchronous, and therefore **not** cross-process
20
+ * safe. Throws when a `lockPath` is set rather than silently dropping the guarantee.
21
+ */
22
+ provide(): Uint8Array;
23
+ provideAsync(): Promise<Uint8Array>;
14
24
  remove(): void;
25
+ removeAsync(): Promise<void>;
15
26
  }
package/lib/entry.js CHANGED
@@ -1,23 +1,26 @@
1
1
  import { randomPrivateKey } from '@kokuin/token';
2
2
  import { fromB64, toB64 } from '@sozai/codec';
3
+ import { withFileLock } from '@sozai/lock';
3
4
  import { safeStorage } from 'electron';
4
- function encryptKey(encoded) {
5
- return toB64(safeStorage.encryptString(encoded));
5
+ /** Encrypt raw key bytes for storage: base64(safeStorage(base64(key))). */ function encryptKey(privateKey) {
6
+ return toB64(safeStorage.encryptString(toB64(privateKey)));
6
7
  }
7
8
  function decryptKey(encrypted) {
8
- return safeStorage.decryptString(Buffer.from(fromB64(encrypted)));
9
+ return fromB64(safeStorage.decryptString(Buffer.from(fromB64(encrypted))));
9
10
  }
10
- // Stored as base64
11
11
  export class ElectronKeyEntry {
12
12
  #keyID;
13
13
  #key;
14
14
  #storage;
15
15
  #allowInsecureStorage;
16
- constructor(storage, keyID, key, allowInsecureStorage = false){
17
- this.#keyID = keyID;
18
- this.#key = key;
19
- this.#storage = storage;
20
- this.#allowInsecureStorage = allowInsecureStorage;
16
+ #lockPath;
17
+ #provideLock = Promise.resolve();
18
+ constructor(params){
19
+ this.#keyID = params.keyID;
20
+ this.#key = params.key;
21
+ this.#storage = params.storage;
22
+ this.#allowInsecureStorage = params.allowInsecureStorage ?? false;
23
+ this.#lockPath = params.lockPath;
21
24
  }
22
25
  #assertEncryptionAvailable() {
23
26
  if (!this.#allowInsecureStorage && !safeStorage.isEncryptionAvailable()) {
@@ -27,9 +30,6 @@ export class ElectronKeyEntry {
27
30
  get keyID() {
28
31
  return this.#keyID;
29
32
  }
30
- getAsync() {
31
- return Promise.resolve(this.get());
32
- }
33
33
  get() {
34
34
  if (this.#key != null) {
35
35
  return this.#key;
@@ -38,42 +38,61 @@ export class ElectronKeyEntry {
38
38
  if (encrypted == null) {
39
39
  return null;
40
40
  }
41
- const key = decryptKey(encrypted);
42
- if (key == null) {
43
- return null;
44
- }
45
- this.#key = key;
41
+ this.#key = decryptKey(encrypted);
46
42
  return this.#key;
47
43
  }
48
- async setAsync(key) {
49
- return this.set(key);
44
+ async getAsync() {
45
+ return this.get();
50
46
  }
51
- set(key) {
47
+ set(privateKey) {
52
48
  this.#assertEncryptionAvailable();
53
- const encrypted = encryptKey(key);
54
49
  const keys = this.#storage.getKeys();
55
- keys[this.#keyID] = encrypted;
50
+ keys[this.#keyID] = encryptKey(privateKey);
56
51
  this.#storage.setKeys(keys);
57
- this.#key = key;
52
+ this.#key = privateKey;
58
53
  }
59
- async provideAsync() {
60
- return this.provide();
54
+ async setAsync(privateKey) {
55
+ this.set(privateKey);
56
+ }
57
+ /**
58
+ * The stored key, generating one if absent. Synchronous, and therefore **not** cross-process
59
+ * safe. Throws when a `lockPath` is set rather than silently dropping the guarantee.
60
+ */ provide() {
61
+ if (this.#lockPath != null) {
62
+ throw new Error('ElectronKeyEntry.provide() cannot hold a cross-process lock: a file lock cannot be ' + 'acquired synchronously. This store was opened with a lockPath — use provideAsync().');
63
+ }
64
+ return this.#provideUnlocked();
61
65
  }
62
- provide() {
66
+ provideAsync() {
67
+ const run = this.#provideLock.then(async ()=>{
68
+ const lockPath = this.#lockPath;
69
+ if (lockPath == null) {
70
+ return this.#provideUnlocked();
71
+ }
72
+ return await withFileLock(lockPath, async ()=>this.#provideUnlocked());
73
+ });
74
+ this.#provideLock = run.catch(()=>undefined);
75
+ return run;
76
+ }
77
+ /** Read-if-absent, generate, write. The caller owns exclusion. */ #provideUnlocked() {
78
+ // Drop the in-memory cache: inside the lock we must see a peer's write, not our own
79
+ // stale read. Without this the winner clobbers the peer's key.
80
+ this.#key = undefined;
63
81
  const existing = this.get();
64
82
  if (existing != null) {
65
83
  return existing;
66
84
  }
67
- const privateKey = toB64(randomPrivateKey());
85
+ const privateKey = randomPrivateKey();
68
86
  this.set(privateKey);
69
87
  return privateKey;
70
88
  }
71
- removeAsync() {
72
- return Promise.resolve(this.remove());
73
- }
74
89
  remove() {
75
- const { [this.#keyID]: _, ...keys } = this.#storage.getKeys();
90
+ const keys = this.#storage.getKeys();
91
+ delete keys[this.#keyID];
76
92
  this.#storage.setKeys(keys);
77
93
  this.#key = undefined;
78
94
  }
95
+ async removeAsync() {
96
+ this.remove();
97
+ }
79
98
  }
package/lib/index.d.ts CHANGED
@@ -9,6 +9,6 @@
9
9
  *
10
10
  * @module electron-keystore
11
11
  */
12
- export { ElectronKeyEntry } from './entry.js';
13
- export { provideFullIdentity, provideFullIdentityAsync } from './identity.js';
14
- export { ElectronKeyStore } from './store.js';
12
+ export { ElectronKeyEntry, type ElectronKeyEntryParams } from './entry.js';
13
+ export { ElectronKeyStore, type ElectronKeyStoreParams } from './store.js';
14
+ export type { KeyStorage } from './types.js';
package/lib/index.js CHANGED
@@ -9,5 +9,4 @@
9
9
  *
10
10
  * @module electron-keystore
11
11
  */ export { ElectronKeyEntry } from './entry.js';
12
- export { provideFullIdentity, provideFullIdentityAsync } from './identity.js';
13
12
  export { ElectronKeyStore } from './store.js';
package/lib/store.d.ts CHANGED
@@ -1,10 +1,30 @@
1
- import type { KeyStore } from '@kokuin/token';
1
+ import { type FullIdentity, type IdentityProvider, type KeyStore } from '@kokuin/token';
2
2
  import { ElectronKeyEntry } from './entry.js';
3
- export declare class ElectronKeyStore implements KeyStore<string, ElectronKeyEntry> {
3
+ export type ElectronKeyStoreParams = {
4
+ /** The electron-store file name. Defaults to `'keystore'`. */
5
+ name?: string;
6
+ allowInsecureStorage?: boolean;
7
+ /**
8
+ * Path to a lockfile enabling **cross-process** exclusion on `provideAsync`.
9
+ *
10
+ * Absent, nothing touches the filesystem and only the in-process lock applies — two
11
+ * processes can still both generate a key for a fresh keyID, and the loser's key is lost.
12
+ *
13
+ * A **file**, not a directory: one coarse lock per store, not one per keyID. A per-keyID
14
+ * lockfile would derive its name from an attacker-influenced keyID (`entry('../../etc/x')`),
15
+ * and `provideAsync` runs once per identity, so serializing across keyIDs costs nothing real.
16
+ *
17
+ * Must be on a local filesystem (`link()` is not atomic on NFS). Acquisition is bounded and
18
+ * throws `TimeoutInterruption` on expiry — it never proceeds unlocked.
19
+ */
20
+ lockPath?: string;
21
+ };
22
+ export declare class ElectronKeyStore implements KeyStore<Uint8Array, ElectronKeyEntry>, IdentityProvider<FullIdentity> {
4
23
  #private;
5
- static open(name?: string, options?: {
6
- allowInsecureStorage?: boolean;
7
- }): ElectronKeyStore;
8
- constructor(name: string, allowInsecureStorage?: boolean);
24
+ static open(params?: ElectronKeyStoreParams): ElectronKeyStore;
25
+ constructor(params?: ElectronKeyStoreParams);
9
26
  entry(keyID: string): ElectronKeyEntry;
27
+ provideIdentity(keyID: string): Promise<FullIdentity>;
28
+ /** {@link provideIdentity}, synchronously. Beyond the `IdentityProvider` contract. */
29
+ provideIdentitySync(keyID: string): FullIdentity;
10
30
  }
package/lib/store.js CHANGED
@@ -1,23 +1,40 @@
1
+ import { createTracer, KokuinAttributeKeys, KokuinSpanNames } from '@kokuin/otel';
2
+ import { createFullIdentity } from '@kokuin/token';
3
+ import { getLogger } from '@sozai/log';
4
+ import { withSpan, withSyncSpan } from '@sozai/otel';
1
5
  import Store from 'electron-store';
2
6
  import { ElectronKeyEntry } from './entry.js';
7
+ const tracer = createTracer('keystore.electron');
8
+ const logger = getLogger([
9
+ 'kokuin',
10
+ 'electron'
11
+ ]);
3
12
  export class ElectronKeyStore {
4
13
  static #byName = Object.create(null);
5
- static open(name = 'keystore', options) {
14
+ static open(params) {
15
+ const name = params?.name ?? 'keystore';
6
16
  const cached = ElectronKeyStore.#byName[name];
7
17
  if (cached == null) {
8
- ElectronKeyStore.#byName[name] = new ElectronKeyStore(name, options?.allowInsecureStorage ?? false);
9
- } else if (options?.allowInsecureStorage != null && options.allowInsecureStorage !== cached.#allowInsecureStorage) {
10
- throw new Error(`ElectronKeyStore.open('${name}') was already opened with allowInsecureStorage: ` + `${cached.#allowInsecureStorage}; cannot reopen with conflicting allowInsecureStorage: ` + `${options.allowInsecureStorage}.`);
18
+ ElectronKeyStore.#byName[name] = new ElectronKeyStore(params);
19
+ return ElectronKeyStore.#byName[name];
11
20
  }
12
- return ElectronKeyStore.#byName[name];
21
+ if (params?.allowInsecureStorage != null && params.allowInsecureStorage !== cached.#allowInsecureStorage) {
22
+ throw new Error(`ElectronKeyStore.open('${name}') was already opened with allowInsecureStorage: ` + `${cached.#allowInsecureStorage}; cannot reopen with conflicting allowInsecureStorage: ` + `${params.allowInsecureStorage}.`);
23
+ }
24
+ if (params?.lockPath != null && params.lockPath !== cached.#lockPath) {
25
+ throw new Error(`ElectronKeyStore.open('${name}') was already opened with lockPath: ` + `${String(cached.#lockPath)}; cannot reopen with conflicting lockPath: ${params.lockPath}.`);
26
+ }
27
+ return cached;
13
28
  }
14
29
  #entries = Object.create(null);
15
30
  #storage;
16
31
  #allowInsecureStorage;
17
- constructor(name, allowInsecureStorage = false){
18
- this.#allowInsecureStorage = allowInsecureStorage;
32
+ #lockPath;
33
+ constructor(params){
34
+ this.#allowInsecureStorage = params?.allowInsecureStorage ?? false;
35
+ this.#lockPath = params?.lockPath;
19
36
  const store = new Store({
20
- name,
37
+ name: params?.name ?? 'keystore',
21
38
  schema: {
22
39
  keys: {
23
40
  type: 'object',
@@ -33,12 +50,61 @@ export class ElectronKeyStore {
33
50
  }
34
51
  });
35
52
  this.#storage = {
36
- getKeys: ()=>store.get('keys', {}),
37
- setKeys: (keys)=>store.set('keys', keys)
53
+ // Copy onto a null prototype: the object electron-store hands back is JSON-derived and
54
+ // inherits Object.prototype, which makes '__proto__' and 'constructor' hazardous keyIDs.
55
+ getKeys: ()=>Object.assign(Object.create(null), store.get('keys', {})),
56
+ setKeys: (keys)=>store.set('keys', {
57
+ ...keys
58
+ })
38
59
  };
39
60
  }
40
61
  entry(keyID) {
41
- this.#entries[keyID] ??= new ElectronKeyEntry(this.#storage, keyID, undefined, this.#allowInsecureStorage);
62
+ this.#entries[keyID] ??= new ElectronKeyEntry({
63
+ storage: this.#storage,
64
+ keyID,
65
+ allowInsecureStorage: this.#allowInsecureStorage,
66
+ lockPath: this.#lockPath
67
+ });
42
68
  return this.#entries[keyID];
43
69
  }
70
+ async provideIdentity(keyID) {
71
+ return withSpan(tracer, KokuinSpanNames.KEYSTORE_GET_OR_CREATE, {
72
+ attributes: {
73
+ [KokuinAttributeKeys.KEYSTORE_STORE_TYPE]: 'electron'
74
+ }
75
+ }, async (span)=>{
76
+ const entry = this.entry(keyID);
77
+ const existing = await entry.getAsync();
78
+ const privateKey = existing ?? await entry.provideAsync();
79
+ const identity = createFullIdentity(privateKey);
80
+ span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
81
+ span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, existing == null);
82
+ if (existing == null) {
83
+ logger.info('New identity generated: {did}', {
84
+ did: identity.id
85
+ });
86
+ }
87
+ return identity;
88
+ });
89
+ }
90
+ /** {@link provideIdentity}, synchronously. Beyond the `IdentityProvider` contract. */ provideIdentitySync(keyID) {
91
+ return withSyncSpan(tracer, KokuinSpanNames.KEYSTORE_GET_OR_CREATE, {
92
+ attributes: {
93
+ [KokuinAttributeKeys.KEYSTORE_STORE_TYPE]: 'electron'
94
+ }
95
+ }, (span)=>{
96
+ const entry = this.entry(keyID);
97
+ const existing = entry.get();
98
+ const privateKey = existing ?? entry.provide();
99
+ const identity = createFullIdentity(privateKey);
100
+ span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
101
+ span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, existing == null);
102
+ if (existing == null) {
103
+ logger.info('New identity generated: {did}', {
104
+ did: identity.id
105
+ });
106
+ }
107
+ return identity;
108
+ });
109
+ }
44
110
  }
package/lib/types.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ /**
2
+ * The persisted keyID → encrypted-key map.
3
+ *
4
+ * `getKeys` MUST return a **null-prototype** object. A plain `{}` inherits from
5
+ * `Object.prototype`, so `keys['constructor']` reads back a function rather than `undefined`,
6
+ * and `keys['__proto__'] = value` sets the prototype instead of creating an own property —
7
+ * silently discarding the key. With a null prototype both behave as ordinary string keys.
8
+ */
1
9
  export type KeyStorage = {
2
10
  getKeys: () => Record<string, string>;
3
11
  setKeys: (keys: Record<string, string>) => void;
package/lib/types.js CHANGED
@@ -1 +1,8 @@
1
- export { };
1
+ /**
2
+ * The persisted keyID → encrypted-key map.
3
+ *
4
+ * `getKeys` MUST return a **null-prototype** object. A plain `{}` inherits from
5
+ * `Object.prototype`, so `keys['constructor']` reads back a function rather than `undefined`,
6
+ * and `keys['__proto__'] = value` sets the prototype instead of creating an own property —
7
+ * silently discarding the key. With a null prototype both behave as ordinary string keys.
8
+ */ export { };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kokuin/electron",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Electron keystore",
6
6
  "keywords": [
@@ -26,14 +26,16 @@
26
26
  "sideEffects": false,
27
27
  "dependencies": {
28
28
  "@sozai/codec": "^0.2.0",
29
- "@sozai/log": "^0.1.0",
30
- "@sozai/otel": "^0.2.0",
29
+ "@sozai/lock": "^0.1.0",
30
+ "@sozai/log": "^0.2.0",
31
+ "@sozai/otel": "^0.3.0",
31
32
  "electron-store": "^11.0.2",
32
- "@kokuin/otel": "^0.1.0",
33
- "@kokuin/token": "^0.2.0"
33
+ "@kokuin/otel": "^0.3.0",
34
+ "@kokuin/token": "^0.3.0"
34
35
  },
35
36
  "devDependencies": {
36
- "electron": "^43.1.0"
37
+ "electron": "^43.1.0",
38
+ "@kokuin/keystore-conformance": "^0.0.0"
37
39
  },
38
40
  "scripts": {
39
41
  "build:clean": "del lib",
package/lib/identity.d.ts DELETED
@@ -1,4 +0,0 @@
1
- import { type FullIdentity } from '@kokuin/token';
2
- import { ElectronKeyStore } from './store.js';
3
- export declare function provideFullIdentity(store: ElectronKeyStore | string, keyID: string): FullIdentity;
4
- export declare function provideFullIdentityAsync(store: ElectronKeyStore | string, keyID: string): Promise<FullIdentity>;
package/lib/identity.js DELETED
@@ -1,61 +0,0 @@
1
- import { createTracer, KokuinAttributeKeys, KokuinSpanNames } from '@kokuin/otel';
2
- import { createFullIdentity, decodePrivateKey } from '@kokuin/token';
3
- import { getLogger } from '@sozai/log';
4
- import { withSpan, withSyncSpan } from '@sozai/otel';
5
- import { ElectronKeyStore } from './store.js';
6
- const tracer = createTracer('keystore.electron');
7
- const logger = getLogger([
8
- 'kokuin',
9
- 'electron'
10
- ]);
11
- function getStore(store) {
12
- return typeof store === 'string' ? ElectronKeyStore.open(store) : store;
13
- }
14
- export function provideFullIdentity(store, keyID) {
15
- return withSyncSpan(tracer, KokuinSpanNames.KEYSTORE_GET_OR_CREATE, {
16
- attributes: {
17
- [KokuinAttributeKeys.KEYSTORE_STORE_TYPE]: 'electron'
18
- }
19
- }, (span)=>{
20
- const entry = getStore(store).entry(keyID);
21
- const existing = entry.get();
22
- if (existing != null) {
23
- const identity = createFullIdentity(decodePrivateKey(existing));
24
- span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
25
- span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, false);
26
- return identity;
27
- }
28
- const key = entry.provide();
29
- const identity = createFullIdentity(decodePrivateKey(key));
30
- span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
31
- span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, true);
32
- logger.info('New identity generated: {did}', {
33
- did: identity.id
34
- });
35
- return identity;
36
- });
37
- }
38
- export async function provideFullIdentityAsync(store, keyID) {
39
- return withSpan(tracer, KokuinSpanNames.KEYSTORE_GET_OR_CREATE, {
40
- attributes: {
41
- [KokuinAttributeKeys.KEYSTORE_STORE_TYPE]: 'electron'
42
- }
43
- }, async (span)=>{
44
- const entry = getStore(store).entry(keyID);
45
- const existing = await entry.getAsync();
46
- if (existing != null) {
47
- const identity = createFullIdentity(decodePrivateKey(existing));
48
- span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
49
- span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, false);
50
- return identity;
51
- }
52
- const key = await entry.provideAsync();
53
- const identity = createFullIdentity(decodePrivateKey(key));
54
- span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
55
- span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, true);
56
- logger.info('New identity generated: {did}', {
57
- did: identity.id
58
- });
59
- return identity;
60
- });
61
- }