Skip to content

Commit f19126f

Browse files
authored
feat(core): add secure storage foundation (#36)
2 parents 3c19bbf + 647cec4 commit f19126f

10 files changed

Lines changed: 1453 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@inflowpayai/inflow': patch
3+
---
4+
5+
Add the Keychain, SQLite, and cross-store lifecycle foundation used by the signed command-line application.

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"url": "https://github.com/inflowpayai/inflow-cli/issues"
4444
},
4545
"dependencies": {
46+
"@napi-rs/keyring": "1.3.0",
4647
"conf": "^13.1.0",
4748
"strip-ansi": "^7.2.0"
4849
},
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { InflowSdkError } from '../errors.js';
2+
3+
export type SecureStorageErrorCode =
4+
| 'secure_storage_corrupt'
5+
| 'secure_storage_invalid_path'
6+
| 'secure_storage_io_error'
7+
| 'secure_storage_secret_missing'
8+
| 'secure_storage_unavailable';
9+
10+
export class SecureStorageError extends InflowSdkError {
11+
readonly secureStorageCode: SecureStorageErrorCode;
12+
13+
constructor(code: SecureStorageErrorCode, message: string, options: { cause?: unknown } = {}) {
14+
super(message, { cause: options.cause, code });
15+
this.name = 'SecureStorageError';
16+
this.secureStorageCode = code;
17+
}
18+
}
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import { Buffer } from 'node:buffer';
2+
import { createRequire } from 'node:module';
3+
import { randomUUID } from 'node:crypto';
4+
import { SecureStorageError } from './errors.js';
5+
6+
const require = createRequire(import.meta.url);
7+
8+
type KeyringPassword = string | Buffer | Uint8Array | null | undefined;
9+
10+
interface KeyringEntry {
11+
deletePassword(): Promise<boolean>;
12+
getPassword(): Promise<KeyringPassword>;
13+
setPassword(password: string | Buffer | Uint8Array): Promise<void>;
14+
}
15+
16+
interface KeyringModule {
17+
AsyncEntry: new (service: string, account: string) => KeyringEntry;
18+
Entry: new (service: string, account: string) => SyncKeyringEntry;
19+
}
20+
21+
interface SyncKeyringEntry {
22+
deleteCredential(): boolean;
23+
getSecret(): number[] | Uint8Array | null;
24+
setSecret(secret: Uint8Array): void;
25+
}
26+
27+
export interface KeychainSecretStoreOptions {
28+
loadKeyring?: () => KeyringModule;
29+
serviceName?: string;
30+
}
31+
32+
export interface SecretReference {
33+
purpose: string;
34+
reference: string;
35+
}
36+
37+
export interface SecureSecretStore {
38+
create(reference: SecretReference, value: Uint8Array): Promise<void>;
39+
delete(reference: SecretReference): Promise<void>;
40+
read(reference: SecretReference): Promise<Uint8Array>;
41+
}
42+
43+
export interface SyncSecureSecretStore {
44+
create(reference: SecretReference, value: Uint8Array): void;
45+
delete(reference: SecretReference): void;
46+
read(reference: SecretReference): Uint8Array;
47+
}
48+
49+
const DEFAULT_SERVICE_NAME = 'ai.inflowpay.cli';
50+
51+
function loadDefaultKeyring(): KeyringModule {
52+
const loaded = require('@napi-rs/keyring') as unknown;
53+
if (typeof loaded !== 'object' || loaded === null || !('AsyncEntry' in loaded)) {
54+
throw new SecureStorageError('secure_storage_unavailable', 'The Keychain binding did not expose AsyncEntry.');
55+
}
56+
return loaded as KeyringModule;
57+
}
58+
59+
function accountFor(reference: SecretReference): string {
60+
if (reference.reference.length === 0 || reference.purpose.length === 0) {
61+
throw new SecureStorageError(
62+
'secure_storage_invalid_path',
63+
'Secret references require a purpose and opaque reference.',
64+
);
65+
}
66+
return `${reference.purpose}:${reference.reference}`;
67+
}
68+
69+
function bytesFromPassword(value: KeyringPassword): Uint8Array {
70+
if (value === null || value === undefined) {
71+
throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.');
72+
}
73+
if (typeof value === 'string') {
74+
return Buffer.from(value, 'base64');
75+
}
76+
return Uint8Array.from(value);
77+
}
78+
79+
export function createOpaqueSecretReference(purpose: string): SecretReference {
80+
return { purpose, reference: randomUUID() };
81+
}
82+
83+
export class KeychainSecretStore implements SecureSecretStore {
84+
private readonly loadKeyring: () => KeyringModule;
85+
private readonly serviceName: string;
86+
87+
constructor(options: KeychainSecretStoreOptions = {}) {
88+
this.loadKeyring = options.loadKeyring ?? loadDefaultKeyring;
89+
this.serviceName = options.serviceName ?? DEFAULT_SERVICE_NAME;
90+
}
91+
92+
async create(reference: SecretReference, value: Uint8Array): Promise<void> {
93+
try {
94+
await this.entry(reference).setPassword(Buffer.from(value).toString('base64'));
95+
} catch (cause) {
96+
throw new SecureStorageError('secure_storage_io_error', 'Failed to write a secret to Keychain.', { cause });
97+
}
98+
}
99+
100+
async read(reference: SecretReference): Promise<Uint8Array> {
101+
try {
102+
return bytesFromPassword(await this.entry(reference).getPassword());
103+
} catch (cause) {
104+
if (cause instanceof SecureStorageError) throw cause;
105+
throw new SecureStorageError('secure_storage_io_error', 'Failed to read a secret from Keychain.', { cause });
106+
}
107+
}
108+
109+
async delete(reference: SecretReference): Promise<void> {
110+
try {
111+
const deleted = await this.entry(reference).deletePassword();
112+
if (!deleted) {
113+
throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret could not be deleted.');
114+
}
115+
} catch (cause) {
116+
if (cause instanceof SecureStorageError) throw cause;
117+
throw new SecureStorageError('secure_storage_io_error', 'Failed to delete a secret from Keychain.', { cause });
118+
}
119+
}
120+
121+
private entry(reference: SecretReference): KeyringEntry {
122+
return new (this.loadKeyring().AsyncEntry)(this.serviceName, accountFor(reference));
123+
}
124+
}
125+
126+
export class SyncKeychainSecretStore implements SyncSecureSecretStore {
127+
private readonly loadKeyring: () => KeyringModule;
128+
private readonly serviceName: string;
129+
130+
constructor(options: KeychainSecretStoreOptions = {}) {
131+
this.loadKeyring = options.loadKeyring ?? loadDefaultKeyring;
132+
this.serviceName = options.serviceName ?? DEFAULT_SERVICE_NAME;
133+
}
134+
135+
create(reference: SecretReference, value: Uint8Array): void {
136+
try {
137+
this.entry(reference).setSecret(value);
138+
} catch (cause) {
139+
throw new SecureStorageError('secure_storage_io_error', 'Failed to write a secret to Keychain.', { cause });
140+
}
141+
}
142+
143+
read(reference: SecretReference): Uint8Array {
144+
try {
145+
const value = this.entry(reference).getSecret();
146+
if (value === null) {
147+
throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.');
148+
}
149+
return Uint8Array.from(value);
150+
} catch (cause) {
151+
if (cause instanceof SecureStorageError) throw cause;
152+
throw new SecureStorageError('secure_storage_io_error', 'Failed to read a secret from Keychain.', { cause });
153+
}
154+
}
155+
156+
delete(reference: SecretReference): void {
157+
try {
158+
if (!this.entry(reference).deleteCredential()) {
159+
throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret could not be deleted.');
160+
}
161+
} catch (cause) {
162+
if (cause instanceof SecureStorageError) throw cause;
163+
throw new SecureStorageError('secure_storage_io_error', 'Failed to delete a secret from Keychain.', { cause });
164+
}
165+
}
166+
167+
private entry(reference: SecretReference): SyncKeyringEntry {
168+
return new (this.loadKeyring().Entry)(this.serviceName, accountFor(reference));
169+
}
170+
}
171+
172+
export class KeychainReferenceManifest {
173+
private readonly reference: SecretReference;
174+
175+
constructor(
176+
private readonly store: SecureSecretStore,
177+
purpose = 'manifest',
178+
) {
179+
this.reference = { purpose, reference: 'fixed-keychain-references' };
180+
}
181+
182+
async add(reference: SecretReference): Promise<void> {
183+
const manifest = await this.read();
184+
if (!manifest.some((candidate) => sameReference(candidate, reference))) {
185+
manifest.push(reference);
186+
await this.write(manifest);
187+
}
188+
}
189+
190+
async remove(reference: SecretReference): Promise<void> {
191+
await this.write((await this.read()).filter((candidate) => !sameReference(candidate, reference)));
192+
}
193+
194+
async read(): Promise<SecretReference[]> {
195+
try {
196+
const parsed = JSON.parse(Buffer.from(await this.store.read(this.reference)).toString('utf8')) as unknown;
197+
if (!Array.isArray(parsed)) {
198+
throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.');
199+
}
200+
return parsed.map(parseManifestEntry);
201+
} catch (cause) {
202+
if (cause instanceof SecureStorageError && cause.secureStorageCode === 'secure_storage_secret_missing') return [];
203+
if (cause instanceof SecureStorageError) throw cause;
204+
throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.', {
205+
cause,
206+
});
207+
}
208+
}
209+
210+
private async write(references: SecretReference[]): Promise<void> {
211+
const payload = Buffer.from(JSON.stringify(references), 'utf8');
212+
try {
213+
await this.store.delete(this.reference);
214+
} catch (cause) {
215+
if (!(cause instanceof SecureStorageError) || cause.secureStorageCode !== 'secure_storage_secret_missing') {
216+
throw cause;
217+
}
218+
}
219+
await this.store.create(this.reference, payload);
220+
}
221+
}
222+
223+
export class SyncKeychainReferenceManifest {
224+
private readonly reference: SecretReference;
225+
226+
constructor(
227+
private readonly store: SyncSecureSecretStore,
228+
purpose = 'manifest',
229+
) {
230+
this.reference = { purpose, reference: 'fixed-keychain-references' };
231+
}
232+
233+
add(reference: SecretReference): void {
234+
const manifest = this.read();
235+
if (!manifest.some((candidate) => sameReference(candidate, reference))) {
236+
manifest.push(reference);
237+
this.write(manifest);
238+
}
239+
}
240+
241+
remove(reference: SecretReference): void {
242+
this.write(this.read().filter((candidate) => !sameReference(candidate, reference)));
243+
}
244+
245+
read(): SecretReference[] {
246+
try {
247+
const parsed = JSON.parse(Buffer.from(this.store.read(this.reference)).toString('utf8')) as unknown;
248+
if (!Array.isArray(parsed)) {
249+
throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.');
250+
}
251+
return parsed.map(parseManifestEntry);
252+
} catch (cause) {
253+
if (cause instanceof SecureStorageError && cause.secureStorageCode === 'secure_storage_secret_missing') return [];
254+
if (cause instanceof SecureStorageError) throw cause;
255+
throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.', {
256+
cause,
257+
});
258+
}
259+
}
260+
261+
private write(references: SecretReference[]): void {
262+
const payload = Buffer.from(JSON.stringify(references), 'utf8');
263+
try {
264+
this.store.delete(this.reference);
265+
} catch (cause) {
266+
if (!(cause instanceof SecureStorageError) || cause.secureStorageCode !== 'secure_storage_secret_missing') {
267+
throw cause;
268+
}
269+
}
270+
this.store.create(this.reference, payload);
271+
}
272+
}
273+
274+
function parseManifestEntry(value: unknown): SecretReference {
275+
if (typeof value !== 'object' || value === null) {
276+
throw new SecureStorageError(
277+
'secure_storage_corrupt',
278+
'The Keychain reference manifest contains a malformed entry.',
279+
);
280+
}
281+
const candidate = value as Partial<SecretReference>;
282+
if (typeof candidate.purpose !== 'string' || typeof candidate.reference !== 'string') {
283+
throw new SecureStorageError(
284+
'secure_storage_corrupt',
285+
'The Keychain reference manifest contains a malformed entry.',
286+
);
287+
}
288+
return { purpose: candidate.purpose, reference: candidate.reference };
289+
}
290+
291+
function sameReference(left: SecretReference, right: SecretReference): boolean {
292+
return left.purpose === right.purpose && left.reference === right.reference;
293+
}
294+
295+
export class MemorySecretStore implements SecureSecretStore {
296+
private readonly values = new Map<string, Uint8Array>();
297+
298+
create(reference: SecretReference, value: Uint8Array): Promise<void> {
299+
this.values.set(accountFor(reference), Uint8Array.from(value));
300+
return Promise.resolve();
301+
}
302+
303+
read(reference: SecretReference): Promise<Uint8Array> {
304+
const value = this.values.get(accountFor(reference));
305+
if (value === undefined) {
306+
return Promise.reject(
307+
new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.'),
308+
);
309+
}
310+
return Promise.resolve(Uint8Array.from(value));
311+
}
312+
313+
delete(reference: SecretReference): Promise<void> {
314+
if (!this.values.delete(accountFor(reference))) {
315+
return Promise.reject(
316+
new SecureStorageError('secure_storage_secret_missing', 'A referenced secret could not be deleted.'),
317+
);
318+
}
319+
return Promise.resolve();
320+
}
321+
}
322+
323+
export class SyncMemorySecretStore implements SyncSecureSecretStore {
324+
private readonly values = new Map<string, Uint8Array>();
325+
326+
create(reference: SecretReference, value: Uint8Array): void {
327+
this.values.set(accountFor(reference), Uint8Array.from(value));
328+
}
329+
330+
read(reference: SecretReference): Uint8Array {
331+
const value = this.values.get(accountFor(reference));
332+
if (value === undefined) {
333+
throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.');
334+
}
335+
return Uint8Array.from(value);
336+
}
337+
338+
delete(reference: SecretReference): void {
339+
if (!this.values.delete(accountFor(reference))) {
340+
throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret could not be deleted.');
341+
}
342+
}
343+
}

0 commit comments

Comments
 (0)