@noy-db/on-pin 0.1.0-pre.3
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.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/index.cjs +217 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +161 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vLannaAi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @noy-db/on-pin
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@noy-db/on-pin)
|
|
4
|
+
|
|
5
|
+
> Session-resume PIN quick-lock for noy-db
|
|
6
|
+
|
|
7
|
+
Part of [**`@noy-db/hub`**](https://www.npmjs.com/package/@noy-db/hub) — the zero-knowledge, offline-first, encrypted document store.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @noy-db/hub @noy-db/on-pin
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What it is
|
|
16
|
+
|
|
17
|
+
Session-resume PIN quick-lock for noy-db — after a full passphrase unlock, a short-lived PIN (or a per-device biometric) re-unlocks the cached DEKs without re-typing the passphrase. PIN never replaces the passphrase; only resumes an already-unlocked session.
|
|
18
|
+
|
|
19
|
+
## Status
|
|
20
|
+
|
|
21
|
+
**Pre-release** (`0.1.0-pre.1`). API may change before `1.0`.
|
|
22
|
+
|
|
23
|
+
## Documentation
|
|
24
|
+
|
|
25
|
+
See the [main repository](https://github.com/vLannaAi/noy-db#readme) for setup, examples, and the full subsystem catalog.
|
|
26
|
+
|
|
27
|
+
- Source — [`packages/on-pin`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-pin)
|
|
28
|
+
- Issues — [github.com/vLannaAi/noy-db/issues](https://github.com/vLannaAi/noy-db/issues)
|
|
29
|
+
- Spec — [`SPEC.md`](https://github.com/vLannaAi/noy-db/blob/main/SPEC.md)
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
[MIT](./LICENSE) © vLannaAi
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
PIN_DEFAULT_MAX_ATTEMPTS: () => PIN_DEFAULT_MAX_ATTEMPTS,
|
|
24
|
+
PIN_DEFAULT_TTL_MS: () => PIN_DEFAULT_TTL_MS,
|
|
25
|
+
PIN_PBKDF2_ITERATIONS: () => PIN_PBKDF2_ITERATIONS,
|
|
26
|
+
PinAttemptsExceededError: () => PinAttemptsExceededError,
|
|
27
|
+
PinEnrollmentError: () => PinEnrollmentError,
|
|
28
|
+
PinExpiredError: () => PinExpiredError,
|
|
29
|
+
PinInvalidError: () => PinInvalidError,
|
|
30
|
+
clearPinState: () => clearPinState,
|
|
31
|
+
enrollPin: () => enrollPin,
|
|
32
|
+
isPinStateValid: () => isPinStateValid,
|
|
33
|
+
resumePin: () => resumePin
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(index_exports);
|
|
36
|
+
var PIN_DEFAULT_TTL_MS = 15 * 60 * 1e3;
|
|
37
|
+
var PIN_DEFAULT_MAX_ATTEMPTS = 5;
|
|
38
|
+
var PIN_PBKDF2_ITERATIONS = 1e5;
|
|
39
|
+
var PinInvalidError = class extends Error {
|
|
40
|
+
code = "PIN_INVALID";
|
|
41
|
+
constructor(message = "PIN is incorrect.") {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "PinInvalidError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var PinExpiredError = class extends Error {
|
|
47
|
+
code = "PIN_EXPIRED";
|
|
48
|
+
constructor(message = "PIN resume window has expired; re-enter full passphrase.") {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "PinExpiredError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var PinAttemptsExceededError = class extends Error {
|
|
54
|
+
code = "PIN_ATTEMPTS_EXCEEDED";
|
|
55
|
+
constructor(message = "Too many wrong PIN attempts; re-enter full passphrase.") {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "PinAttemptsExceededError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var PinEnrollmentError = class extends Error {
|
|
61
|
+
code = "PIN_ENROLLMENT_FAILED";
|
|
62
|
+
constructor(message = "PIN enrolment failed.") {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "PinEnrollmentError";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
async function enrollPin(keyring, options) {
|
|
68
|
+
const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS;
|
|
69
|
+
const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS;
|
|
70
|
+
const salt = crypto.getRandomValues(new Uint8Array(32));
|
|
71
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
72
|
+
const wrappingKey = await deriveWrappingKey(options.pin, salt);
|
|
73
|
+
let serialized;
|
|
74
|
+
try {
|
|
75
|
+
serialized = await serializeKeyring(keyring);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new PinEnrollmentError(
|
|
78
|
+
`Failed to serialize keyring \u2014 DEK not extractable. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
82
|
+
{ name: "AES-GCM", iv },
|
|
83
|
+
wrappingKey,
|
|
84
|
+
serialized
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
_noydb_on_pin: 1,
|
|
88
|
+
salt: bytesToBase64(salt),
|
|
89
|
+
iv: bytesToBase64(iv),
|
|
90
|
+
wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),
|
|
91
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
92
|
+
attempts: 0,
|
|
93
|
+
maxAttempts
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function resumePin(state, options) {
|
|
97
|
+
if (Date.now() > new Date(state.expiresAt).getTime()) {
|
|
98
|
+
throw new PinExpiredError();
|
|
99
|
+
}
|
|
100
|
+
if (state.attempts >= state.maxAttempts) {
|
|
101
|
+
throw new PinAttemptsExceededError();
|
|
102
|
+
}
|
|
103
|
+
const salt = base64ToBytes(state.salt);
|
|
104
|
+
const iv = base64ToBytes(state.iv);
|
|
105
|
+
const ciphertext = base64ToBytes(state.wrappedKeyring);
|
|
106
|
+
const wrappingKey = await deriveWrappingKey(options.pin, salt);
|
|
107
|
+
let plaintext;
|
|
108
|
+
try {
|
|
109
|
+
plaintext = await crypto.subtle.decrypt(
|
|
110
|
+
{ name: "AES-GCM", iv },
|
|
111
|
+
wrappingKey,
|
|
112
|
+
ciphertext
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
state.attempts = state.attempts + 1;
|
|
116
|
+
throw new PinInvalidError();
|
|
117
|
+
}
|
|
118
|
+
return deserializeKeyring(new Uint8Array(plaintext));
|
|
119
|
+
}
|
|
120
|
+
function isPinStateValid(state) {
|
|
121
|
+
return Date.now() <= new Date(state.expiresAt).getTime() && state.attempts < state.maxAttempts;
|
|
122
|
+
}
|
|
123
|
+
function clearPinState(state) {
|
|
124
|
+
;
|
|
125
|
+
state.attempts = state.maxAttempts;
|
|
126
|
+
state.expiresAt = (/* @__PURE__ */ new Date(0)).toISOString();
|
|
127
|
+
state.wrappedKeyring = "";
|
|
128
|
+
}
|
|
129
|
+
async function deriveWrappingKey(pin, salt) {
|
|
130
|
+
const ikm = await crypto.subtle.importKey(
|
|
131
|
+
"raw",
|
|
132
|
+
new TextEncoder().encode(pin),
|
|
133
|
+
"PBKDF2",
|
|
134
|
+
false,
|
|
135
|
+
["deriveKey"]
|
|
136
|
+
);
|
|
137
|
+
return crypto.subtle.deriveKey(
|
|
138
|
+
{
|
|
139
|
+
name: "PBKDF2",
|
|
140
|
+
salt,
|
|
141
|
+
iterations: PIN_PBKDF2_ITERATIONS,
|
|
142
|
+
hash: "SHA-256"
|
|
143
|
+
},
|
|
144
|
+
ikm,
|
|
145
|
+
{ name: "AES-GCM", length: 256 },
|
|
146
|
+
false,
|
|
147
|
+
["encrypt", "decrypt"]
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
async function serializeKeyring(k) {
|
|
151
|
+
const deks = {};
|
|
152
|
+
for (const [collection, key] of k.deks) {
|
|
153
|
+
const raw = await crypto.subtle.exportKey("raw", key);
|
|
154
|
+
deks[collection] = bytesToBase64(new Uint8Array(raw));
|
|
155
|
+
}
|
|
156
|
+
const json = {
|
|
157
|
+
userId: k.userId,
|
|
158
|
+
displayName: k.displayName,
|
|
159
|
+
role: k.role,
|
|
160
|
+
permissions: k.permissions,
|
|
161
|
+
salt: bytesToBase64(k.salt),
|
|
162
|
+
deks
|
|
163
|
+
};
|
|
164
|
+
return new TextEncoder().encode(JSON.stringify(json));
|
|
165
|
+
}
|
|
166
|
+
async function deserializeKeyring(bytes) {
|
|
167
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
168
|
+
const deks = /* @__PURE__ */ new Map();
|
|
169
|
+
for (const [coll, b64] of Object.entries(parsed.deks)) {
|
|
170
|
+
const raw = base64ToBytes(b64);
|
|
171
|
+
const key = await crypto.subtle.importKey(
|
|
172
|
+
"raw",
|
|
173
|
+
raw,
|
|
174
|
+
{ name: "AES-GCM" },
|
|
175
|
+
true,
|
|
176
|
+
["encrypt", "decrypt"]
|
|
177
|
+
);
|
|
178
|
+
deks.set(coll, key);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
userId: parsed.userId,
|
|
182
|
+
displayName: parsed.displayName,
|
|
183
|
+
role: parsed.role,
|
|
184
|
+
permissions: parsed.permissions,
|
|
185
|
+
salt: base64ToBytes(parsed.salt),
|
|
186
|
+
deks,
|
|
187
|
+
// KEK is deliberately null — PIN-resume returns a keyring that can
|
|
188
|
+
// read/write but cannot open additional vaults or rotate keys.
|
|
189
|
+
kek: null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function bytesToBase64(bytes) {
|
|
193
|
+
let s = "";
|
|
194
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
195
|
+
return btoa(s);
|
|
196
|
+
}
|
|
197
|
+
function base64ToBytes(b64) {
|
|
198
|
+
const s = atob(b64);
|
|
199
|
+
const out = new Uint8Array(s.length);
|
|
200
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
204
|
+
0 && (module.exports = {
|
|
205
|
+
PIN_DEFAULT_MAX_ATTEMPTS,
|
|
206
|
+
PIN_DEFAULT_TTL_MS,
|
|
207
|
+
PIN_PBKDF2_ITERATIONS,
|
|
208
|
+
PinAttemptsExceededError,
|
|
209
|
+
PinEnrollmentError,
|
|
210
|
+
PinExpiredError,
|
|
211
|
+
PinInvalidError,
|
|
212
|
+
clearPinState,
|
|
213
|
+
enrollPin,
|
|
214
|
+
isPinStateValid,
|
|
215
|
+
resumePin
|
|
216
|
+
});
|
|
217
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.\n *\n * The use case: after the user unlocks a vault with the full passphrase,\n * the session goes idle (screen lock, tab switch). Instead of re-entering\n * the full passphrase, the user types a 4–6 digit PIN (or taps their\n * device biometric) to **resume the already-open session**.\n *\n * ## What this is NOT\n *\n * This is **NOT** a passphrase replacement. If the vault is cold-started\n * (fresh app launch, no prior unlock), a PIN alone cannot open it — the\n * KEK must be re-derived from the real passphrase via PBKDF2-600K.\n *\n * ## Security model\n *\n * 1. **PIN never derives the KEK.** The PIN derives a transient wrapping\n * key via PBKDF2 (100k iterations, not 600k — the protection window is\n * short, so fewer iterations are acceptable).\n * 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries\n * the encrypted DEK map but NOT the KEK. Even if the PIN is\n * compromised, an attacker cannot re-derive the KEK or unwrap a cold\n * keyring — they can only re-open THIS session's cached DEKs.\n * 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After\n * expiry, `resumePin()` throws; the user must re-enter the full\n * passphrase.\n * 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state\n * refuses further attempts until re-enrolment.\n * 5. **Memory-scoped by convention.** The caller is responsible for\n * storing the `PinResumeState` appropriately — ideally in memory\n * (lost when the process exits). Writing it to `localStorage` is\n * allowed but defeats the short-lived-session property, so it is\n * flagged here as a design decision the caller owns.\n *\n * ## Limits (read before shipping)\n *\n * - The `attempts` counter lives inside the `PinResumeState` object.\n * An attacker with a stale copy of the state can \"reset\" attempts\n * by reverting their copy. Real lockout enforcement needs a trusted\n * counter (server-side or OS secure enclave). Document this to\n * consumers.\n * - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the\n * state blob. Do not persist the state to a public location.\n * - A 4-digit numeric PIN has only 10,000 possibilities. With 100k\n * PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to\n * exhaust the space — roughly hours. Combined with the short TTL\n * and the attempts counter, this is acceptable for UX convenience\n * but NOT for primary authentication.\n *\n * ## API shape (mirrors @noy-db/on-* siblings)\n *\n * ```ts\n * import { enrollPin, resumePin } from '@noy-db/on-pin'\n *\n * // After the user has opened the vault with the full passphrase:\n * const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })\n * // Keep `state` in memory. Do not write it anywhere durable.\n *\n * // Later, when session resumes:\n * const keyring = await resumePin(state, { pin: '1234' })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { Role, Permissions, UnlockedKeyring } from '@noy-db/hub'\n\n// ─── Constants ──────────────────────────────────────────────────────────\n\n/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */\nexport const PIN_DEFAULT_TTL_MS = 15 * 60 * 1000\n\n/** Default max attempts before state refuses further unlock. */\nexport const PIN_DEFAULT_MAX_ATTEMPTS = 5\n\n/**\n * PBKDF2 iteration count for the PIN. Lower than the 600k used for\n * passphrase KEK derivation because (a) the window is short, (b) the\n * attempt counter bounds online attacks, (c) the state is not\n * persisted in a public location. Do not lower this further without\n * also raising attempt-counter rigour.\n */\nexport const PIN_PBKDF2_ITERATIONS = 100_000\n\n// ─── Errors ─────────────────────────────────────────────────────────────\n\nexport class PinInvalidError extends Error {\n readonly code = 'PIN_INVALID' as const\n constructor(message = 'PIN is incorrect.') {\n super(message)\n this.name = 'PinInvalidError'\n }\n}\n\nexport class PinExpiredError extends Error {\n readonly code = 'PIN_EXPIRED' as const\n constructor(message = 'PIN resume window has expired; re-enter full passphrase.') {\n super(message)\n this.name = 'PinExpiredError'\n }\n}\n\nexport class PinAttemptsExceededError extends Error {\n readonly code = 'PIN_ATTEMPTS_EXCEEDED' as const\n constructor(message = 'Too many wrong PIN attempts; re-enter full passphrase.') {\n super(message)\n this.name = 'PinAttemptsExceededError'\n }\n}\n\nexport class PinEnrollmentError extends Error {\n readonly code = 'PIN_ENROLLMENT_FAILED' as const\n constructor(message = 'PIN enrolment failed.') {\n super(message)\n this.name = 'PinEnrollmentError'\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Opaque serializable state produced by `enrollPin()`. Hand it to\n * `resumePin()` to unlock. Callers keep this in memory (not on disk /\n * sessionStorage in general) per the security model above.\n *\n * `attempts` is the only mutable field; incremented on wrong-PIN\n * failures. Callers should treat the rest as immutable.\n */\nexport interface PinResumeState {\n /** Schema marker. */\n readonly _noydb_on_pin: 1\n /** Base64 PBKDF2 salt (32 random bytes). */\n readonly salt: string\n /** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */\n readonly wrappedKeyring: string\n /** ISO-8601 timestamp after which `resumePin()` refuses. */\n readonly expiresAt: string\n /** Mutable counter — incremented on each wrong-PIN attempt. */\n attempts: number\n /** Upper bound; when `attempts >= maxAttempts`, resume throws. */\n readonly maxAttempts: number\n}\n\nexport interface EnrollPinOptions {\n /** The short secret. Typically 4–6 digits, but any string works. */\n readonly pin: string\n /** Resume window length. Default: 15 minutes. */\n readonly ttlMs?: number\n /** Max wrong-PIN attempts before the state is dead. Default: 5. */\n readonly maxAttempts?: number\n}\n\nexport interface ResumePinOptions {\n readonly pin: string\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────\n\n/**\n * Enrol a PIN for session-resume against an already-unlocked keyring.\n *\n * Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`\n * must succeed). The hub creates DEKs with `extractable: true` by default.\n *\n * @throws `PinEnrollmentError` if any DEK is non-extractable.\n */\nexport async function enrollPin(\n keyring: UnlockedKeyring,\n options: EnrollPinOptions,\n): Promise<PinResumeState> {\n const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS\n const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS\n\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const iv = crypto.getRandomValues(new Uint8Array(12))\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let serialized: Uint8Array\n try {\n serialized = await serializeKeyring(keyring)\n } catch (err) {\n throw new PinEnrollmentError(\n 'Failed to serialize keyring — DEK not extractable. ' +\n `Underlying error: ${err instanceof Error ? err.message : String(err)}`,\n )\n }\n\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n serialized as BufferSource,\n )\n\n return {\n _noydb_on_pin: 1,\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n attempts: 0,\n maxAttempts,\n }\n}\n\n/**\n * Resume a session from a previously-enrolled `PinResumeState`.\n *\n * The returned keyring has `kek: null` — PIN resume does NOT reconstruct\n * the KEK (by design). The DEKs are sufficient for normal reads and\n * writes; operations that require a KEK (opening additional vaults,\n * re-enrolling, key rotation) still need the full passphrase flow.\n *\n * @throws `PinExpiredError` if the resume window has elapsed.\n * @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.\n * @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).\n */\nexport async function resumePin(\n state: PinResumeState,\n options: ResumePinOptions,\n): Promise<UnlockedKeyring> {\n if (Date.now() > new Date(state.expiresAt).getTime()) {\n throw new PinExpiredError()\n }\n if (state.attempts >= state.maxAttempts) {\n throw new PinAttemptsExceededError()\n }\n\n const salt = base64ToBytes(state.salt)\n const iv = base64ToBytes(state.iv)\n const ciphertext = base64ToBytes(state.wrappedKeyring)\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n ciphertext as BufferSource,\n )\n } catch {\n // AES-GCM auth failure. Increment the attempts counter before\n // throwing so repeated wrong PINs progressively lock the state.\n state.attempts = state.attempts + 1\n throw new PinInvalidError()\n }\n\n return deserializeKeyring(new Uint8Array(plaintext))\n}\n\n/** Fast TTL check without attempting decrypt. */\nexport function isPinStateValid(state: PinResumeState): boolean {\n return (\n Date.now() <= new Date(state.expiresAt).getTime() &&\n state.attempts < state.maxAttempts\n )\n}\n\n/**\n * Zero the state in place. After this, `resumePin()` will fail.\n * Use on explicit logout.\n */\nexport function clearPinState(state: PinResumeState): void {\n // Overwrite the attempts counter past the max + expire the state.\n ;(state as { attempts: number }).attempts = state.maxAttempts\n ;(state as { expiresAt: string }).expiresAt = new Date(0).toISOString()\n ;(state as { wrappedKeyring: string }).wrappedKeyring = ''\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(\n pin: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(pin),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PIN_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\ninterface SerializedKeyring {\n userId: string\n displayName: string\n role: Role\n permissions: Permissions\n salt: string\n deks: Record<string, string>\n}\n\nasync function serializeKeyring(k: UnlockedKeyring): Promise<Uint8Array> {\n const deks: Record<string, string> = {}\n for (const [collection, key] of k.deks) {\n const raw = await crypto.subtle.exportKey('raw', key)\n deks[collection] = bytesToBase64(new Uint8Array(raw))\n }\n const json: SerializedKeyring = {\n userId: k.userId,\n displayName: k.displayName,\n role: k.role,\n permissions: k.permissions,\n salt: bytesToBase64(k.salt),\n deks,\n }\n return new TextEncoder().encode(JSON.stringify(json))\n}\n\nasync function deserializeKeyring(bytes: Uint8Array): Promise<UnlockedKeyring> {\n const parsed = JSON.parse(new TextDecoder().decode(bytes)) as SerializedKeyring\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await crypto.subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM' },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n salt: base64ToBytes(parsed.salt),\n deks,\n // KEK is deliberately null — PIN-resume returns a keyring that can\n // read/write but cannot open additional vaults or rotate keys.\n kek: null as unknown as CryptoKey,\n }\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { UnlockedKeyring } from '@noy-db/hub';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.
|
|
5
|
+
*
|
|
6
|
+
* The use case: after the user unlocks a vault with the full passphrase,
|
|
7
|
+
* the session goes idle (screen lock, tab switch). Instead of re-entering
|
|
8
|
+
* the full passphrase, the user types a 4–6 digit PIN (or taps their
|
|
9
|
+
* device biometric) to **resume the already-open session**.
|
|
10
|
+
*
|
|
11
|
+
* ## What this is NOT
|
|
12
|
+
*
|
|
13
|
+
* This is **NOT** a passphrase replacement. If the vault is cold-started
|
|
14
|
+
* (fresh app launch, no prior unlock), a PIN alone cannot open it — the
|
|
15
|
+
* KEK must be re-derived from the real passphrase via PBKDF2-600K.
|
|
16
|
+
*
|
|
17
|
+
* ## Security model
|
|
18
|
+
*
|
|
19
|
+
* 1. **PIN never derives the KEK.** The PIN derives a transient wrapping
|
|
20
|
+
* key via PBKDF2 (100k iterations, not 600k — the protection window is
|
|
21
|
+
* short, so fewer iterations are acceptable).
|
|
22
|
+
* 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries
|
|
23
|
+
* the encrypted DEK map but NOT the KEK. Even if the PIN is
|
|
24
|
+
* compromised, an attacker cannot re-derive the KEK or unwrap a cold
|
|
25
|
+
* keyring — they can only re-open THIS session's cached DEKs.
|
|
26
|
+
* 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After
|
|
27
|
+
* expiry, `resumePin()` throws; the user must re-enter the full
|
|
28
|
+
* passphrase.
|
|
29
|
+
* 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state
|
|
30
|
+
* refuses further attempts until re-enrolment.
|
|
31
|
+
* 5. **Memory-scoped by convention.** The caller is responsible for
|
|
32
|
+
* storing the `PinResumeState` appropriately — ideally in memory
|
|
33
|
+
* (lost when the process exits). Writing it to `localStorage` is
|
|
34
|
+
* allowed but defeats the short-lived-session property, so it is
|
|
35
|
+
* flagged here as a design decision the caller owns.
|
|
36
|
+
*
|
|
37
|
+
* ## Limits (read before shipping)
|
|
38
|
+
*
|
|
39
|
+
* - The `attempts` counter lives inside the `PinResumeState` object.
|
|
40
|
+
* An attacker with a stale copy of the state can "reset" attempts
|
|
41
|
+
* by reverting their copy. Real lockout enforcement needs a trusted
|
|
42
|
+
* counter (server-side or OS secure enclave). Document this to
|
|
43
|
+
* consumers.
|
|
44
|
+
* - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the
|
|
45
|
+
* state blob. Do not persist the state to a public location.
|
|
46
|
+
* - A 4-digit numeric PIN has only 10,000 possibilities. With 100k
|
|
47
|
+
* PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to
|
|
48
|
+
* exhaust the space — roughly hours. Combined with the short TTL
|
|
49
|
+
* and the attempts counter, this is acceptable for UX convenience
|
|
50
|
+
* but NOT for primary authentication.
|
|
51
|
+
*
|
|
52
|
+
* ## API shape (mirrors @noy-db/on-* siblings)
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { enrollPin, resumePin } from '@noy-db/on-pin'
|
|
56
|
+
*
|
|
57
|
+
* // After the user has opened the vault with the full passphrase:
|
|
58
|
+
* const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })
|
|
59
|
+
* // Keep `state` in memory. Do not write it anywhere durable.
|
|
60
|
+
*
|
|
61
|
+
* // Later, when session resumes:
|
|
62
|
+
* const keyring = await resumePin(state, { pin: '1234' })
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @packageDocumentation
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */
|
|
69
|
+
declare const PIN_DEFAULT_TTL_MS: number;
|
|
70
|
+
/** Default max attempts before state refuses further unlock. */
|
|
71
|
+
declare const PIN_DEFAULT_MAX_ATTEMPTS = 5;
|
|
72
|
+
/**
|
|
73
|
+
* PBKDF2 iteration count for the PIN. Lower than the 600k used for
|
|
74
|
+
* passphrase KEK derivation because (a) the window is short, (b) the
|
|
75
|
+
* attempt counter bounds online attacks, (c) the state is not
|
|
76
|
+
* persisted in a public location. Do not lower this further without
|
|
77
|
+
* also raising attempt-counter rigour.
|
|
78
|
+
*/
|
|
79
|
+
declare const PIN_PBKDF2_ITERATIONS = 100000;
|
|
80
|
+
declare class PinInvalidError extends Error {
|
|
81
|
+
readonly code: "PIN_INVALID";
|
|
82
|
+
constructor(message?: string);
|
|
83
|
+
}
|
|
84
|
+
declare class PinExpiredError extends Error {
|
|
85
|
+
readonly code: "PIN_EXPIRED";
|
|
86
|
+
constructor(message?: string);
|
|
87
|
+
}
|
|
88
|
+
declare class PinAttemptsExceededError extends Error {
|
|
89
|
+
readonly code: "PIN_ATTEMPTS_EXCEEDED";
|
|
90
|
+
constructor(message?: string);
|
|
91
|
+
}
|
|
92
|
+
declare class PinEnrollmentError extends Error {
|
|
93
|
+
readonly code: "PIN_ENROLLMENT_FAILED";
|
|
94
|
+
constructor(message?: string);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Opaque serializable state produced by `enrollPin()`. Hand it to
|
|
98
|
+
* `resumePin()` to unlock. Callers keep this in memory (not on disk /
|
|
99
|
+
* sessionStorage in general) per the security model above.
|
|
100
|
+
*
|
|
101
|
+
* `attempts` is the only mutable field; incremented on wrong-PIN
|
|
102
|
+
* failures. Callers should treat the rest as immutable.
|
|
103
|
+
*/
|
|
104
|
+
interface PinResumeState {
|
|
105
|
+
/** Schema marker. */
|
|
106
|
+
readonly _noydb_on_pin: 1;
|
|
107
|
+
/** Base64 PBKDF2 salt (32 random bytes). */
|
|
108
|
+
readonly salt: string;
|
|
109
|
+
/** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */
|
|
110
|
+
readonly iv: string;
|
|
111
|
+
/** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */
|
|
112
|
+
readonly wrappedKeyring: string;
|
|
113
|
+
/** ISO-8601 timestamp after which `resumePin()` refuses. */
|
|
114
|
+
readonly expiresAt: string;
|
|
115
|
+
/** Mutable counter — incremented on each wrong-PIN attempt. */
|
|
116
|
+
attempts: number;
|
|
117
|
+
/** Upper bound; when `attempts >= maxAttempts`, resume throws. */
|
|
118
|
+
readonly maxAttempts: number;
|
|
119
|
+
}
|
|
120
|
+
interface EnrollPinOptions {
|
|
121
|
+
/** The short secret. Typically 4–6 digits, but any string works. */
|
|
122
|
+
readonly pin: string;
|
|
123
|
+
/** Resume window length. Default: 15 minutes. */
|
|
124
|
+
readonly ttlMs?: number;
|
|
125
|
+
/** Max wrong-PIN attempts before the state is dead. Default: 5. */
|
|
126
|
+
readonly maxAttempts?: number;
|
|
127
|
+
}
|
|
128
|
+
interface ResumePinOptions {
|
|
129
|
+
readonly pin: string;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Enrol a PIN for session-resume against an already-unlocked keyring.
|
|
133
|
+
*
|
|
134
|
+
* Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`
|
|
135
|
+
* must succeed). The hub creates DEKs with `extractable: true` by default.
|
|
136
|
+
*
|
|
137
|
+
* @throws `PinEnrollmentError` if any DEK is non-extractable.
|
|
138
|
+
*/
|
|
139
|
+
declare function enrollPin(keyring: UnlockedKeyring, options: EnrollPinOptions): Promise<PinResumeState>;
|
|
140
|
+
/**
|
|
141
|
+
* Resume a session from a previously-enrolled `PinResumeState`.
|
|
142
|
+
*
|
|
143
|
+
* The returned keyring has `kek: null` — PIN resume does NOT reconstruct
|
|
144
|
+
* the KEK (by design). The DEKs are sufficient for normal reads and
|
|
145
|
+
* writes; operations that require a KEK (opening additional vaults,
|
|
146
|
+
* re-enrolling, key rotation) still need the full passphrase flow.
|
|
147
|
+
*
|
|
148
|
+
* @throws `PinExpiredError` if the resume window has elapsed.
|
|
149
|
+
* @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.
|
|
150
|
+
* @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).
|
|
151
|
+
*/
|
|
152
|
+
declare function resumePin(state: PinResumeState, options: ResumePinOptions): Promise<UnlockedKeyring>;
|
|
153
|
+
/** Fast TTL check without attempting decrypt. */
|
|
154
|
+
declare function isPinStateValid(state: PinResumeState): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Zero the state in place. After this, `resumePin()` will fail.
|
|
157
|
+
* Use on explicit logout.
|
|
158
|
+
*/
|
|
159
|
+
declare function clearPinState(state: PinResumeState): void;
|
|
160
|
+
|
|
161
|
+
export { type EnrollPinOptions, PIN_DEFAULT_MAX_ATTEMPTS, PIN_DEFAULT_TTL_MS, PIN_PBKDF2_ITERATIONS, PinAttemptsExceededError, PinEnrollmentError, PinExpiredError, PinInvalidError, type PinResumeState, type ResumePinOptions, clearPinState, enrollPin, isPinStateValid, resumePin };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { UnlockedKeyring } from '@noy-db/hub';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.
|
|
5
|
+
*
|
|
6
|
+
* The use case: after the user unlocks a vault with the full passphrase,
|
|
7
|
+
* the session goes idle (screen lock, tab switch). Instead of re-entering
|
|
8
|
+
* the full passphrase, the user types a 4–6 digit PIN (or taps their
|
|
9
|
+
* device biometric) to **resume the already-open session**.
|
|
10
|
+
*
|
|
11
|
+
* ## What this is NOT
|
|
12
|
+
*
|
|
13
|
+
* This is **NOT** a passphrase replacement. If the vault is cold-started
|
|
14
|
+
* (fresh app launch, no prior unlock), a PIN alone cannot open it — the
|
|
15
|
+
* KEK must be re-derived from the real passphrase via PBKDF2-600K.
|
|
16
|
+
*
|
|
17
|
+
* ## Security model
|
|
18
|
+
*
|
|
19
|
+
* 1. **PIN never derives the KEK.** The PIN derives a transient wrapping
|
|
20
|
+
* key via PBKDF2 (100k iterations, not 600k — the protection window is
|
|
21
|
+
* short, so fewer iterations are acceptable).
|
|
22
|
+
* 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries
|
|
23
|
+
* the encrypted DEK map but NOT the KEK. Even if the PIN is
|
|
24
|
+
* compromised, an attacker cannot re-derive the KEK or unwrap a cold
|
|
25
|
+
* keyring — they can only re-open THIS session's cached DEKs.
|
|
26
|
+
* 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After
|
|
27
|
+
* expiry, `resumePin()` throws; the user must re-enter the full
|
|
28
|
+
* passphrase.
|
|
29
|
+
* 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state
|
|
30
|
+
* refuses further attempts until re-enrolment.
|
|
31
|
+
* 5. **Memory-scoped by convention.** The caller is responsible for
|
|
32
|
+
* storing the `PinResumeState` appropriately — ideally in memory
|
|
33
|
+
* (lost when the process exits). Writing it to `localStorage` is
|
|
34
|
+
* allowed but defeats the short-lived-session property, so it is
|
|
35
|
+
* flagged here as a design decision the caller owns.
|
|
36
|
+
*
|
|
37
|
+
* ## Limits (read before shipping)
|
|
38
|
+
*
|
|
39
|
+
* - The `attempts` counter lives inside the `PinResumeState` object.
|
|
40
|
+
* An attacker with a stale copy of the state can "reset" attempts
|
|
41
|
+
* by reverting their copy. Real lockout enforcement needs a trusted
|
|
42
|
+
* counter (server-side or OS secure enclave). Document this to
|
|
43
|
+
* consumers.
|
|
44
|
+
* - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the
|
|
45
|
+
* state blob. Do not persist the state to a public location.
|
|
46
|
+
* - A 4-digit numeric PIN has only 10,000 possibilities. With 100k
|
|
47
|
+
* PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to
|
|
48
|
+
* exhaust the space — roughly hours. Combined with the short TTL
|
|
49
|
+
* and the attempts counter, this is acceptable for UX convenience
|
|
50
|
+
* but NOT for primary authentication.
|
|
51
|
+
*
|
|
52
|
+
* ## API shape (mirrors @noy-db/on-* siblings)
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { enrollPin, resumePin } from '@noy-db/on-pin'
|
|
56
|
+
*
|
|
57
|
+
* // After the user has opened the vault with the full passphrase:
|
|
58
|
+
* const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })
|
|
59
|
+
* // Keep `state` in memory. Do not write it anywhere durable.
|
|
60
|
+
*
|
|
61
|
+
* // Later, when session resumes:
|
|
62
|
+
* const keyring = await resumePin(state, { pin: '1234' })
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @packageDocumentation
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */
|
|
69
|
+
declare const PIN_DEFAULT_TTL_MS: number;
|
|
70
|
+
/** Default max attempts before state refuses further unlock. */
|
|
71
|
+
declare const PIN_DEFAULT_MAX_ATTEMPTS = 5;
|
|
72
|
+
/**
|
|
73
|
+
* PBKDF2 iteration count for the PIN. Lower than the 600k used for
|
|
74
|
+
* passphrase KEK derivation because (a) the window is short, (b) the
|
|
75
|
+
* attempt counter bounds online attacks, (c) the state is not
|
|
76
|
+
* persisted in a public location. Do not lower this further without
|
|
77
|
+
* also raising attempt-counter rigour.
|
|
78
|
+
*/
|
|
79
|
+
declare const PIN_PBKDF2_ITERATIONS = 100000;
|
|
80
|
+
declare class PinInvalidError extends Error {
|
|
81
|
+
readonly code: "PIN_INVALID";
|
|
82
|
+
constructor(message?: string);
|
|
83
|
+
}
|
|
84
|
+
declare class PinExpiredError extends Error {
|
|
85
|
+
readonly code: "PIN_EXPIRED";
|
|
86
|
+
constructor(message?: string);
|
|
87
|
+
}
|
|
88
|
+
declare class PinAttemptsExceededError extends Error {
|
|
89
|
+
readonly code: "PIN_ATTEMPTS_EXCEEDED";
|
|
90
|
+
constructor(message?: string);
|
|
91
|
+
}
|
|
92
|
+
declare class PinEnrollmentError extends Error {
|
|
93
|
+
readonly code: "PIN_ENROLLMENT_FAILED";
|
|
94
|
+
constructor(message?: string);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Opaque serializable state produced by `enrollPin()`. Hand it to
|
|
98
|
+
* `resumePin()` to unlock. Callers keep this in memory (not on disk /
|
|
99
|
+
* sessionStorage in general) per the security model above.
|
|
100
|
+
*
|
|
101
|
+
* `attempts` is the only mutable field; incremented on wrong-PIN
|
|
102
|
+
* failures. Callers should treat the rest as immutable.
|
|
103
|
+
*/
|
|
104
|
+
interface PinResumeState {
|
|
105
|
+
/** Schema marker. */
|
|
106
|
+
readonly _noydb_on_pin: 1;
|
|
107
|
+
/** Base64 PBKDF2 salt (32 random bytes). */
|
|
108
|
+
readonly salt: string;
|
|
109
|
+
/** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */
|
|
110
|
+
readonly iv: string;
|
|
111
|
+
/** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */
|
|
112
|
+
readonly wrappedKeyring: string;
|
|
113
|
+
/** ISO-8601 timestamp after which `resumePin()` refuses. */
|
|
114
|
+
readonly expiresAt: string;
|
|
115
|
+
/** Mutable counter — incremented on each wrong-PIN attempt. */
|
|
116
|
+
attempts: number;
|
|
117
|
+
/** Upper bound; when `attempts >= maxAttempts`, resume throws. */
|
|
118
|
+
readonly maxAttempts: number;
|
|
119
|
+
}
|
|
120
|
+
interface EnrollPinOptions {
|
|
121
|
+
/** The short secret. Typically 4–6 digits, but any string works. */
|
|
122
|
+
readonly pin: string;
|
|
123
|
+
/** Resume window length. Default: 15 minutes. */
|
|
124
|
+
readonly ttlMs?: number;
|
|
125
|
+
/** Max wrong-PIN attempts before the state is dead. Default: 5. */
|
|
126
|
+
readonly maxAttempts?: number;
|
|
127
|
+
}
|
|
128
|
+
interface ResumePinOptions {
|
|
129
|
+
readonly pin: string;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Enrol a PIN for session-resume against an already-unlocked keyring.
|
|
133
|
+
*
|
|
134
|
+
* Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`
|
|
135
|
+
* must succeed). The hub creates DEKs with `extractable: true` by default.
|
|
136
|
+
*
|
|
137
|
+
* @throws `PinEnrollmentError` if any DEK is non-extractable.
|
|
138
|
+
*/
|
|
139
|
+
declare function enrollPin(keyring: UnlockedKeyring, options: EnrollPinOptions): Promise<PinResumeState>;
|
|
140
|
+
/**
|
|
141
|
+
* Resume a session from a previously-enrolled `PinResumeState`.
|
|
142
|
+
*
|
|
143
|
+
* The returned keyring has `kek: null` — PIN resume does NOT reconstruct
|
|
144
|
+
* the KEK (by design). The DEKs are sufficient for normal reads and
|
|
145
|
+
* writes; operations that require a KEK (opening additional vaults,
|
|
146
|
+
* re-enrolling, key rotation) still need the full passphrase flow.
|
|
147
|
+
*
|
|
148
|
+
* @throws `PinExpiredError` if the resume window has elapsed.
|
|
149
|
+
* @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.
|
|
150
|
+
* @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).
|
|
151
|
+
*/
|
|
152
|
+
declare function resumePin(state: PinResumeState, options: ResumePinOptions): Promise<UnlockedKeyring>;
|
|
153
|
+
/** Fast TTL check without attempting decrypt. */
|
|
154
|
+
declare function isPinStateValid(state: PinResumeState): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Zero the state in place. After this, `resumePin()` will fail.
|
|
157
|
+
* Use on explicit logout.
|
|
158
|
+
*/
|
|
159
|
+
declare function clearPinState(state: PinResumeState): void;
|
|
160
|
+
|
|
161
|
+
export { type EnrollPinOptions, PIN_DEFAULT_MAX_ATTEMPTS, PIN_DEFAULT_TTL_MS, PIN_PBKDF2_ITERATIONS, PinAttemptsExceededError, PinEnrollmentError, PinExpiredError, PinInvalidError, type PinResumeState, type ResumePinOptions, clearPinState, enrollPin, isPinStateValid, resumePin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var PIN_DEFAULT_TTL_MS = 15 * 60 * 1e3;
|
|
3
|
+
var PIN_DEFAULT_MAX_ATTEMPTS = 5;
|
|
4
|
+
var PIN_PBKDF2_ITERATIONS = 1e5;
|
|
5
|
+
var PinInvalidError = class extends Error {
|
|
6
|
+
code = "PIN_INVALID";
|
|
7
|
+
constructor(message = "PIN is incorrect.") {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "PinInvalidError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var PinExpiredError = class extends Error {
|
|
13
|
+
code = "PIN_EXPIRED";
|
|
14
|
+
constructor(message = "PIN resume window has expired; re-enter full passphrase.") {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "PinExpiredError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var PinAttemptsExceededError = class extends Error {
|
|
20
|
+
code = "PIN_ATTEMPTS_EXCEEDED";
|
|
21
|
+
constructor(message = "Too many wrong PIN attempts; re-enter full passphrase.") {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "PinAttemptsExceededError";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var PinEnrollmentError = class extends Error {
|
|
27
|
+
code = "PIN_ENROLLMENT_FAILED";
|
|
28
|
+
constructor(message = "PIN enrolment failed.") {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "PinEnrollmentError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
async function enrollPin(keyring, options) {
|
|
34
|
+
const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS;
|
|
35
|
+
const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS;
|
|
36
|
+
const salt = crypto.getRandomValues(new Uint8Array(32));
|
|
37
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
38
|
+
const wrappingKey = await deriveWrappingKey(options.pin, salt);
|
|
39
|
+
let serialized;
|
|
40
|
+
try {
|
|
41
|
+
serialized = await serializeKeyring(keyring);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new PinEnrollmentError(
|
|
44
|
+
`Failed to serialize keyring \u2014 DEK not extractable. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
48
|
+
{ name: "AES-GCM", iv },
|
|
49
|
+
wrappingKey,
|
|
50
|
+
serialized
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
_noydb_on_pin: 1,
|
|
54
|
+
salt: bytesToBase64(salt),
|
|
55
|
+
iv: bytesToBase64(iv),
|
|
56
|
+
wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),
|
|
57
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
58
|
+
attempts: 0,
|
|
59
|
+
maxAttempts
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function resumePin(state, options) {
|
|
63
|
+
if (Date.now() > new Date(state.expiresAt).getTime()) {
|
|
64
|
+
throw new PinExpiredError();
|
|
65
|
+
}
|
|
66
|
+
if (state.attempts >= state.maxAttempts) {
|
|
67
|
+
throw new PinAttemptsExceededError();
|
|
68
|
+
}
|
|
69
|
+
const salt = base64ToBytes(state.salt);
|
|
70
|
+
const iv = base64ToBytes(state.iv);
|
|
71
|
+
const ciphertext = base64ToBytes(state.wrappedKeyring);
|
|
72
|
+
const wrappingKey = await deriveWrappingKey(options.pin, salt);
|
|
73
|
+
let plaintext;
|
|
74
|
+
try {
|
|
75
|
+
plaintext = await crypto.subtle.decrypt(
|
|
76
|
+
{ name: "AES-GCM", iv },
|
|
77
|
+
wrappingKey,
|
|
78
|
+
ciphertext
|
|
79
|
+
);
|
|
80
|
+
} catch {
|
|
81
|
+
state.attempts = state.attempts + 1;
|
|
82
|
+
throw new PinInvalidError();
|
|
83
|
+
}
|
|
84
|
+
return deserializeKeyring(new Uint8Array(plaintext));
|
|
85
|
+
}
|
|
86
|
+
function isPinStateValid(state) {
|
|
87
|
+
return Date.now() <= new Date(state.expiresAt).getTime() && state.attempts < state.maxAttempts;
|
|
88
|
+
}
|
|
89
|
+
function clearPinState(state) {
|
|
90
|
+
;
|
|
91
|
+
state.attempts = state.maxAttempts;
|
|
92
|
+
state.expiresAt = (/* @__PURE__ */ new Date(0)).toISOString();
|
|
93
|
+
state.wrappedKeyring = "";
|
|
94
|
+
}
|
|
95
|
+
async function deriveWrappingKey(pin, salt) {
|
|
96
|
+
const ikm = await crypto.subtle.importKey(
|
|
97
|
+
"raw",
|
|
98
|
+
new TextEncoder().encode(pin),
|
|
99
|
+
"PBKDF2",
|
|
100
|
+
false,
|
|
101
|
+
["deriveKey"]
|
|
102
|
+
);
|
|
103
|
+
return crypto.subtle.deriveKey(
|
|
104
|
+
{
|
|
105
|
+
name: "PBKDF2",
|
|
106
|
+
salt,
|
|
107
|
+
iterations: PIN_PBKDF2_ITERATIONS,
|
|
108
|
+
hash: "SHA-256"
|
|
109
|
+
},
|
|
110
|
+
ikm,
|
|
111
|
+
{ name: "AES-GCM", length: 256 },
|
|
112
|
+
false,
|
|
113
|
+
["encrypt", "decrypt"]
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
async function serializeKeyring(k) {
|
|
117
|
+
const deks = {};
|
|
118
|
+
for (const [collection, key] of k.deks) {
|
|
119
|
+
const raw = await crypto.subtle.exportKey("raw", key);
|
|
120
|
+
deks[collection] = bytesToBase64(new Uint8Array(raw));
|
|
121
|
+
}
|
|
122
|
+
const json = {
|
|
123
|
+
userId: k.userId,
|
|
124
|
+
displayName: k.displayName,
|
|
125
|
+
role: k.role,
|
|
126
|
+
permissions: k.permissions,
|
|
127
|
+
salt: bytesToBase64(k.salt),
|
|
128
|
+
deks
|
|
129
|
+
};
|
|
130
|
+
return new TextEncoder().encode(JSON.stringify(json));
|
|
131
|
+
}
|
|
132
|
+
async function deserializeKeyring(bytes) {
|
|
133
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
134
|
+
const deks = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const [coll, b64] of Object.entries(parsed.deks)) {
|
|
136
|
+
const raw = base64ToBytes(b64);
|
|
137
|
+
const key = await crypto.subtle.importKey(
|
|
138
|
+
"raw",
|
|
139
|
+
raw,
|
|
140
|
+
{ name: "AES-GCM" },
|
|
141
|
+
true,
|
|
142
|
+
["encrypt", "decrypt"]
|
|
143
|
+
);
|
|
144
|
+
deks.set(coll, key);
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
userId: parsed.userId,
|
|
148
|
+
displayName: parsed.displayName,
|
|
149
|
+
role: parsed.role,
|
|
150
|
+
permissions: parsed.permissions,
|
|
151
|
+
salt: base64ToBytes(parsed.salt),
|
|
152
|
+
deks,
|
|
153
|
+
// KEK is deliberately null — PIN-resume returns a keyring that can
|
|
154
|
+
// read/write but cannot open additional vaults or rotate keys.
|
|
155
|
+
kek: null
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function bytesToBase64(bytes) {
|
|
159
|
+
let s = "";
|
|
160
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
161
|
+
return btoa(s);
|
|
162
|
+
}
|
|
163
|
+
function base64ToBytes(b64) {
|
|
164
|
+
const s = atob(b64);
|
|
165
|
+
const out = new Uint8Array(s.length);
|
|
166
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
export {
|
|
170
|
+
PIN_DEFAULT_MAX_ATTEMPTS,
|
|
171
|
+
PIN_DEFAULT_TTL_MS,
|
|
172
|
+
PIN_PBKDF2_ITERATIONS,
|
|
173
|
+
PinAttemptsExceededError,
|
|
174
|
+
PinEnrollmentError,
|
|
175
|
+
PinExpiredError,
|
|
176
|
+
PinInvalidError,
|
|
177
|
+
clearPinState,
|
|
178
|
+
enrollPin,
|
|
179
|
+
isPinStateValid,
|
|
180
|
+
resumePin
|
|
181
|
+
};
|
|
182
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.\n *\n * The use case: after the user unlocks a vault with the full passphrase,\n * the session goes idle (screen lock, tab switch). Instead of re-entering\n * the full passphrase, the user types a 4–6 digit PIN (or taps their\n * device biometric) to **resume the already-open session**.\n *\n * ## What this is NOT\n *\n * This is **NOT** a passphrase replacement. If the vault is cold-started\n * (fresh app launch, no prior unlock), a PIN alone cannot open it — the\n * KEK must be re-derived from the real passphrase via PBKDF2-600K.\n *\n * ## Security model\n *\n * 1. **PIN never derives the KEK.** The PIN derives a transient wrapping\n * key via PBKDF2 (100k iterations, not 600k — the protection window is\n * short, so fewer iterations are acceptable).\n * 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries\n * the encrypted DEK map but NOT the KEK. Even if the PIN is\n * compromised, an attacker cannot re-derive the KEK or unwrap a cold\n * keyring — they can only re-open THIS session's cached DEKs.\n * 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After\n * expiry, `resumePin()` throws; the user must re-enter the full\n * passphrase.\n * 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state\n * refuses further attempts until re-enrolment.\n * 5. **Memory-scoped by convention.** The caller is responsible for\n * storing the `PinResumeState` appropriately — ideally in memory\n * (lost when the process exits). Writing it to `localStorage` is\n * allowed but defeats the short-lived-session property, so it is\n * flagged here as a design decision the caller owns.\n *\n * ## Limits (read before shipping)\n *\n * - The `attempts` counter lives inside the `PinResumeState` object.\n * An attacker with a stale copy of the state can \"reset\" attempts\n * by reverting their copy. Real lockout enforcement needs a trusted\n * counter (server-side or OS secure enclave). Document this to\n * consumers.\n * - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the\n * state blob. Do not persist the state to a public location.\n * - A 4-digit numeric PIN has only 10,000 possibilities. With 100k\n * PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to\n * exhaust the space — roughly hours. Combined with the short TTL\n * and the attempts counter, this is acceptable for UX convenience\n * but NOT for primary authentication.\n *\n * ## API shape (mirrors @noy-db/on-* siblings)\n *\n * ```ts\n * import { enrollPin, resumePin } from '@noy-db/on-pin'\n *\n * // After the user has opened the vault with the full passphrase:\n * const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })\n * // Keep `state` in memory. Do not write it anywhere durable.\n *\n * // Later, when session resumes:\n * const keyring = await resumePin(state, { pin: '1234' })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { Role, Permissions, UnlockedKeyring } from '@noy-db/hub'\n\n// ─── Constants ──────────────────────────────────────────────────────────\n\n/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */\nexport const PIN_DEFAULT_TTL_MS = 15 * 60 * 1000\n\n/** Default max attempts before state refuses further unlock. */\nexport const PIN_DEFAULT_MAX_ATTEMPTS = 5\n\n/**\n * PBKDF2 iteration count for the PIN. Lower than the 600k used for\n * passphrase KEK derivation because (a) the window is short, (b) the\n * attempt counter bounds online attacks, (c) the state is not\n * persisted in a public location. Do not lower this further without\n * also raising attempt-counter rigour.\n */\nexport const PIN_PBKDF2_ITERATIONS = 100_000\n\n// ─── Errors ─────────────────────────────────────────────────────────────\n\nexport class PinInvalidError extends Error {\n readonly code = 'PIN_INVALID' as const\n constructor(message = 'PIN is incorrect.') {\n super(message)\n this.name = 'PinInvalidError'\n }\n}\n\nexport class PinExpiredError extends Error {\n readonly code = 'PIN_EXPIRED' as const\n constructor(message = 'PIN resume window has expired; re-enter full passphrase.') {\n super(message)\n this.name = 'PinExpiredError'\n }\n}\n\nexport class PinAttemptsExceededError extends Error {\n readonly code = 'PIN_ATTEMPTS_EXCEEDED' as const\n constructor(message = 'Too many wrong PIN attempts; re-enter full passphrase.') {\n super(message)\n this.name = 'PinAttemptsExceededError'\n }\n}\n\nexport class PinEnrollmentError extends Error {\n readonly code = 'PIN_ENROLLMENT_FAILED' as const\n constructor(message = 'PIN enrolment failed.') {\n super(message)\n this.name = 'PinEnrollmentError'\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Opaque serializable state produced by `enrollPin()`. Hand it to\n * `resumePin()` to unlock. Callers keep this in memory (not on disk /\n * sessionStorage in general) per the security model above.\n *\n * `attempts` is the only mutable field; incremented on wrong-PIN\n * failures. Callers should treat the rest as immutable.\n */\nexport interface PinResumeState {\n /** Schema marker. */\n readonly _noydb_on_pin: 1\n /** Base64 PBKDF2 salt (32 random bytes). */\n readonly salt: string\n /** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */\n readonly wrappedKeyring: string\n /** ISO-8601 timestamp after which `resumePin()` refuses. */\n readonly expiresAt: string\n /** Mutable counter — incremented on each wrong-PIN attempt. */\n attempts: number\n /** Upper bound; when `attempts >= maxAttempts`, resume throws. */\n readonly maxAttempts: number\n}\n\nexport interface EnrollPinOptions {\n /** The short secret. Typically 4–6 digits, but any string works. */\n readonly pin: string\n /** Resume window length. Default: 15 minutes. */\n readonly ttlMs?: number\n /** Max wrong-PIN attempts before the state is dead. Default: 5. */\n readonly maxAttempts?: number\n}\n\nexport interface ResumePinOptions {\n readonly pin: string\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────\n\n/**\n * Enrol a PIN for session-resume against an already-unlocked keyring.\n *\n * Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`\n * must succeed). The hub creates DEKs with `extractable: true` by default.\n *\n * @throws `PinEnrollmentError` if any DEK is non-extractable.\n */\nexport async function enrollPin(\n keyring: UnlockedKeyring,\n options: EnrollPinOptions,\n): Promise<PinResumeState> {\n const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS\n const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS\n\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const iv = crypto.getRandomValues(new Uint8Array(12))\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let serialized: Uint8Array\n try {\n serialized = await serializeKeyring(keyring)\n } catch (err) {\n throw new PinEnrollmentError(\n 'Failed to serialize keyring — DEK not extractable. ' +\n `Underlying error: ${err instanceof Error ? err.message : String(err)}`,\n )\n }\n\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n serialized as BufferSource,\n )\n\n return {\n _noydb_on_pin: 1,\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n attempts: 0,\n maxAttempts,\n }\n}\n\n/**\n * Resume a session from a previously-enrolled `PinResumeState`.\n *\n * The returned keyring has `kek: null` — PIN resume does NOT reconstruct\n * the KEK (by design). The DEKs are sufficient for normal reads and\n * writes; operations that require a KEK (opening additional vaults,\n * re-enrolling, key rotation) still need the full passphrase flow.\n *\n * @throws `PinExpiredError` if the resume window has elapsed.\n * @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.\n * @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).\n */\nexport async function resumePin(\n state: PinResumeState,\n options: ResumePinOptions,\n): Promise<UnlockedKeyring> {\n if (Date.now() > new Date(state.expiresAt).getTime()) {\n throw new PinExpiredError()\n }\n if (state.attempts >= state.maxAttempts) {\n throw new PinAttemptsExceededError()\n }\n\n const salt = base64ToBytes(state.salt)\n const iv = base64ToBytes(state.iv)\n const ciphertext = base64ToBytes(state.wrappedKeyring)\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n ciphertext as BufferSource,\n )\n } catch {\n // AES-GCM auth failure. Increment the attempts counter before\n // throwing so repeated wrong PINs progressively lock the state.\n state.attempts = state.attempts + 1\n throw new PinInvalidError()\n }\n\n return deserializeKeyring(new Uint8Array(plaintext))\n}\n\n/** Fast TTL check without attempting decrypt. */\nexport function isPinStateValid(state: PinResumeState): boolean {\n return (\n Date.now() <= new Date(state.expiresAt).getTime() &&\n state.attempts < state.maxAttempts\n )\n}\n\n/**\n * Zero the state in place. After this, `resumePin()` will fail.\n * Use on explicit logout.\n */\nexport function clearPinState(state: PinResumeState): void {\n // Overwrite the attempts counter past the max + expire the state.\n ;(state as { attempts: number }).attempts = state.maxAttempts\n ;(state as { expiresAt: string }).expiresAt = new Date(0).toISOString()\n ;(state as { wrappedKeyring: string }).wrappedKeyring = ''\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(\n pin: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(pin),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PIN_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\ninterface SerializedKeyring {\n userId: string\n displayName: string\n role: Role\n permissions: Permissions\n salt: string\n deks: Record<string, string>\n}\n\nasync function serializeKeyring(k: UnlockedKeyring): Promise<Uint8Array> {\n const deks: Record<string, string> = {}\n for (const [collection, key] of k.deks) {\n const raw = await crypto.subtle.exportKey('raw', key)\n deks[collection] = bytesToBase64(new Uint8Array(raw))\n }\n const json: SerializedKeyring = {\n userId: k.userId,\n displayName: k.displayName,\n role: k.role,\n permissions: k.permissions,\n salt: bytesToBase64(k.salt),\n deks,\n }\n return new TextEncoder().encode(JSON.stringify(json))\n}\n\nasync function deserializeKeyring(bytes: Uint8Array): Promise<UnlockedKeyring> {\n const parsed = JSON.parse(new TextDecoder().decode(bytes)) as SerializedKeyring\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await crypto.subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM' },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n salt: base64ToBytes(parsed.salt),\n deks,\n // KEK is deliberately null — PIN-resume returns a keyring that can\n // read/write but cannot open additional vaults or rotate keys.\n kek: null as unknown as CryptoKey,\n }\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":";AAsEO,IAAM,qBAAqB,KAAK,KAAK;AAGrC,IAAM,2BAA2B;AASjC,IAAM,wBAAwB;AAI9B,IAAM,kBAAN,cAA8B,MAAM;AAAA,EAChC,OAAO;AAAA,EAChB,YAAY,UAAU,qBAAqB;AACzC,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,IAAM,kBAAN,cAA8B,MAAM;AAAA,EAChC,OAAO;AAAA,EAChB,YAAY,UAAU,4DAA4D;AAChF,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,IAAM,2BAAN,cAAuC,MAAM;AAAA,EACzC,OAAO;AAAA,EAChB,YAAY,UAAU,0DAA0D;AAC9E,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,IAAM,qBAAN,cAAiC,MAAM;AAAA,EACnC,OAAO;AAAA,EAChB,YAAY,UAAU,yBAAyB;AAC7C,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAoDA,eAAsB,UACpB,SACA,SACyB;AACzB,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,cAAc,QAAQ,eAAe;AAE3C,QAAM,OAAO,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACtD,QAAM,KAAK,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AAEpD,QAAM,cAAc,MAAM,kBAAkB,QAAQ,KAAK,IAAI;AAE7D,MAAI;AACJ,MAAI;AACF,iBAAa,MAAM,iBAAiB,OAAO;AAAA,EAC7C,SAAS,KAAK;AACZ,UAAM,IAAI;AAAA,MACR,6EACqB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA,IACvE;AAAA,EACF;AAEA,QAAM,aAAa,MAAM,OAAO,OAAO;AAAA,IACrC,EAAE,MAAM,WAAW,GAAuB;AAAA,IAC1C;AAAA,IACA;AAAA,EACF;AAEA,SAAO;AAAA,IACL,eAAe;AAAA,IACf,MAAM,cAAc,IAAI;AAAA,IACxB,IAAI,cAAc,EAAE;AAAA,IACpB,gBAAgB,cAAc,IAAI,WAAW,UAAU,CAAC;AAAA,IACxD,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,EAAE,YAAY;AAAA,IACpD,UAAU;AAAA,IACV;AAAA,EACF;AACF;AAcA,eAAsB,UACpB,OACA,SAC0B;AAC1B,MAAI,KAAK,IAAI,IAAI,IAAI,KAAK,MAAM,SAAS,EAAE,QAAQ,GAAG;AACpD,UAAM,IAAI,gBAAgB;AAAA,EAC5B;AACA,MAAI,MAAM,YAAY,MAAM,aAAa;AACvC,UAAM,IAAI,yBAAyB;AAAA,EACrC;AAEA,QAAM,OAAO,cAAc,MAAM,IAAI;AACrC,QAAM,KAAK,cAAc,MAAM,EAAE;AACjC,QAAM,aAAa,cAAc,MAAM,cAAc;AAErD,QAAM,cAAc,MAAM,kBAAkB,QAAQ,KAAK,IAAI;AAE7D,MAAI;AACJ,MAAI;AACF,gBAAY,MAAM,OAAO,OAAO;AAAA,MAC9B,EAAE,MAAM,WAAW,GAAuB;AAAA,MAC1C;AAAA,MACA;AAAA,IACF;AAAA,EACF,QAAQ;AAGN,UAAM,WAAW,MAAM,WAAW;AAClC,UAAM,IAAI,gBAAgB;AAAA,EAC5B;AAEA,SAAO,mBAAmB,IAAI,WAAW,SAAS,CAAC;AACrD;AAGO,SAAS,gBAAgB,OAAgC;AAC9D,SACE,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,SAAS,EAAE,QAAQ,KAChD,MAAM,WAAW,MAAM;AAE3B;AAMO,SAAS,cAAc,OAA6B;AAEzD;AAAC,EAAC,MAA+B,WAAW,MAAM;AACjD,EAAC,MAAgC,aAAY,oBAAI,KAAK,CAAC,GAAE,YAAY;AACrE,EAAC,MAAqC,iBAAiB;AAC1D;AAIA,eAAe,kBACb,KACA,MACoB;AACpB,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,YAAY,EAAE,OAAO,GAAG;AAAA,IAC5B;AAAA,IACA;AAAA,IACA,CAAC,WAAW;AAAA,EACd;AACA,SAAO,OAAO,OAAO;AAAA,IACnB;AAAA,MACE,MAAM;AAAA,MACN;AAAA,MACA,YAAY;AAAA,MACZ,MAAM;AAAA,IACR;AAAA,IACA;AAAA,IACA,EAAE,MAAM,WAAW,QAAQ,IAAI;AAAA,IAC/B;AAAA,IACA,CAAC,WAAW,SAAS;AAAA,EACvB;AACF;AAWA,eAAe,iBAAiB,GAAyC;AACvE,QAAM,OAA+B,CAAC;AACtC,aAAW,CAAC,YAAY,GAAG,KAAK,EAAE,MAAM;AACtC,UAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,GAAG;AACpD,SAAK,UAAU,IAAI,cAAc,IAAI,WAAW,GAAG,CAAC;AAAA,EACtD;AACA,QAAM,OAA0B;AAAA,IAC9B,QAAQ,EAAE;AAAA,IACV,aAAa,EAAE;AAAA,IACf,MAAM,EAAE;AAAA,IACR,aAAa,EAAE;AAAA,IACf,MAAM,cAAc,EAAE,IAAI;AAAA,IAC1B;AAAA,EACF;AACA,SAAO,IAAI,YAAY,EAAE,OAAO,KAAK,UAAU,IAAI,CAAC;AACtD;AAEA,eAAe,mBAAmB,OAA6C;AAC7E,QAAM,SAAS,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AACzD,QAAM,OAAO,oBAAI,IAAuB;AACxC,aAAW,CAAC,MAAM,GAAG,KAAK,OAAO,QAAQ,OAAO,IAAI,GAAG;AACrD,UAAM,MAAM,cAAc,GAAG;AAC7B,UAAM,MAAM,MAAM,OAAO,OAAO;AAAA,MAC9B;AAAA,MACA;AAAA,MACA,EAAE,MAAM,UAAU;AAAA,MAClB;AAAA,MACA,CAAC,WAAW,SAAS;AAAA,IACvB;AACA,SAAK,IAAI,MAAM,GAAG;AAAA,EACpB;AACA,SAAO;AAAA,IACL,QAAQ,OAAO;AAAA,IACf,aAAa,OAAO;AAAA,IACpB,MAAM,OAAO;AAAA,IACb,aAAa,OAAO;AAAA,IACpB,MAAM,cAAc,OAAO,IAAI;AAAA,IAC/B;AAAA;AAAA;AAAA,IAGA,KAAK;AAAA,EACP;AACF;AAEA,SAAS,cAAc,OAA2B;AAChD,MAAI,IAAI;AACR,aAAW,KAAK,MAAO,MAAK,OAAO,aAAa,CAAC;AACjD,SAAO,KAAK,CAAC;AACf;AAEA,SAAS,cAAc,KAAyB;AAC9C,QAAM,IAAI,KAAK,GAAG;AAClB,QAAM,MAAM,IAAI,WAAW,EAAE,MAAM;AACnC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,IAAK,KAAI,CAAC,IAAI,EAAE,WAAW,CAAC;AAC1D,SAAO;AACT;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noy-db/on-pin",
|
|
3
|
+
"version": "0.1.0-pre.3",
|
|
4
|
+
"description": "Session-resume PIN quick-lock for noy-db — after a full passphrase unlock, a short-lived PIN (or a per-device biometric) re-unlocks the cached DEKs without re-typing the passphrase. PIN never replaces the passphrase; only resumes an already-unlocked session.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "vLannaAi <vicio@lanna.ai>",
|
|
7
|
+
"homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/on-pin#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vLannaAi/noy-db.git",
|
|
11
|
+
"directory": "packages/on-pin"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/vLannaAi/noy-db/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/index.d.cts",
|
|
26
|
+
"default": "./dist/index.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@noy-db/hub": "0.1.0-pre.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@noy-db/hub": "0.1.0-pre.3"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"noy-db",
|
|
49
|
+
"auth",
|
|
50
|
+
"on-pin",
|
|
51
|
+
"pin",
|
|
52
|
+
"quick-lock",
|
|
53
|
+
"session-resume",
|
|
54
|
+
"biometric",
|
|
55
|
+
"idle-timeout",
|
|
56
|
+
"zero-knowledge"
|
|
57
|
+
],
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public",
|
|
60
|
+
"tag": "latest"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsup",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"lint": "eslint src/",
|
|
66
|
+
"typecheck": "tsc --noEmit"
|
|
67
|
+
}
|
|
68
|
+
}
|