Skip to content

Commit a846cb0

Browse files
committed
feat: add safeStorage with AES-256-GCM keyed by the macOS Keychain and Linux libsecret
1 parent cba16f6 commit a846cb0

12 files changed

Lines changed: 801 additions & 3 deletions

File tree

.github/workflows/validate.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ jobs:
2424
steps:
2525
- uses: actions/checkout@v4
2626

27-
- name: Install Linux native deps (GTK 4 + WebKitGTK 6.0 + libnotify + Xvfb)
27+
- name: Install Linux native deps (GTK 4 + WebKitGTK 6.0 + libnotify + libsecret + Xvfb)
2828
if: runner.os == 'Linux'
2929
run: |
3030
sudo apt-get update
31-
sudo apt-get install -y libgtk-4-1 libwebkitgtk-6.0-4 libnotify4 xvfb
31+
sudo apt-get install -y libgtk-4-1 libwebkitgtk-6.0-4 libnotify4 libsecret-1-0 xvfb
3232
3333
- uses: oven-sh/setup-bun@v2
3434
with:

src/main/api/safe-storage.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2+
import { InvalidArgumentError, SambarError } from '../../common/errors';
3+
import { currentPlatform } from '../../common/platform';
4+
import { linuxLibsecretBackend } from '../platform/linux/libsecret-keyring';
5+
import { macosKeychainBackend } from '../platform/macos/cocoa-safe-storage';
6+
7+
/**
8+
* Encryption of strings tied to an OS-protected key — the drop-in equivalent of
9+
* Electron's `safeStorage`.
10+
*
11+
* The key is a random 32-byte secret kept in the OS keyring (macOS Keychain,
12+
* Linux libsecret) and never written to disk by Sambar. Strings are sealed with
13+
* AES-256-GCM (authenticated — tampering throws on decrypt).
14+
*
15+
* DIVERGENCE FROM ELECTRON (deliberate): Electron falls back to a `basic_text`
16+
* scheme (an obfuscated, effectively-plaintext key) when no OS keyring exists.
17+
* Sambar does NOT — a key sitting next to the ciphertext is not protection. With
18+
* no keyring, `isEncryptionAvailable()` returns `false` and
19+
* `encryptString`/`decryptString` throw. Sambar also does not promise
20+
* Electron-blob compatibility: a native, versioned blob format is used.
21+
*/
22+
23+
export type SafeStorage = {
24+
/** Whether a keyring-backed key is available so encrypt/decrypt can run. Never throws. */
25+
isEncryptionAvailable(): boolean;
26+
/** Seal `plainText` (UTF-8) into an authenticated blob. Throws if encryption is unavailable. */
27+
encryptString(plainText: string): Buffer;
28+
/** Open a blob produced by {@link encryptString}. Throws on tamper, bad format, or unavailability. */
29+
decryptString(encrypted: Buffer): string;
30+
};
31+
32+
/**
33+
* The keyring seam the crypto layer delegates to for its 32-byte key. Injectable
34+
* so the format/crypto logic is unit-tested with an in-memory fake — no FFI, no
35+
* real keyring, never a blocking call in CI.
36+
*/
37+
export type KeyringBackend = {
38+
/** Whether this host can store/retrieve a key. MUST be cheap + non-blocking + never throw. */
39+
isAvailable(): boolean;
40+
/** Fetch the existing 32-byte key or create+persist one. May throw (surfaced by encrypt/decrypt). */
41+
getOrCreateKey(): Buffer;
42+
};
43+
44+
const KEY_LENGTH = 32;
45+
/** GCM nonce length (96-bit IV — the GCM standard / fastest path). */
46+
const IV_LENGTH = 12;
47+
/** GCM authentication tag length. */
48+
const TAG_LENGTH = 16;
49+
/** Blob format version, so a future format can co-exist. */
50+
const VERSION = 0x01;
51+
/** Smallest valid blob: version + IV + (≥0 ciphertext) + tag. */
52+
const MIN_BLOB_LENGTH = 1 + IV_LENGTH + TAG_LENGTH;
53+
54+
/**
55+
* Blob layout: `[version:1][iv:12][ciphertext:N][tag:16]`. A random IV per
56+
* encryption (never reused) + the GCM tag make the blob tamper-evident.
57+
*/
58+
const encryptWithKey = (key: Buffer, plainText: string): Buffer => {
59+
const iv = randomBytes(IV_LENGTH);
60+
const cipher = createCipheriv('aes-256-gcm', key, iv);
61+
const ciphertext = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
62+
const tag = cipher.getAuthTag();
63+
return Buffer.concat([Buffer.from([VERSION]), iv, ciphertext, tag]);
64+
};
65+
66+
const decryptWithKey = (key: Buffer, blob: Buffer): string => {
67+
if (blob.length < MIN_BLOB_LENGTH) {
68+
throw new InvalidArgumentError('safeStorage: corrupt blob (too short)');
69+
}
70+
if (blob[0] !== VERSION) {
71+
throw new InvalidArgumentError(
72+
`safeStorage: unsupported blob version 0x${blob[0]?.toString(16)}`,
73+
);
74+
}
75+
const iv = blob.subarray(1, 1 + IV_LENGTH);
76+
const tag = blob.subarray(blob.length - TAG_LENGTH);
77+
const ciphertext = blob.subarray(1 + IV_LENGTH, blob.length - TAG_LENGTH);
78+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
79+
decipher.setAuthTag(tag);
80+
// GCM auth failure (tamper / wrong key) makes final() THROW — surface it loudly,
81+
// never return garbage plaintext.
82+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
83+
};
84+
85+
/** Built-in backend for platforms with no keyring: reports unavailable, throws if forced. */
86+
const unavailableBackend: KeyringBackend = {
87+
isAvailable: () => false,
88+
getOrCreateKey: () => {
89+
throw new SambarError(`safeStorage has no keyring backend on ${currentPlatform()}`);
90+
},
91+
};
92+
93+
let backend: KeyringBackend | undefined;
94+
let cachedKey: Buffer | undefined;
95+
let cachedAvailable: boolean | undefined;
96+
97+
const getBackend = (): KeyringBackend => {
98+
if (backend !== undefined) {
99+
return backend;
100+
}
101+
const platform = currentPlatform();
102+
if (platform === 'macos') {
103+
return macosKeychainBackend;
104+
}
105+
if (platform === 'linux') {
106+
return linuxLibsecretBackend;
107+
}
108+
return unavailableBackend;
109+
};
110+
111+
/** Whether encryption is available — probed once, then memoised (Electron caches at startup). */
112+
const isAvailable = (): boolean => {
113+
if (cachedAvailable === undefined) {
114+
cachedAvailable = getBackend().isAvailable();
115+
}
116+
return cachedAvailable;
117+
};
118+
119+
/**
120+
* Read the keyring ONCE and cache the key for the process — only the first op
121+
* pays the round-trip (on Linux, the one blocking D-Bus call); later ops are pure
122+
* in-memory AES.
123+
*/
124+
const getKey = (): Buffer => {
125+
if (cachedKey === undefined) {
126+
const key = getBackend().getOrCreateKey();
127+
if (key.length !== KEY_LENGTH) {
128+
throw new SambarError(
129+
`safeStorage: keyring returned a ${key.length}-byte key, expected ${KEY_LENGTH}`,
130+
);
131+
}
132+
cachedKey = key;
133+
}
134+
return cachedKey;
135+
};
136+
137+
/** Override the keyring backend AND clear the cached key + availability. Test-only. */
138+
export const setSafeStorageBackendForTesting = (fake: KeyringBackend | undefined): void => {
139+
backend = fake;
140+
cachedKey = undefined;
141+
cachedAvailable = undefined;
142+
};
143+
144+
export const safeStorage: SafeStorage = {
145+
isEncryptionAvailable() {
146+
return isAvailable();
147+
},
148+
encryptString(plainText) {
149+
if (!isAvailable()) {
150+
throw new SambarError('safeStorage: encryption is not available (no OS keyring)');
151+
}
152+
return encryptWithKey(getKey(), plainText);
153+
},
154+
decryptString(encrypted) {
155+
if (!isAvailable()) {
156+
throw new SambarError('safeStorage: encryption is not available (no OS keyring)');
157+
}
158+
return decryptWithKey(getKey(), encrypted);
159+
},
160+
};

src/main/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export {
3535
type ProtocolResponse,
3636
protocol,
3737
} from './api/protocol';
38+
export { type SafeStorage, safeStorage } from './api/safe-storage';
3839
export { type Display, type Point, screen, type Size } from './api/screen';
3940
export { Session, session } from './api/session';
4041
export { shell, type Shell } from './api/shell';

src/main/module-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const IMPLEMENTED_MODULES = [
7070
'Notification',
7171
'powerMonitor',
7272
'protocol',
73+
'safeStorage',
7374
'screen',
7475
'session',
7576
'shell',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { dlopen, FFIType, type Pointer } from 'bun:ffi';
2+
import { UnsupportedPlatformError } from '../../../common/errors';
3+
import { currentPlatform } from '../../../common/platform';
4+
import { cstr } from '../cstr';
5+
6+
/**
7+
* libsecret symbols behind the Linux keyring backend of `safeStorage`.
8+
*
9+
* The simple-password API stores/looks up a single secret under a `SecretSchema`.
10+
* We build the schema with {@link secret_schema_new} (libsecret owns the struct
11+
* layout — no blind offset reasoning) carrying ZERO attributes, so the schema
12+
* identifies one secret and the variadic attribute lists collapse to a trailing
13+
* `NULL`. We pass `NULL` for every `GError**` out-param (a NULL/0 return already
14+
* means "absent or failed"), which sidesteps GError ownership + pointer-precision
15+
* entirely.
16+
*
17+
* These calls are SYNCHRONOUS (blocking D-Bus). They are gated off in CI and
18+
* never run from unit tests (see `libsecret-keyring.ts`). Only callable on Linux.
19+
*/
20+
21+
const LIBSECRET_PATH = 'libsecret-1.so.0';
22+
const SCHEMA_NAME = 'dev.sambar.safeStorage';
23+
/** `SECRET_SCHEMA_NONE` — the secret is tagged with the schema name, so a zero-attribute lookup finds it. */
24+
const SECRET_SCHEMA_NONE = 0;
25+
26+
export const LIBSECRET_FFI_SYMBOLS = {
27+
// (name, flags, ...attrs terminated by NULL) -> SecretSchema* (transfer-full).
28+
secret_schema_new: {
29+
args: [FFIType.cstring, FFIType.i32, FFIType.pointer],
30+
returns: FFIType.pointer,
31+
},
32+
// (schema, collection|null, label, password, cancellable|null, error|null, NULL) -> gboolean
33+
secret_password_store_sync: {
34+
args: [
35+
FFIType.pointer,
36+
FFIType.cstring,
37+
FFIType.cstring,
38+
FFIType.cstring,
39+
FFIType.pointer,
40+
FFIType.pointer,
41+
FFIType.pointer,
42+
],
43+
returns: FFIType.i32,
44+
},
45+
// (schema, cancellable|null, error|null, NULL) -> gchar* (NULL if absent OR error)
46+
secret_password_lookup_sync: {
47+
args: [FFIType.pointer, FFIType.pointer, FFIType.pointer, FFIType.pointer],
48+
returns: FFIType.pointer,
49+
},
50+
// (password) -> void; frees a transfer-full gchar* from lookup.
51+
secret_password_free: {
52+
args: [FFIType.pointer],
53+
returns: FFIType.void,
54+
},
55+
} as const;
56+
57+
const cache: {
58+
ffi: ReturnType<typeof dlopen<typeof LIBSECRET_FFI_SYMBOLS>> | undefined;
59+
schema: Pointer | undefined;
60+
} = { ffi: undefined, schema: undefined };
61+
62+
const requireLinux = (): void => {
63+
const platform = currentPlatform();
64+
if (platform !== 'linux') {
65+
throw new UnsupportedPlatformError(
66+
`libsecret is only supported on Linux; current platform is ${platform}`,
67+
);
68+
}
69+
};
70+
71+
/** Open `libsecret-1.so.0` and expose the simple-password symbols. */
72+
export const loadLibsecretFFI = () => {
73+
requireLinux();
74+
if (cache.ffi) {
75+
return cache.ffi;
76+
}
77+
const ffi = dlopen(LIBSECRET_PATH, LIBSECRET_FFI_SYMBOLS);
78+
cache.ffi = ffi;
79+
return ffi;
80+
};
81+
82+
/** The shared, zero-attribute `SecretSchema*` (built + cached once). */
83+
export const secretSchema = (): Pointer => {
84+
if (cache.schema !== undefined) {
85+
return cache.schema;
86+
}
87+
const schema = loadLibsecretFFI().symbols.secret_schema_new(
88+
cstr(SCHEMA_NAME),
89+
SECRET_SCHEMA_NONE,
90+
null,
91+
);
92+
if (schema === null) {
93+
throw new Error('safeStorage: secret_schema_new() returned null');
94+
}
95+
cache.schema = schema;
96+
return schema;
97+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { randomBytes } from 'node:crypto';
2+
import { CString, type Pointer } from 'bun:ffi';
3+
import type { KeyringBackend } from '../../api/safe-storage';
4+
import { cstr } from '../cstr';
5+
import { loadLibsecretFFI, secretSchema } from './libsecret-ffi';
6+
7+
/**
8+
* Linux libsecret backend for `safeStorage`. The 32-byte key is stored as a
9+
* lowercase-hex STRING (libsecret passwords are NUL-terminated C strings, so raw
10+
* bytes are unsafe) in the default login keyring under a fixed zero-attribute
11+
* schema.
12+
*
13+
* The real store/lookup is SYNCHRONOUS (blocking D-Bus) and is gated behind
14+
* `SAMBAR_ENABLE_LINUX_KEYRING` — CI never sets it, so the blocking path is
15+
* unreachable under xvfb (echoing the GIO-read deadlock lesson). Crucially,
16+
* `isAvailable()` is CHEAP + NON-BLOCKING: it only checks the gate + that the
17+
* library dlopens; the one blocking keyring round-trip happens lazily inside
18+
* `getOrCreateKey()` (called once, behind the API's key cache).
19+
*/
20+
21+
const LABEL = 'Sambar safeStorage key';
22+
23+
/** Whether the live keyring path is enabled. CI leaves this unset → backend reports unavailable. */
24+
const liveKeyringEnabled = (): boolean => process.env['SAMBAR_ENABLE_LINUX_KEYRING'] === '1';
25+
26+
/** Read a transfer-full `gchar*` into a JS string and `secret_password_free` it. */
27+
const takePassword = (password: Pointer): string => {
28+
const value = new CString(password).toString();
29+
loadLibsecretFFI().symbols.secret_password_free(password);
30+
return value;
31+
};
32+
33+
/** Look up the stored hex key. Returns null if absent OR on any error. Never throws. */
34+
const lookupHex = (): string | null => {
35+
const lib = loadLibsecretFFI();
36+
let result: Pointer | null;
37+
try {
38+
// (schema, cancellable=null, error=null, NULL terminator). A NULL error
39+
// out-param means a null return already covers "absent or failed".
40+
result = lib.symbols.secret_password_lookup_sync(secretSchema(), null, null, null);
41+
} catch {
42+
return null;
43+
}
44+
return result === null ? null : takePassword(result);
45+
};
46+
47+
/** Store the hex key. Returns false on any failure. */
48+
const storeHex = (hex: string): boolean => {
49+
const lib = loadLibsecretFFI();
50+
try {
51+
// (schema, collection=null→default, label, password, cancellable=null, error=null, NULL)
52+
const ok = lib.symbols.secret_password_store_sync(
53+
secretSchema(),
54+
null,
55+
cstr(LABEL),
56+
cstr(hex),
57+
null,
58+
null,
59+
null,
60+
);
61+
return ok !== 0;
62+
} catch {
63+
return false;
64+
}
65+
};
66+
67+
/** Decode a stored hex value to a 32-byte key, or throw if it is malformed (never overwrite). */
68+
const decodeKey = (hex: string): Buffer => {
69+
const buf = Buffer.from(hex, 'hex');
70+
if (buf.length !== 32) {
71+
throw new Error(
72+
'safeStorage: existing Linux keyring key is malformed; refusing to overwrite it',
73+
);
74+
}
75+
return buf;
76+
};
77+
78+
export const linuxLibsecretBackend: KeyringBackend = {
79+
// Cheap + non-blocking: the gate must be on AND the library must dlopen. The
80+
// actual keyring round-trip is deferred to getOrCreateKey().
81+
isAvailable: () => {
82+
if (!liveKeyringEnabled()) {
83+
return false;
84+
}
85+
try {
86+
loadLibsecretFFI();
87+
return true;
88+
} catch {
89+
return false;
90+
}
91+
},
92+
getOrCreateKey: () => {
93+
const existing = lookupHex();
94+
if (existing !== null) {
95+
return decodeKey(existing);
96+
}
97+
const fresh = randomBytes(32);
98+
if (!storeHex(fresh.toString('hex'))) {
99+
throw new Error('safeStorage: failed to store key in the Linux keyring');
100+
}
101+
// Adopt whatever the keyring actually holds (a concurrent writer may have won).
102+
const winner = lookupHex();
103+
return winner !== null ? decodeKey(winner) : fresh;
104+
},
105+
};

0 commit comments

Comments
 (0)