Skip to content

Commit f11fa97

Browse files
authored
Merge pull request #272 from rindicomfort/feat/secrets-management
feat: implement subscription secrets management system (#250)
2 parents 5d432b3 + 9393191 commit f11fa97

2 files changed

Lines changed: 479 additions & 0 deletions

File tree

backend/secrets/SecretsVault.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
3+
// ---------------------------------------------------------------------------
4+
// Types
5+
// ---------------------------------------------------------------------------
6+
7+
export type Environment = 'development' | 'staging' | 'production';
8+
9+
export interface SecretMetadata {
10+
key: string;
11+
env: Environment;
12+
version: number;
13+
createdAt: number;
14+
rotatedAt: number | null;
15+
/** Rotation interval in ms; null = no auto-rotation */
16+
rotationIntervalMs: number | null;
17+
/** Whether this secret has been soft-deleted */
18+
deleted: boolean;
19+
}
20+
21+
export interface SecretEntry {
22+
meta: SecretMetadata;
23+
/** Obfuscated value stored in AsyncStorage (base64) */
24+
value: string;
25+
}
26+
27+
export interface AuditEvent {
28+
action: 'set' | 'get' | 'rotate' | 'delete' | 'recover' | 'inject';
29+
key: string;
30+
env: Environment;
31+
timestamp: number;
32+
success: boolean;
33+
reason?: string;
34+
}
35+
36+
export interface InjectedSecrets {
37+
STELLAR_NETWORK: string;
38+
CONTRACT_ID: string;
39+
WEB3AUTH_CLIENT_ID: string;
40+
[key: string]: string;
41+
}
42+
43+
// ---------------------------------------------------------------------------
44+
// Constants
45+
// ---------------------------------------------------------------------------
46+
47+
const VAULT_PREFIX = '@subtrackr:secrets:';
48+
const AUDIT_KEY = '@subtrackr:secrets:audit';
49+
const INDEX_KEY = '@subtrackr:secrets:index';
50+
const MAX_AUDIT_EVENTS = 1000;
51+
52+
// ---------------------------------------------------------------------------
53+
// Minimal obfuscation (base64) — keeps values out of plain-text logs.
54+
// For production-grade encryption, replace with expo-crypto AES-GCM.
55+
// ---------------------------------------------------------------------------
56+
57+
function encode(value: string): string {
58+
return Buffer.from(value, 'utf8').toString('base64');
59+
}
60+
61+
function decode(encoded: string): string {
62+
return Buffer.from(encoded, 'base64').toString('utf8');
63+
}
64+
65+
function storageKey(key: string, env: Environment): string {
66+
return `${VAULT_PREFIX}${env}:${key}`;
67+
}
68+
69+
// ---------------------------------------------------------------------------
70+
// SecretsVault
71+
// ---------------------------------------------------------------------------
72+
73+
export class SecretsVault {
74+
private readonly currentEnv: Environment;
75+
76+
constructor(env: Environment = 'development') {
77+
this.currentEnv = env;
78+
}
79+
80+
// ── Set / Get ─────────────────────────────────────────────────────────────
81+
82+
async set(
83+
key: string,
84+
value: string,
85+
options: { env?: Environment; rotationIntervalMs?: number } = {}
86+
): Promise<SecretMetadata> {
87+
const env = options.env ?? this.currentEnv;
88+
const existing = await this._load(key, env);
89+
const version = existing ? existing.meta.version + 1 : 1;
90+
91+
const meta: SecretMetadata = {
92+
key,
93+
env,
94+
version,
95+
createdAt: existing?.meta.createdAt ?? Date.now(),
96+
rotatedAt: version > 1 ? Date.now() : null,
97+
rotationIntervalMs: options.rotationIntervalMs ?? existing?.meta.rotationIntervalMs ?? null,
98+
deleted: false,
99+
};
100+
101+
const entry: SecretEntry = { meta, value: encode(value) };
102+
await AsyncStorage.setItem(storageKey(key, env), JSON.stringify(entry));
103+
await this._updateIndex(meta);
104+
await this._audit({ action: version > 1 ? 'rotate' : 'set', key, env, success: true });
105+
return meta;
106+
}
107+
108+
async get(key: string, env?: Environment): Promise<string | null> {
109+
const resolvedEnv = env ?? this.currentEnv;
110+
const entry = await this._load(key, resolvedEnv);
111+
if (!entry || entry.meta.deleted) {
112+
await this._audit({
113+
action: 'get',
114+
key,
115+
env: resolvedEnv,
116+
success: false,
117+
reason: 'not found or deleted',
118+
});
119+
return null;
120+
}
121+
await this._audit({ action: 'get', key, env: resolvedEnv, success: true });
122+
return decode(entry.value);
123+
}
124+
125+
// ── Rotation ──────────────────────────────────────────────────────────────
126+
127+
/** Rotate a secret to a new value, incrementing its version */
128+
async rotate(key: string, newValue: string, env?: Environment): Promise<SecretMetadata> {
129+
const resolvedEnv = env ?? this.currentEnv;
130+
const existing = await this._load(key, resolvedEnv);
131+
if (!existing || existing.meta.deleted) {
132+
await this._audit({
133+
action: 'rotate',
134+
key,
135+
env: resolvedEnv,
136+
success: false,
137+
reason: 'secret not found',
138+
});
139+
throw new Error(`Secret "${key}" not found in ${resolvedEnv}`);
140+
}
141+
return this.set(key, newValue, {
142+
env: resolvedEnv,
143+
rotationIntervalMs: existing.meta.rotationIntervalMs ?? undefined,
144+
});
145+
}
146+
147+
/** Returns secrets whose rotation interval has elapsed */
148+
async getDueForRotation(env?: Environment): Promise<SecretMetadata[]> {
149+
const resolvedEnv = env ?? this.currentEnv;
150+
const index = await this._getIndex();
151+
const now = Date.now();
152+
return index.filter(
153+
(m) =>
154+
m.env === resolvedEnv &&
155+
!m.deleted &&
156+
m.rotationIntervalMs !== null &&
157+
now - (m.rotatedAt ?? m.createdAt) >= m.rotationIntervalMs
158+
);
159+
}
160+
161+
// ── Environment-specific secrets ──────────────────────────────────────────
162+
163+
/** List all non-deleted secrets for a given environment */
164+
async listByEnv(env?: Environment): Promise<SecretMetadata[]> {
165+
const resolvedEnv = env ?? this.currentEnv;
166+
const index = await this._getIndex();
167+
return index.filter((m) => m.env === resolvedEnv && !m.deleted);
168+
}
169+
170+
// ── Secrets injection ─────────────────────────────────────────────────────
171+
172+
/**
173+
* Inject all secrets for the current environment into a flat object.
174+
* Use this to populate app config at startup.
175+
*/
176+
async inject(env?: Environment): Promise<Partial<InjectedSecrets>> {
177+
const resolvedEnv = env ?? this.currentEnv;
178+
const metas = await this.listByEnv(resolvedEnv);
179+
const result: Partial<InjectedSecrets> = {};
180+
for (const meta of metas) {
181+
const value = await this.get(meta.key, resolvedEnv);
182+
if (value !== null) result[meta.key] = value;
183+
}
184+
await this._audit({ action: 'inject', key: '*', env: resolvedEnv, success: true });
185+
return result;
186+
}
187+
188+
// ── Soft delete ───────────────────────────────────────────────────────────
189+
190+
async delete(key: string, env?: Environment): Promise<void> {
191+
const resolvedEnv = env ?? this.currentEnv;
192+
const entry = await this._load(key, resolvedEnv);
193+
if (!entry) return;
194+
entry.meta.deleted = true;
195+
await AsyncStorage.setItem(storageKey(key, resolvedEnv), JSON.stringify(entry));
196+
await this._updateIndex(entry.meta);
197+
await this._audit({ action: 'delete', key, env: resolvedEnv, success: true });
198+
}
199+
200+
// ── Recovery ──────────────────────────────────────────────────────────────
201+
202+
/** Recover a soft-deleted secret */
203+
async recover(key: string, env?: Environment): Promise<SecretMetadata> {
204+
const resolvedEnv = env ?? this.currentEnv;
205+
const entry = await this._load(key, resolvedEnv);
206+
if (!entry) {
207+
await this._audit({
208+
action: 'recover',
209+
key,
210+
env: resolvedEnv,
211+
success: false,
212+
reason: 'not found',
213+
});
214+
throw new Error(`Secret "${key}" not found in ${resolvedEnv}`);
215+
}
216+
entry.meta.deleted = false;
217+
await AsyncStorage.setItem(storageKey(key, resolvedEnv), JSON.stringify(entry));
218+
await this._updateIndex(entry.meta);
219+
await this._audit({ action: 'recover', key, env: resolvedEnv, success: true });
220+
return entry.meta;
221+
}
222+
223+
// ── Audit log ─────────────────────────────────────────────────────────────
224+
225+
async getAuditLog(limit = 100): Promise<AuditEvent[]> {
226+
const raw = await AsyncStorage.getItem(AUDIT_KEY);
227+
const events: AuditEvent[] = raw ? JSON.parse(raw) : [];
228+
return events.slice(-limit);
229+
}
230+
231+
async clearAuditLog(): Promise<void> {
232+
await AsyncStorage.removeItem(AUDIT_KEY);
233+
}
234+
235+
// ── Private ───────────────────────────────────────────────────────────────
236+
237+
private async _load(key: string, env: Environment): Promise<SecretEntry | null> {
238+
const raw = await AsyncStorage.getItem(storageKey(key, env));
239+
return raw ? (JSON.parse(raw) as SecretEntry) : null;
240+
}
241+
242+
private async _getIndex(): Promise<SecretMetadata[]> {
243+
const raw = await AsyncStorage.getItem(INDEX_KEY);
244+
return raw ? (JSON.parse(raw) as SecretMetadata[]) : [];
245+
}
246+
247+
private async _updateIndex(meta: SecretMetadata): Promise<void> {
248+
const index = await this._getIndex();
249+
const idx = index.findIndex((m) => m.key === meta.key && m.env === meta.env);
250+
if (idx >= 0) index[idx] = meta;
251+
else index.push(meta);
252+
await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index));
253+
}
254+
255+
private async _audit(event: Omit<AuditEvent, 'timestamp'>): Promise<void> {
256+
const raw = await AsyncStorage.getItem(AUDIT_KEY);
257+
const events: AuditEvent[] = raw ? JSON.parse(raw) : [];
258+
events.push({ ...event, timestamp: Date.now() });
259+
if (events.length > MAX_AUDIT_EVENTS) events.splice(0, events.length - MAX_AUDIT_EVENTS);
260+
await AsyncStorage.setItem(AUDIT_KEY, JSON.stringify(events));
261+
}
262+
}
263+
264+
export const secretsVault = new SecretsVault(
265+
(process.env['APP_ENV'] as Environment | undefined) ?? 'development'
266+
);

0 commit comments

Comments
 (0)