@mudlet/mudlet-web 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,19 +3,24 @@ interface CharLoginModalProps {
3
3
  connectionName?: string;
4
4
  /** Error from a previous failed attempt (Char.Login.Result success=false). */
5
5
  error?: string;
6
- /** Account/username to prefill (saved per-profile credential). */
6
+ /** Account/username to prefill. Not a secret — it lives on the connection
7
+ * record, and prefilling it is also what lets a password manager pick the
8
+ * matching password instead of listing every profile's. */
7
9
  initialAccount?: string;
8
- /** Password to prefill (saved per-profile credential — plaintext). */
10
+ /** Password to prefill, from the unlocked vault. */
9
11
  initialPassword?: string;
10
- /** Initial state of the "remember on this device" checkbox. */
11
- initialRemember?: boolean;
12
- /** Show the "remember on this device" checkbox (default true). Branded
13
- * builds hide it — credentials are never persisted there, so `onSubmit`
14
- * always receives `remember=false`. */
15
- allowRemember?: boolean;
12
+ /** Initial state of the "save this password" checkbox. */
13
+ initialSave?: boolean;
14
+ /** Offer to save the password (default true). False where there is nothing
15
+ * to save it into: branded builds, which never persist credentials, and
16
+ * pages served without a secure context. */
17
+ allowSave?: boolean;
18
+ /** Wording under the checkbox — the caller knows whether ticking it will
19
+ * store into an existing vault or set one up first. */
20
+ saveNote?: string;
16
21
  /** Send `account` + `password` to the server (Char.Login.Credentials).
17
- * `remember` tells the caller whether to persist them for next time. */
18
- onSubmit: (account: string, password: string, remember: boolean) => void;
22
+ * `save` asks the caller to put the password in the credential vault. */
23
+ onSubmit: (account: string, password: string, save: boolean) => void;
19
24
  /** Decline GMCP login — sends the empty reply so the server falls back to
20
25
  * its text login prompt. */
21
26
  onCancel: () => void;
@@ -27,10 +32,14 @@ interface CharLoginModalProps {
27
32
  /**
28
33
  * Credentials popup for GMCP `Char.Login` authentication. Rendered as a real
29
34
  * `<form>` with `autocomplete="username"` / `autocomplete="current-password"`
30
- * inputs so browser password managers offer to fill and save. Optionally
31
- * remembers the account + password per profile (plaintext localStorage — see
32
- * the inline warning); the password is otherwise handed straight to `onSubmit`
33
- * and relayed to the server, never stored. Cancelling falls back to text login.
35
+ * inputs so browser password managers can still fill and save for anyone who
36
+ * prefers them.
37
+ *
38
+ * Saving is the credential vault's job (`vault/CredentialVault`): ticking the
39
+ * box hands the password to the caller, which encrypts it under a key derived
40
+ * from a passkey or a passphrase. That replaced an older checkbox that wrote
41
+ * the password to localStorage in the clear and had to apologise for it in a
42
+ * red warning right underneath — see issue #25.
34
43
  */
35
- export declare function CharLoginModal({ connectionName, error, initialAccount, initialPassword, initialRemember, allowRemember, onSubmit, onCancel, restoreFocusTo, }: CharLoginModalProps): import("react/jsx-runtime").JSX.Element;
44
+ export declare function CharLoginModal({ connectionName, error, initialAccount, initialPassword, initialSave, allowSave, saveNote, onSubmit, onCancel, restoreFocusTo, }: CharLoginModalProps): import("react/jsx-runtime").JSX.Element;
36
45
  export {};
@@ -1,4 +1,5 @@
1
1
  import { type MudConnection } from '../storage';
2
+ import type { VaultSaver } from './useVaultSaver';
2
3
  interface Props {
3
4
  /** The connection to edit, or null to add a new one. */
4
5
  connection: MudConnection | null;
@@ -8,6 +9,10 @@ interface Props {
8
9
  onAdd: (data: Omit<MudConnection, 'id'>) => string;
9
10
  onUpdate: (id: string, data: Omit<MudConnection, 'id'>) => void;
10
11
  onClose: () => void;
12
+ /** Where a typed password goes. Owned by the screen above, not by this
13
+ * modal: saving may need a vault setup or unlock step, and this form closes
14
+ * on submit — a prompt rendered from here would unmount with it. */
15
+ vaultSaver: VaultSaver;
11
16
  }
12
- export declare function ConnectionFormModal({ connection, firstConnection, busy, onAdd, onUpdate, onClose }: Props): import("react/jsx-runtime").JSX.Element;
17
+ export declare function ConnectionFormModal({ connection, firstConnection, busy, onAdd, onUpdate, onClose, vaultSaver }: Props): import("react/jsx-runtime").JSX.Element;
13
18
  export {};
@@ -0,0 +1,15 @@
1
+ import type { MudConnection } from '../storage';
2
+ interface VaultManageButtonProps {
3
+ connections: MudConnection[];
4
+ disabled?: boolean;
5
+ }
6
+ /**
7
+ * "Saved logins" on the connection screen's tools row — the one place to lock
8
+ * the vault, change how it opens, drop a stored password or delete the whole
9
+ * thing.
10
+ *
11
+ * Renders nothing when no vault is installed, which is how a branded build gets
12
+ * neither the button nor anything behind it (see `vault/vaultAccess`).
13
+ */
14
+ export declare function VaultManageButton({ connections, disabled }: VaultManageButtonProps): import("react/jsx-runtime").JSX.Element | null;
15
+ export {};
@@ -0,0 +1,2 @@
1
+ import { type VaultUIProps } from '../vault/vaultAccess';
2
+ export declare function VaultModal({ mode, reason, seed, connectionNames, onDone }: VaultUIProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,15 @@
1
+ interface VaultUnlockPromptProps {
2
+ /** Why the app is asking right now — shown above the unlock controls. */
3
+ reason: string;
4
+ onDone: (result: 'created' | 'unlocked' | 'closed') => void;
5
+ }
6
+ /**
7
+ * The unlock step on its own.
8
+ *
9
+ * Kept separate from `useVaultSaver` because unlocking is asked for on the read
10
+ * path — a profile with a saved login is being opened — where there is nothing
11
+ * to save and no vault to create. Renders nothing in a build that installed no
12
+ * vault UI, which is how a branded build gets none of this.
13
+ */
14
+ export declare function VaultUnlockPrompt({ reason, onDone }: VaultUnlockPromptProps): import("react/jsx-runtime").JSX.Element | null;
15
+ export {};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Closing the split view — Mudlet's `TConsole::clearSplit`, which drops the
3
+ * frozen upper pane and puts the console back on the tail. Mudlet reaches it
4
+ * two ways: Ctrl+Return in the command line (`TCommandLine::event`) and a
5
+ * middle click anywhere on the console (`TTextEdit::mousePressEvent`). This
6
+ * module holds the DOM-level predicate so it's unit-testable without React.
7
+ */
8
+ /** True when `e` is the Ctrl/Cmd+Return that closes the split view. Shift and
9
+ * Alt combinations are left alone — the command bar stages a newline on those
10
+ * — as is anything typed into an editor that owns its own Ctrl+Enter: a code
11
+ * editor (contentEditable) or any textarea other than the command line, which
12
+ * is itself a textarea and so cannot be excluded by tag alone. */
13
+ export declare function matchClearSplitKey(e: KeyboardEvent): boolean;
14
+ /** True when `e` is the middle click that closes the split view. */
15
+ export declare function isClearSplitClick(e: {
16
+ button: number;
17
+ }): boolean;
@@ -0,0 +1,26 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * "Save this password" for the two places that offer it — the GMCP login popup
4
+ * and the connection editor.
5
+ *
6
+ * Saving can need a detour: the vault may not exist yet (set one up) or may be
7
+ * locked (unlock it). Rather than making every caller sequence that, `save()`
8
+ * parks the password and returns, the returned `element` renders whichever step
9
+ * is needed, and the write happens once it succeeds.
10
+ *
11
+ * In a build with no vault installed `canSave` is false and `element` is null —
12
+ * callers then don't offer the checkbox at all (see `vault/vaultAccess`).
13
+ */
14
+ export interface VaultSaver {
15
+ /** True when a vault can be written to, now or after a setup/unlock step. */
16
+ canSave: boolean;
17
+ /** Store (or, with `null`, forget) a profile's password, prompting first if
18
+ * the vault needs setting up or unlocking. */
19
+ save: (connectionId: string, password: string | null) => void;
20
+ /** Explains where a ticked checkbox will put the password. */
21
+ note: string;
22
+ /** Render this next to the form — the setup/unlock modal, when one is due.
23
+ * Mount it somewhere that outlives the form: saving usually closes it. */
24
+ element: ReactNode;
25
+ }
26
+ export declare function useVaultSaver(): VaultSaver;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Where auto-login credentials come from, in priority order — one place so the
3
+ * GMCP `Char.Login` path and the text-login state machine can never disagree
4
+ * about which password they are about to send.
5
+ *
6
+ * 1. **In-memory** (`sessionCredentials`) — a branded build's login form. Those
7
+ * builds never persist anything, so this is the only source they have.
8
+ * 2. **The credential vault**, when one is installed and unlocked.
9
+ * 3. **`charLoginPassword` on the connection record** — the pre-vault plaintext
10
+ * field. Never written any more (see issue #25) and no longer offered in the
11
+ * UI, but still read so that an existing saved login keeps working until the
12
+ * user sets a vault up, at which point it is migrated and deleted
13
+ * (`useVaultSaver`).
14
+ *
15
+ * The account name is not a secret and stays on the connection record either
16
+ * way: prefilling it is also what lets a browser password manager pick the one
17
+ * matching password instead of offering every profile's.
18
+ */
19
+ export interface StoredLogin {
20
+ account: string;
21
+ password: string;
22
+ }
23
+ export declare function readStoredLogin(connectionId: string): StoredLogin;
24
+ /**
25
+ * Drop vault entries for profiles that no longer exist.
26
+ *
27
+ * Deleting a profile can't rewrite the vault when it is locked — there is no
28
+ * key to re-encrypt the remaining secrets with — so `forgetConnection` leaves
29
+ * the entry behind and the sweep happens here instead, on the next unlock.
30
+ */
31
+ export declare function pruneVaultEntries(): void;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The credential vault: per-profile login passwords, encrypted at rest under a
3
+ * key the browser cannot produce on its own.
4
+ *
5
+ * Why this exists rather than a "remember my password" checkbox: the app store
6
+ * is plaintext localStorage, so the old checkbox had to ship a warning telling
7
+ * the user that the convenience it had just offered was unsafe (issue #25). And
8
+ * handing the job to the browser's password manager doesn't work either —
9
+ * managers scope credentials by *origin*, so ten profiles on one host produce
10
+ * ten indistinguishable entries in one dropdown, every time, for every game.
11
+ *
12
+ * The vault inverts that. Secrets live here, encrypted; the single thing the
13
+ * password manager is asked to remember is the vault's unlock passphrase — one
14
+ * entry per origin, never ambiguous — or nothing at all when the unlocker is a
15
+ * passkey. Envelope encryption (see `vaultCrypto`) lets a vault carry several
16
+ * unlockers at once, so a passkey can be primary with a passphrase as the
17
+ * recovery path.
18
+ *
19
+ * **Scope of the protection.** Ciphertext at rest defends against everything
20
+ * that reads storage without running in the page: disk images, backups, profile
21
+ * sync, another user on the machine, an extension trawling localStorage. It does
22
+ * not defend against script running in the page *while the vault is unlocked* —
23
+ * no in-page vault can, and neither can a password manager with an open vault.
24
+ * The unlocked state is per page load and lives only in memory.
25
+ *
26
+ * State changes notify `subscribe` listeners so React can render off
27
+ * `getSnapshot` through `useSyncExternalStore`.
28
+ */
29
+ import { type VaultRecord, type VaultUnlocker } from './vaultRecord';
30
+ /** What a listener needs to render. Immutable; identity changes on every
31
+ * mutation so `useSyncExternalStore` re-renders exactly when something did. */
32
+ export interface VaultSnapshot {
33
+ /** A vault has been set up on this device. */
34
+ exists: boolean;
35
+ /** Set up but not opened this page load. `false` when `exists` is false. */
36
+ locked: boolean;
37
+ /** Profiles with a stored password — readable while locked. */
38
+ entryIds: readonly string[];
39
+ /** Unlock methods, without their key material. */
40
+ unlockers: readonly {
41
+ id: string;
42
+ kind: VaultUnlocker['kind'];
43
+ label: string;
44
+ createdAt: string;
45
+ }[];
46
+ }
47
+ /** Failure modes worth telling apart in the UI. */
48
+ export type VaultErrorCode = 'unavailable' | 'no-vault' | 'locked' | 'no-prf' | 'no-passkey' | 'last-unlocker';
49
+ export declare class VaultError extends Error {
50
+ code: VaultErrorCode;
51
+ constructor(code: VaultErrorCode, message: string);
52
+ }
53
+ interface VaultIO {
54
+ load(): VaultRecord | null;
55
+ save(record: VaultRecord | null): void;
56
+ }
57
+ export declare class CredentialVault {
58
+ private io;
59
+ private record;
60
+ /** Recovered at unlock, dropped at lock. Never persisted. */
61
+ private masterKey;
62
+ /** Decrypted secrets while unlocked, keyed by connection id. */
63
+ private secrets;
64
+ private listeners;
65
+ private snapshot;
66
+ constructor(io?: VaultIO);
67
+ subscribe: (listener: () => void) => (() => void);
68
+ getSnapshot: () => VaultSnapshot;
69
+ /** Whether this browser can do vault crypto at all (secure context). */
70
+ get available(): boolean;
71
+ /** Whether a passkey unlocker can even be offered here. */
72
+ get passkeysSupported(): boolean;
73
+ get exists(): boolean;
74
+ get locked(): boolean;
75
+ /** True when this profile has a stored password — answerable while locked,
76
+ * which is what lets the app decide whether an unlock is worth asking for. */
77
+ hasEntry(connectionId: string): boolean;
78
+ /** The stored password, or `undefined` when there is none *or* the vault is
79
+ * locked. Callers treat both the same way: fall back to asking the user. */
80
+ getPassword(connectionId: string): string | undefined;
81
+ /**
82
+ * Create a vault whose first unlocker is a passphrase.
83
+ *
84
+ * `seed` migrates any passwords that were already sitting in plaintext on
85
+ * the connection records — the caller strips them from the store once this
86
+ * resolves, so the plaintext copy does not outlive its replacement.
87
+ */
88
+ createWithPassphrase(passphrase: string, seed?: Record<string, string>): Promise<void>;
89
+ /**
90
+ * Create a vault whose first unlocker is a passkey. Two authenticator
91
+ * prompts: one to register the credential, one to evaluate PRF for its KEK
92
+ * (Chrome does not return PRF output at registration).
93
+ *
94
+ * Throws `no-prf` when the authenticator cannot do PRF — the caller falls
95
+ * back to `createWithPassphrase`.
96
+ */
97
+ createWithPasskey(appName: string, label: string, seed?: Record<string, string>): Promise<void>;
98
+ /** Open the vault with a passphrase. `false` = wrong passphrase (the only
99
+ * outcome a user can act on); anything else throws. */
100
+ unlockWithPassphrase(passphrase: string): Promise<boolean>;
101
+ /** Open the vault with a registered passkey. Throws on cancel (WebAuthn's
102
+ * own error) or when no passkey unlocker exists. */
103
+ unlockWithPasskey(): Promise<boolean>;
104
+ /** Drop the master key and every decrypted secret. The record stays. */
105
+ lock(): void;
106
+ /** Remove the vault and everything in it, irreversibly. */
107
+ deleteVault(): void;
108
+ /** Store (or, with `null`, forget) one profile's password. Requires unlocked. */
109
+ setPassword(connectionId: string, password: string | null): Promise<void>;
110
+ /**
111
+ * Forget every trace of a profile — called when the profile is deleted.
112
+ *
113
+ * A locked vault cannot rewrite its ciphertext, so the entry is left in
114
+ * place and swept on the next unlock by {@link pruneMissing}. Returning
115
+ * `false` says so; nothing leaks either way, since a deleted profile's
116
+ * password is unreachable.
117
+ */
118
+ forgetConnection(connectionId: string): Promise<boolean>;
119
+ /** Drop stored passwords for profiles that no longer exist. No-op while
120
+ * locked; the caller runs it right after an unlock. */
121
+ pruneMissing(existingConnectionIds: Iterable<string>): Promise<void>;
122
+ /** Add a passphrase to an already-open vault (recovery path for a
123
+ * passkey-first vault, or a new phrase alongside the old one). */
124
+ addPassphrase(passphrase: string, label?: string): Promise<void>;
125
+ /** Add a passkey to an already-open vault. */
126
+ addPasskey(appName: string, label: string): Promise<void>;
127
+ /** Remove an unlock method. Refuses to remove the last one — that would
128
+ * leave a vault nobody can open, which is data loss wearing a button. */
129
+ removeUnlocker(id: string): void;
130
+ /** Replace every passphrase unlocker with one derived from `next`. The
131
+ * vault must be open, so this is "change", not "reset" — a forgotten
132
+ * passphrase is recovered with a passkey or not at all. */
133
+ changePassphrase(next: string, label?: string): Promise<void>;
134
+ private requireAvailable;
135
+ private requireRecord;
136
+ private requireMaster;
137
+ private requireUnlocked;
138
+ private buildPassphraseUnlocker;
139
+ private buildPasskeyUnlocker;
140
+ private installNewVault;
141
+ private adoptMasterKey;
142
+ private persistSecrets;
143
+ private writeRecord;
144
+ private buildSnapshot;
145
+ private emit;
146
+ }
147
+ /** The app's vault. One per page — the unlocked state is deliberately shared
148
+ * across profiles, so opening a second game after unlocking the first asks for
149
+ * nothing. */
150
+ export declare const credentialVault: CredentialVault;
151
+ export {};
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Passkey-backed vault unlocking, via the WebAuthn **PRF extension**.
3
+ *
4
+ * PRF turns an authenticator into a keyed pseudo-random function: given a salt,
5
+ * the credential returns the same 32 secret bytes every time, and those bytes
6
+ * never leave the authenticator's control without a user-verification gesture
7
+ * (Touch ID, Windows Hello, a security key's PIN). We use them as the KEK for
8
+ * one vault unlocker — so "unlock" is a fingerprint rather than a typed secret,
9
+ * and the browser's password manager holds a single passkey for the app instead
10
+ * of one saved login per game.
11
+ *
12
+ * There is no server here. The challenges are client-side randomness and the
13
+ * signature is never verified: we are deriving a key, not authenticating. That
14
+ * is why registration invents a local user handle.
15
+ *
16
+ * Support is feature-detected at registration: `create()` reports `prf.enabled`,
17
+ * and an authenticator that says no cannot back a vault, so the caller falls
18
+ * back to a passphrase.
19
+ */
20
+ /** Registered credential, as persisted in the vault record. */
21
+ export interface PasskeyRegistration {
22
+ /** Raw credential id, base64 — replayed in `allowCredentials` at unlock. */
23
+ credentialId: string;
24
+ }
25
+ /** True when the browser exposes WebAuthn at all. PRF support is only knowable
26
+ * after a registration attempt, so this is a pre-filter, not a guarantee. */
27
+ export declare function passkeysAvailable(): boolean;
28
+ /** True when this device has a built-in authenticator (Touch ID, Hello, Android
29
+ * screen lock) — used only to word the offer, never to gate it: security keys
30
+ * do PRF too. */
31
+ export declare function platformAuthenticatorAvailable(): Promise<boolean>;
32
+ /**
33
+ * Register a new passkey for the vault and check that it can do PRF.
34
+ *
35
+ * Returns `null` when the credential was created but the authenticator refused
36
+ * PRF — the caller must then fall back to a passphrase. Throws when the user
37
+ * cancels or registration fails outright.
38
+ */
39
+ export declare function registerVaultPasskey(appName: string, label: string): Promise<PasskeyRegistration | null>;
40
+ /**
41
+ * Evaluate the vault's PRF salt against whichever registered passkey the user
42
+ * presents, and return the 32 secret bytes it answers with.
43
+ *
44
+ * All known credentials go into one `allowCredentials` list so a vault holding
45
+ * a laptop's Touch ID *and* a phone opens from either in a single prompt. That
46
+ * works because the salt belongs to the vault, not to a credential: one
47
+ * assertion cannot evaluate a different salt per credential, and the KEKs stay
48
+ * distinct regardless, since PRF output is keyed by the credential's own secret.
49
+ *
50
+ * Returns `null` when the authenticator answered without a PRF result. A cancel
51
+ * or timeout throws, as WebAuthn does.
52
+ */
53
+ export declare function evaluateVaultPrf(registrations: PasskeyRegistration[], prfSalt: Uint8Array): Promise<{
54
+ credentialId: string;
55
+ secret: Uint8Array;
56
+ } | null>;
@@ -0,0 +1,8 @@
1
+ import type { VaultSnapshot } from './CredentialVault';
2
+ /**
3
+ * Render off the credential vault's state. Returns the "no vault" snapshot in
4
+ * builds that never installed one (see `vaultAccess`), so every consumer can
5
+ * call this unconditionally and simply render nothing when `exists` is false
6
+ * and the vault cannot be created.
7
+ */
8
+ export declare function useVault(): VaultSnapshot;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The seam between the app and the credential vault.
3
+ *
4
+ * The vault is a **stock-app feature only**. A branded build ships for one game,
5
+ * so the problem it solves — ten profiles' logins piling up indistinguishably in
6
+ * one origin's password-manager dropdown — does not exist there, and branded
7
+ * builds keep credentials in memory for the page (`utils/sessionCredentials`)
8
+ * rather than storing them at all.
9
+ *
10
+ * So the library entry (`src/index.ts` → `MudletWebApp`) must not reach the
11
+ * vault, and this module is how: it is the only vault file the shared UI
12
+ * imports, it names the vault and its modal through `import type` alone, and
13
+ * `main.tsx` — which the standalone app loads and the library does not — is what
14
+ * puts the real ones in. Nothing here pulls in crypto, WebAuthn or the modal, so
15
+ * a consumer build contains none of it; every `getVault()` there is `null` and
16
+ * the callers render nothing.
17
+ */
18
+ import type { ComponentType } from 'react';
19
+ import type { CredentialVault, VaultSnapshot } from './CredentialVault';
20
+ /** Props of the vault's modal. Declared here, not in the component, so this
21
+ * module can describe the UI it hands out without importing it. */
22
+ export interface VaultUIProps {
23
+ /** Which step to show: create a vault, open one, or manage one. */
24
+ mode: 'setup' | 'unlock' | 'manage';
25
+ /** Why an unlock is being asked for — e.g. the profile being opened. */
26
+ reason?: string;
27
+ /** Plaintext passwords to fold into a *new* vault, keyed by connection id. */
28
+ seed?: Record<string, string>;
29
+ /** Profile id → display name, for the saved-logins list. */
30
+ connectionNames?: Record<string, string>;
31
+ onDone: (result: 'created' | 'unlocked' | 'closed') => void;
32
+ }
33
+ export type VaultUI = ComponentType<VaultUIProps>;
34
+ /** What a consumer without a vault sees. Frozen and shared: `useSyncExternalStore`
35
+ * needs a stable identity from a snapshot that has not changed. */
36
+ export declare const NO_VAULT: VaultSnapshot;
37
+ /**
38
+ * Make a vault (and the UI that drives it) available to the app. Called once
39
+ * from `main.tsx`, before the first render, so credential reads on a cold
40
+ * auto-connect never race the install.
41
+ */
42
+ export declare function installVault(vault: CredentialVault, ui: VaultUI): void;
43
+ /** Drop the installed vault. Tests only — the app installs once per page. */
44
+ export declare function uninstallVault(): void;
45
+ /** The app's vault, or `null` in a build that never installed one. */
46
+ export declare function getVault(): CredentialVault | null;
47
+ /** The vault's modal, or `null` — render it only when this is non-null. */
48
+ export declare function getVaultUI(): VaultUI | null;
49
+ export declare function subscribeVault(listener: () => void): () => void;
50
+ export declare function vaultSnapshot(): VaultSnapshot;
51
+ /** A stored password, or `undefined` when there is no vault, no entry, or the
52
+ * vault is locked. All three mean the same thing to a caller: ask the user. */
53
+ export declare function vaultPassword(connectionId: string): string | undefined;
54
+ /** Whether this profile has a stored password — true even while locked, which
55
+ * is what tells the app an unlock is worth offering. */
56
+ export declare function vaultHasEntry(connectionId: string): boolean;
57
+ /** Whether opening this profile should ask for an unlock first. */
58
+ export declare function vaultNeedsUnlock(connectionId: string): boolean;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * WebCrypto primitives for the credential vault.
3
+ *
4
+ * Envelope encryption: one random 256-bit AES-GCM **master key** encrypts the
5
+ * secrets blob, and every *unlocker* (a passphrase, a passkey) wraps a copy of
6
+ * that master key with a key-encryption key it derives on its own. Adding or
7
+ * removing an unlock method therefore rewraps one small blob instead of
8
+ * re-encrypting the secrets, and two methods can coexist without either being
9
+ * able to read the other's derivation input.
10
+ *
11
+ * Everything here is pure WebCrypto over plain values — no storage, no DOM —
12
+ * so the format can be tested end-to-end without IndexedDB or a browser.
13
+ */
14
+ /** AES-GCM ciphertext plus its nonce, both base64 for JSON storage. */
15
+ export interface Ciphertext {
16
+ /** 12-byte GCM nonce, freshly random for every encryption. */
17
+ iv: string;
18
+ /** Ciphertext with the 16-byte GCM tag appended (WebCrypto's layout). */
19
+ ct: string;
20
+ }
21
+ /**
22
+ * PBKDF2 work factor for passphrase unlockers. OWASP's 2023 floor for
23
+ * PBKDF2-HMAC-SHA256; ~0.3s on a mid-range laptop, which is the right trade for
24
+ * something typed once per page rather than once per keystroke. Stored *in* the
25
+ * record so the number can be raised later without stranding existing vaults.
26
+ */
27
+ export declare const PBKDF2_ITERATIONS = 600000;
28
+ /** True when this context can do vault crypto at all. */
29
+ export declare function cryptoAvailable(): boolean;
30
+ export declare function randomBytes(length: number): Uint8Array;
31
+ export declare function toBase64(bytes: ArrayBuffer | Uint8Array): string;
32
+ export declare function fromBase64(b64: string): Uint8Array;
33
+ /** A fresh master key. Extractable — it has to be, to be wrapped by unlockers. */
34
+ export declare function generateMasterKey(): Promise<CryptoKey>;
35
+ /**
36
+ * Stretch a passphrase into a key-encryption key. The KEK never encrypts
37
+ * secrets directly — it only wraps the master key — so changing the passphrase
38
+ * costs one rewrap.
39
+ */
40
+ export declare function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations?: number): Promise<CryptoKey>;
41
+ /**
42
+ * Stretch a WebAuthn PRF result into a key-encryption key. The PRF output is
43
+ * already 32 uniformly-random bytes bound to the authenticator, so this is a
44
+ * domain-separating HKDF rather than a cost function — there is nothing to
45
+ * brute-force without the authenticator.
46
+ */
47
+ export declare function deriveKeyFromPrf(prfOutput: ArrayBuffer | Uint8Array): Promise<CryptoKey>;
48
+ export declare function encryptBytes(key: CryptoKey, plaintext: Uint8Array): Promise<Ciphertext>;
49
+ /** Decrypt, or throw — GCM authentication failure is how a wrong passphrase is detected. */
50
+ export declare function decryptBytes(key: CryptoKey, box: Ciphertext): Promise<Uint8Array>;
51
+ export declare function encryptJson(key: CryptoKey, value: unknown): Promise<Ciphertext>;
52
+ export declare function decryptJson<T>(key: CryptoKey, box: Ciphertext): Promise<T>;
53
+ /** Wrap the master key for one unlocker. */
54
+ export declare function wrapMasterKey(kek: CryptoKey, master: CryptoKey): Promise<Ciphertext>;
55
+ /** Recover the master key from an unlocker's wrapped copy. Throws on a bad KEK. */
56
+ export declare function unwrapMasterKey(kek: CryptoKey, wrapped: Ciphertext): Promise<CryptoKey>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * On-disk shape of the credential vault, and its (synchronous) persistence.
3
+ *
4
+ * The record lives in `localStorage` rather than IndexedDB on purpose: it is
5
+ * small, and everything sensitive in it is already AES-GCM ciphertext, so the
6
+ * weakness that made plaintext `charLoginPassword` a bad idea — localStorage is
7
+ * readable by anything running on the page — costs nothing here. In exchange
8
+ * the vault's *shape* (does one exist, which profiles have a saved password) is
9
+ * readable synchronously during the first render, with no async init race and
10
+ * no loading state in every consumer.
11
+ *
12
+ * It is deliberately NOT part of the Zustand store: `mudix_v1` is exported,
13
+ * imported and reset by profile tooling, and vault material must not travel
14
+ * with a profile export.
15
+ */
16
+ import type { Ciphertext } from './vaultCrypto';
17
+ export declare const VAULT_STORAGE_KEY = "mudix_vault_v1";
18
+ interface UnlockerBase {
19
+ /** Stable local id, so the manage UI can remove one unambiguously. */
20
+ id: string;
21
+ /** User-facing name ("Passphrase", "MacBook Touch ID"). */
22
+ label: string;
23
+ createdAt: string;
24
+ /** The vault's master key, wrapped by the KEK this unlocker derives. */
25
+ wrapped: Ciphertext;
26
+ }
27
+ export interface PassphraseUnlocker extends UnlockerBase {
28
+ kind: 'passphrase';
29
+ /** PBKDF2 salt, base64. */
30
+ salt: string;
31
+ /** PBKDF2 iteration count *as used*, so the cost can be raised for new
32
+ * vaults without stranding existing ones. */
33
+ iterations: number;
34
+ }
35
+ export interface PasskeyUnlocker extends UnlockerBase {
36
+ kind: 'passkey';
37
+ /** WebAuthn credential id, base64. */
38
+ credentialId: string;
39
+ }
40
+ export type VaultUnlocker = PassphraseUnlocker | PasskeyUnlocker;
41
+ export interface VaultRecord {
42
+ version: 1;
43
+ /** 32-byte PRF salt shared by every passkey unlocker, base64. See
44
+ * `evaluateVaultPrf` for why it is per-vault and not per-credential. */
45
+ prfSalt: string;
46
+ unlockers: VaultUnlocker[];
47
+ /** Connection ids that have a stored password. Plaintext by design — it is
48
+ * what lets a *locked* vault answer "does this profile have a saved login?"
49
+ * so the app knows whether to offer an unlock at all. */
50
+ entryIds: string[];
51
+ /** AES-GCM under the master key over `Record<connectionId, password>`.
52
+ * `null` before the first secret is stored. */
53
+ payload: Ciphertext | null;
54
+ }
55
+ /** Parse defensively — a corrupt record must degrade to "no vault", never to a
56
+ * crash on boot that locks the user out of the app as well as the vault. */
57
+ export declare function parseVaultRecord(raw: string | null): VaultRecord | null;
58
+ export declare function loadVaultRecord(): VaultRecord | null;
59
+ export declare function saveVaultRecord(record: VaultRecord | null): void;
60
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mudlet/mudlet-web",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Mudlet Web — Mudlet in the browser, usable standalone or as a library for branded builds",
6
6
  "license": "GPL-2.0-or-later",