File size: 4,101 Bytes
3d7d9b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { Stronghold, type Client } from '@tauri-apps/plugin-stronghold';
import { appDataDir, join } from '@tauri-apps/api/path';

export interface CredentialRecord {
  id: string;
  origin: string;
  username: string;
  password: string;
  label?: string;
  createdAt: number;
  updatedAt: number;
}

export interface CredentialSummary {
  id: string;
  origin: string;
  username: string;
  label?: string;
  updatedAt: number;
}

const CLIENT_NAME = 'musealpha-credentials';
const INDEX_KEY = 'cred:index';

let stronghold: Stronghold | null = null;
let client: Client | null = null;

const enc = new TextEncoder();
const dec = new TextDecoder();

function bytes(v: string): number[] { return Array.from(enc.encode(v)); }
function text(v: number[] | Uint8Array): string { return dec.decode(new Uint8Array(v)); }

async function vaultPath() {
  return await join(await appDataDir(), 'credentials.stronghold');
}

export async function unlockVault(masterPassword: string) {
  const path = await vaultPath();
  stronghold = await Stronghold.load(path, masterPassword);
  try {
    client = await stronghold.loadClient(CLIENT_NAME);
  } catch {
    client = await stronghold.createClient(CLIENT_NAME);
    await stronghold.save();
  }
}

export function isVaultUnlocked() { return !!stronghold && !!client; }
export function lockVault() { stronghold = null; client = null; }

function ensureClient(): Client {
  if (!client || !stronghold) throw new Error('Password vault is locked');
  return client;
}

async function loadIndex(): Promise<string[]> {
  const store = ensureClient().getStore();
  const raw = await store.get(INDEX_KEY);
  if (!raw) return [];
  try { return JSON.parse(text(raw)); } catch { return []; }
}

async function saveIndex(ids: string[]) {
  const store = ensureClient().getStore();
  await store.insert(INDEX_KEY, bytes(JSON.stringify([...new Set(ids)])));
  await stronghold!.save();
}

function key(id: string) { return `cred:${id}`; }

export async function saveCredential(input: Omit<CredentialRecord, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) {
  const store = ensureClient().getStore();
  const now = Date.now();
  const id = input.id || crypto.randomUUID();
  const existing = input.id ? await getCredential(input.id).catch(() => null) : null;
  const record: CredentialRecord = {
    id,
    origin: normalizeOrigin(input.origin),
    username: input.username,
    password: input.password,
    label: input.label,
    createdAt: existing?.createdAt || now,
    updatedAt: now,
  };
  await store.insert(key(id), bytes(JSON.stringify(record)));
  const idx = await loadIndex();
  await saveIndex([...idx, id]);
  await stronghold!.save();
  return record;
}

export async function getCredential(id: string): Promise<CredentialRecord | null> {
  const store = ensureClient().getStore();
  const raw = await store.get(key(id));
  if (!raw) return null;
  return JSON.parse(text(raw));
}

export async function listCredentials(origin?: string): Promise<CredentialSummary[]> {
  const idx = await loadIndex();
  const normalized = origin ? normalizeOrigin(origin) : null;
  const out: CredentialSummary[] = [];
  for (const id of idx) {
    const r = await getCredential(id).catch(() => null);
    if (!r) continue;
    if (normalized && r.origin !== normalized) continue;
    out.push({ id: r.id, origin: r.origin, username: r.username, label: r.label, updatedAt: r.updatedAt });
  }
  return out.sort((a, b) => b.updatedAt - a.updatedAt);
}

export async function deleteCredential(id: string) {
  const store = ensureClient().getStore();
  await store.remove(key(id));
  const idx = await loadIndex();
  await saveIndex(idx.filter(x => x !== id));
  await stronghold!.save();
}

export function normalizeOrigin(input: string): string {
  try {
    const u = new URL(input.includes('://') ? input : `https://${input}`);
    return `${u.protocol}//${u.host}`.toLowerCase();
  } catch {
    return input.trim().toLowerCase();
  }
}