cautpartener / client / src / core / identity-store.ts
  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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
/**
 * App-level identity lifecycle over lib/identity.ts primitives.
 *
 * States: none → created(backed-up) → active. An identity is NEVER usable
 * until the user confirmed the seed backup: createIdentity() only derives and
 * returns the seed — nothing is persisted until confirmBackup()/import, so a
 * half-finished onboarding leaves no unusable half-identity behind.
 *
 * Persistence: nsKey("id") holds the 43-char base64url seed; nsKey("id-meta")
 * holds { createdAt, backedUp: true, persisted? }. A long-lived marker cookie
 * plus the meta record let wipeCheck() distinguish "new user" from "the
 * browser evicted our storage" — in the second case the recovery screen must
 * offer import and NEVER silently mint a new identity.
 */

import { nsDb, nsKey, NS } from "../config";
import { fromB64url } from "../lib/crypto";
import { Identity } from "../lib/identity";

export interface IdMeta {
  createdAt: number;
  backedUp: boolean;
  /** navigator.storage.persist() outcome; null = API absent / not asked yet. */
  persisted: boolean | null;
}

export interface ActiveIdentity {
  identity: Identity;
  seed: string;
  meta: IdMeta;
}

export type IdentityStateKind = "none" | "wiped" | "active";

const SEED_RE = /^[A-Za-z0-9_-]{43}$/;
// KNOWN LIMITATION (Safari/WebKit ITP): script-set cookies are capped at
// 7 days, and ITP evicts them TOGETHER with localStorage after 7 days without
// interaction — so on Safari a total eviction leaves no marker and wipeCheck()
// reports "none" (onboarding), not "wiped". The marker only reliably detects
// partial evictions and non-WebKit browsers. Mitigations for the blind spot:
// the onboarding CREATE panel carries a "used this device before? import
// instead" hint, and creation warns when navigator.storage.persist() is
// denied. Do not treat the marker as a guarantee.
const MARK_COOKIE = `${NS}-id-mark`;
// Chrome caps cookie lifetime at 400 days; refresh it on every active boot.
const MARK_MAX_AGE = 400 * 24 * 3600;

export function isValidSeed(seed: string): boolean {
  if (!SEED_RE.test(seed)) return false;
  try {
    return fromB64url(seed).length === 32;
  } catch {
    return false;
  }
}

/** Current persisted meta (profile tab reads persisted-storage status live). */
export function getStoredMeta(): IdMeta | null {
  return readMeta();
}

function readMeta(): IdMeta | null {
  try {
    const raw = localStorage.getItem(nsKey("id-meta"));
    if (!raw) return null;
    const m = JSON.parse(raw) as Partial<IdMeta>;
    if (typeof m.createdAt !== "number" || typeof m.backedUp !== "boolean") return null;
    return { createdAt: m.createdAt, backedUp: m.backedUp, persisted: m.persisted ?? null };
  } catch {
    return null;
  }
}

function writeMeta(meta: IdMeta): void {
  try {
    localStorage.setItem(nsKey("id-meta"), JSON.stringify(meta));
  } catch {}
}

function hasMarker(): boolean {
  try {
    return document.cookie.split(";").some((c) => c.trim().startsWith(`${MARK_COOKIE}=`));
  } catch {
    return false;
  }
}

/** (Re-)arm the wipe marker; called on every successful identity load too. */
function setMarker(): void {
  try {
    document.cookie = `${MARK_COOKIE}=1; max-age=${MARK_MAX_AGE}; path=/; SameSite=Lax`;
  } catch {}
}

function clearMarker(): void {
  try {
    document.cookie = `${MARK_COOKIE}=; max-age=0; path=/; SameSite=Lax`;
  } catch {}
}

/**
 * Best-effort durable-storage request. Non-blocking for callers that don't
 * await; the result is recorded into id-meta for the profile sync status line.
 */
export async function requestStoragePersist(): Promise<boolean | null> {
  let result: boolean | null = null;
  try {
    if (navigator.storage?.persist) result = await navigator.storage.persist();
  } catch {
    result = null;
  }
  const meta = readMeta();
  if (meta) writeMeta({ ...meta, persisted: result });
  return result;
}

/** The backed-up identity of this device, or null (not created / not usable). */
export async function getIdentity(): Promise<ActiveIdentity | null> {
  let seed = "";
  try {
    seed = localStorage.getItem(nsKey("id")) ?? "";
  } catch {
    return null;
  }
  const meta = readMeta();
  if (!seed || !isValidSeed(seed) || !meta?.backedUp) return null;
  try {
    const identity = await Identity.fromSeed(seed);
    setMarker(); // keep the eviction marker fresh
    return { identity, seed, meta };
  } catch {
    return null; // no Ed25519 support / corrupted seed
  }
}

/**
 * Mint a new identity candidate. NOTHING is persisted — the identity only
 * becomes real (and usable) once the user proves the backup via
 * confirmBackup(seed). Abandoning the flow costs nothing.
 */
export async function createIdentity(): Promise<{ seed: string; identity: Identity }> {
  const seed = Identity.newSeed();
  return { seed, identity: await Identity.fromSeed(seed) };
}

/**
 * The user confirmed the seed is saved: persist seed + meta (backedUp:true),
 * arm the wipe marker and request durable storage (recorded, non-blocking).
 */
export async function confirmBackup(seed: string): Promise<ActiveIdentity> {
  if (!isValidSeed(seed)) throw new Error("invalid seed");
  const identity = await Identity.fromSeed(seed);
  const meta: IdMeta = { createdAt: Date.now(), backedUp: true, persisted: null };
  // BOTH writes must throw out of confirmBackup on failure (quota, private
  // mode): the UI catches, toasts and keeps the backup gate up — an identity
  // the UI believes is persisted but isn't would strand the user. The later
  // background persist()-result update (requestStoragePersist → writeMeta)
  // stays best-effort/swallowing.
  localStorage.setItem(nsKey("id"), seed);
  localStorage.setItem(nsKey("id-meta"), JSON.stringify(meta));
  setMarker();
  void requestStoragePersist();
  return { identity, seed, meta };
}

/**
 * Import an existing identity from its seed. Validates the 43-char base64url
 * form, derives the keypair (throws on garbage) and persists as backed-up —
 * the user necessarily HAS the seed, they just typed it.
 */
export async function importIdentity(seedB64url: string): Promise<ActiveIdentity> {
  const seed = seedB64url.trim();
  if (!isValidSeed(seed)) throw new Error("invalid seed");
  return confirmBackup(seed);
}

/**
 * Storage-eviction detector, driving the boot gate:
 *  - "active": a backed-up seed is present — normal boot;
 *  - "wiped":  no usable identity BUT the marker cookie (or a stranded
 *              id/meta half) says one existed here — show the recovery
 *              screen, never silently create a new identity;
 *  - "none":   genuinely fresh browser — onboarding.
 */
export async function wipeCheck(): Promise<IdentityStateKind> {
  if (await getIdentity()) return "active";
  let seed = "";
  try {
    seed = localStorage.getItem(nsKey("id")) ?? "";
  } catch {}
  if (hasMarker() || seed !== "" || readMeta() !== null) return "wiped";
  return "none";
}

/** Explicit user choice on the recovery screen: abandon the lost identity. */
export function abandonLostIdentity(): void {
  try {
    localStorage.removeItem(nsKey("id"));
    localStorage.removeItem(nsKey("id-meta"));
  } catch {}
  try {
    // Mailbox sync residue (mbx-cursor/queue/sent/retry/ident) is keyed by
    // roomId, which cannot be re-derived without the lost seed — sweep every
    // key under the namespaced "mbx-" prefix instead.
    const prefix = nsKey("mbx-");
    for (const key of Object.keys(localStorage)) {
      if (key.startsWith(prefix)) localStorage.removeItem(key);
    }
  } catch {}
  clearMarker();
  // Best-effort IndexedDB cleanup, fire-and-forget (the recovery flow must
  // not block on it): the Helia stores nsDb("blocks")/nsDb("data"), plus the
  // OrbitDB level databases — browser-level prefixes IDB names with
  // "level-js-", and RoomLog uses directory "sueta/<roomId>" for its keystore
  // and log _heads/_index stores (lib/log.ts), so match that prefix via
  // indexedDB.databases() where the browser supports enumeration.
  void (async () => {
    const names = new Set<string>([nsDb("blocks"), nsDb("data")]);
    try {
      for (const db of (await indexedDB.databases?.()) ?? []) {
        if (db.name?.startsWith("level-js-sueta/")) names.add(db.name);
      }
    } catch {}
    for (const name of names) {
      try {
        indexedDB.deleteDatabase(name);
      } catch {}
    }
  })().catch(() => {});
}

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git