@noy-db/on-oidc 0.2.0-pre.9 → 0.3.0-pre.1
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/README.md +1 -1
- package/package.json +6 -13
- package/dist/index.cjs +0 -306
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -223
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ See the [main repository](https://github.com/vLannaAi/noy-db#readme) for setup,
|
|
|
38
38
|
|
|
39
39
|
- Source — [`packages/on-oidc`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-oidc)
|
|
40
40
|
- Issues — [github.com/vLannaAi/noy-db/issues](https://github.com/vLannaAi/noy-db/issues)
|
|
41
|
-
- Spec — [`SPEC.md`](https://github.com/vLannaAi/noy-db/blob/main/SPEC.md)
|
|
41
|
+
- Spec — [`SPEC.md`](https://github.com/vLannaAi/noy-db-docs/blob/main/SPEC.md)
|
|
42
42
|
|
|
43
43
|
## License
|
|
44
44
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/on-oidc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-pre.1",
|
|
4
4
|
"description": "OAuth/OIDC bridge for noy-db — federated login (LINE, Google, Apple, Okta) with split-key key connector — server never sees plaintext",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -17,17 +17,10 @@
|
|
|
17
17
|
"sideEffects": false,
|
|
18
18
|
"exports": {
|
|
19
19
|
".": {
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
"default": "./dist/index.js"
|
|
23
|
-
},
|
|
24
|
-
"require": {
|
|
25
|
-
"types": "./dist/index.d.cts",
|
|
26
|
-
"default": "./dist/index.cjs"
|
|
27
|
-
}
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
28
22
|
}
|
|
29
23
|
},
|
|
30
|
-
"main": "./dist/index.cjs",
|
|
31
24
|
"module": "./dist/index.js",
|
|
32
25
|
"types": "./dist/index.d.ts",
|
|
33
26
|
"files": [
|
|
@@ -36,13 +29,13 @@
|
|
|
36
29
|
"LICENSE"
|
|
37
30
|
],
|
|
38
31
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
32
|
+
"node": ">=22.0.0"
|
|
40
33
|
},
|
|
41
34
|
"peerDependencies": {
|
|
42
|
-
"@noy-db/hub": "0.
|
|
35
|
+
"@noy-db/hub": "0.3.0-pre.1"
|
|
43
36
|
},
|
|
44
37
|
"devDependencies": {
|
|
45
|
-
"@noy-db/hub": "0.
|
|
38
|
+
"@noy-db/hub": "0.3.0-pre.1"
|
|
46
39
|
},
|
|
47
40
|
"keywords": [
|
|
48
41
|
"noy-db",
|
package/dist/index.cjs
DELETED
|
@@ -1,306 +0,0 @@
|
|
|
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
|
-
KeyConnectorError: () => KeyConnectorError,
|
|
24
|
-
OidcDeviceSecretNotFoundError: () => OidcDeviceSecretNotFoundError,
|
|
25
|
-
OidcTokenError: () => OidcTokenError,
|
|
26
|
-
ValidationError: () => import_hub3.ValidationError,
|
|
27
|
-
enrollOidc: () => enrollOidc,
|
|
28
|
-
isIdTokenExpired: () => isIdTokenExpired,
|
|
29
|
-
knownProviders: () => knownProviders,
|
|
30
|
-
parseIdTokenClaims: () => parseIdTokenClaims,
|
|
31
|
-
unlockOidc: () => unlockOidc
|
|
32
|
-
});
|
|
33
|
-
module.exports = __toCommonJS(index_exports);
|
|
34
|
-
var import_hub = require("@noy-db/hub");
|
|
35
|
-
var import_hub2 = require("@noy-db/hub");
|
|
36
|
-
var import_hub3 = require("@noy-db/hub");
|
|
37
|
-
var OidcTokenError = class extends Error {
|
|
38
|
-
code = "OIDC_TOKEN_ERROR";
|
|
39
|
-
constructor(message) {
|
|
40
|
-
super(message);
|
|
41
|
-
this.name = "OidcTokenError";
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
var KeyConnectorError = class extends Error {
|
|
45
|
-
code = "KEY_CONNECTOR_ERROR";
|
|
46
|
-
/** HTTP status code from the key-connector server, if available. */
|
|
47
|
-
status;
|
|
48
|
-
constructor(message, status) {
|
|
49
|
-
super(message);
|
|
50
|
-
this.name = "KeyConnectorError";
|
|
51
|
-
this.status = status;
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
var OidcDeviceSecretNotFoundError = class extends Error {
|
|
55
|
-
code = "OIDC_DEVICE_SECRET_NOT_FOUND";
|
|
56
|
-
constructor(sub) {
|
|
57
|
-
super(
|
|
58
|
-
`No device secret found for OIDC subject "${sub}". The device secret is generated at enrollment and stored in browser storage. Re-enroll on this device to restore OIDC unlock.`
|
|
59
|
-
);
|
|
60
|
-
this.name = "OidcDeviceSecretNotFoundError";
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
var DEVICE_SECRET_PREFIX = "noydb:oidc:device-secret:";
|
|
64
|
-
function getDeviceSecret(sub, storage) {
|
|
65
|
-
const store = storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
|
|
66
|
-
if (!store) return null;
|
|
67
|
-
const raw = store.getItem(DEVICE_SECRET_PREFIX + sub);
|
|
68
|
-
if (!raw) return null;
|
|
69
|
-
return (0, import_hub.base64ToBuffer)(raw);
|
|
70
|
-
}
|
|
71
|
-
function saveDeviceSecret(sub, secret, storage) {
|
|
72
|
-
const store = storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
|
|
73
|
-
if (!store) throw new import_hub2.ValidationError("localStorage is not available in this environment");
|
|
74
|
-
store.setItem(DEVICE_SECRET_PREFIX + sub, (0, import_hub.bufferToBase64)(secret));
|
|
75
|
-
}
|
|
76
|
-
async function deriveDeviceHalf(deviceSecret, sub, vault, keyLengthBytes) {
|
|
77
|
-
const subtle = globalThis.crypto.subtle;
|
|
78
|
-
const ikm = await subtle.importKey("raw", deviceSecret, "HKDF", false, ["deriveBits"]);
|
|
79
|
-
const bits = await subtle.deriveBits(
|
|
80
|
-
{
|
|
81
|
-
name: "HKDF",
|
|
82
|
-
hash: "SHA-256",
|
|
83
|
-
salt: new TextEncoder().encode(sub),
|
|
84
|
-
info: new TextEncoder().encode(`noydb-oidc-device-v1:${vault}`)
|
|
85
|
-
},
|
|
86
|
-
ikm,
|
|
87
|
-
keyLengthBytes * 8
|
|
88
|
-
);
|
|
89
|
-
return new Uint8Array(bits);
|
|
90
|
-
}
|
|
91
|
-
function xorBytes(a, b) {
|
|
92
|
-
if (a.length !== b.length) {
|
|
93
|
-
throw new Error(`xorBytes: length mismatch (${a.length} vs ${b.length})`);
|
|
94
|
-
}
|
|
95
|
-
const out = new Uint8Array(a.length);
|
|
96
|
-
for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i];
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
function parseIdTokenClaims(idToken) {
|
|
100
|
-
const parts = idToken.split(".");
|
|
101
|
-
if (parts.length !== 3) {
|
|
102
|
-
throw new OidcTokenError("Invalid ID token format: expected 3 JWT segments");
|
|
103
|
-
}
|
|
104
|
-
const payloadPart = parts[1];
|
|
105
|
-
const padded = payloadPart + "=".repeat((4 - payloadPart.length % 4) % 4);
|
|
106
|
-
const json = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
|
|
107
|
-
const claims = JSON.parse(json);
|
|
108
|
-
if (!claims.sub) throw new OidcTokenError('ID token missing "sub" claim');
|
|
109
|
-
if (!claims.exp) throw new OidcTokenError('ID token missing "exp" claim');
|
|
110
|
-
return claims;
|
|
111
|
-
}
|
|
112
|
-
function isIdTokenExpired(idToken) {
|
|
113
|
-
try {
|
|
114
|
-
const claims = parseIdTokenClaims(idToken);
|
|
115
|
-
return Date.now() / 1e3 > claims.exp;
|
|
116
|
-
} catch {
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
async function putServerHalf(keyConnectorUrl, idToken, serverHalfBase64, ivBase64) {
|
|
121
|
-
const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {
|
|
122
|
-
method: "PUT",
|
|
123
|
-
headers: {
|
|
124
|
-
"Content-Type": "application/json",
|
|
125
|
-
Authorization: `Bearer ${idToken}`
|
|
126
|
-
},
|
|
127
|
-
body: JSON.stringify({ encryptedServerHalf: serverHalfBase64, iv: ivBase64 })
|
|
128
|
-
});
|
|
129
|
-
if (!resp.ok) {
|
|
130
|
-
throw new KeyConnectorError(
|
|
131
|
-
`Key connector PUT failed: ${resp.status} ${resp.statusText}`,
|
|
132
|
-
resp.status
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
async function getServerHalf(keyConnectorUrl, idToken) {
|
|
137
|
-
const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {
|
|
138
|
-
method: "GET",
|
|
139
|
-
headers: { Authorization: `Bearer ${idToken}` }
|
|
140
|
-
});
|
|
141
|
-
if (!resp.ok) {
|
|
142
|
-
throw new KeyConnectorError(
|
|
143
|
-
`Key connector GET failed: ${resp.status} ${resp.statusText}`,
|
|
144
|
-
resp.status
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
return resp.json();
|
|
148
|
-
}
|
|
149
|
-
async function enrollOidc(keyring, vault, config, idToken, options = {}) {
|
|
150
|
-
const claims = parseIdTokenClaims(idToken);
|
|
151
|
-
if (isIdTokenExpired(idToken)) {
|
|
152
|
-
throw new OidcTokenError("ID token is expired. Complete a fresh OIDC flow before enrolling.");
|
|
153
|
-
}
|
|
154
|
-
const subtle = globalThis.crypto.subtle;
|
|
155
|
-
const dekMap = {};
|
|
156
|
-
for (const [collName, dek] of keyring.deks) {
|
|
157
|
-
const raw = await subtle.exportKey("raw", dek);
|
|
158
|
-
dekMap[collName] = (0, import_hub.bufferToBase64)(raw);
|
|
159
|
-
}
|
|
160
|
-
const payloadJson = JSON.stringify({
|
|
161
|
-
userId: keyring.userId,
|
|
162
|
-
displayName: keyring.displayName,
|
|
163
|
-
role: keyring.role,
|
|
164
|
-
permissions: keyring.permissions,
|
|
165
|
-
deks: dekMap,
|
|
166
|
-
salt: (0, import_hub.bufferToBase64)(keyring.salt)
|
|
167
|
-
});
|
|
168
|
-
const payloadBytes = new TextEncoder().encode(payloadJson);
|
|
169
|
-
const deviceSecret = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
170
|
-
const deviceKeyId = (0, import_hub.bufferToBase64)(globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
171
|
-
saveDeviceSecret(claims.sub, deviceSecret, options.storage);
|
|
172
|
-
const padLen = (16 - payloadBytes.length % 16) % 16;
|
|
173
|
-
const padded = new Uint8Array(payloadBytes.length + padLen);
|
|
174
|
-
padded.set(payloadBytes);
|
|
175
|
-
const deviceHalf = await deriveDeviceHalf(deviceSecret, claims.sub, vault, padded.length);
|
|
176
|
-
const serverHalf = xorBytes(padded, deviceHalf);
|
|
177
|
-
const tokenKey = await deriveKeyFromIdToken(idToken);
|
|
178
|
-
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
179
|
-
const encryptedServerHalf = await subtle.encrypt(
|
|
180
|
-
{ name: "AES-GCM", iv },
|
|
181
|
-
tokenKey,
|
|
182
|
-
serverHalf
|
|
183
|
-
);
|
|
184
|
-
await putServerHalf(
|
|
185
|
-
config.keyConnectorUrl,
|
|
186
|
-
idToken,
|
|
187
|
-
(0, import_hub.bufferToBase64)(encryptedServerHalf),
|
|
188
|
-
(0, import_hub.bufferToBase64)(iv)
|
|
189
|
-
);
|
|
190
|
-
return {
|
|
191
|
-
_noydb_oidc: 1,
|
|
192
|
-
providerName: config.name,
|
|
193
|
-
sub: claims.sub,
|
|
194
|
-
vault,
|
|
195
|
-
enrolledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
196
|
-
deviceKeyId,
|
|
197
|
-
enrollmentCount: 1
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
async function unlockOidc(enrollment, config, idToken, options = {}) {
|
|
201
|
-
if (isIdTokenExpired(idToken)) {
|
|
202
|
-
throw new OidcTokenError("ID token is expired. Complete a fresh OIDC flow before unlocking.");
|
|
203
|
-
}
|
|
204
|
-
const { sub, vault } = enrollment;
|
|
205
|
-
const deviceSecret = getDeviceSecret(sub, options.storage);
|
|
206
|
-
if (!deviceSecret) {
|
|
207
|
-
throw new OidcDeviceSecretNotFoundError(sub);
|
|
208
|
-
}
|
|
209
|
-
const { encryptedServerHalf, iv: ivBase64 } = await getServerHalf(config.keyConnectorUrl, idToken);
|
|
210
|
-
const tokenKey = await deriveKeyFromIdToken(idToken);
|
|
211
|
-
const serverHalf = new Uint8Array(
|
|
212
|
-
await globalThis.crypto.subtle.decrypt(
|
|
213
|
-
{ name: "AES-GCM", iv: (0, import_hub.base64ToBuffer)(ivBase64) },
|
|
214
|
-
tokenKey,
|
|
215
|
-
(0, import_hub.base64ToBuffer)(encryptedServerHalf)
|
|
216
|
-
)
|
|
217
|
-
);
|
|
218
|
-
const deviceHalf = await deriveDeviceHalf(deviceSecret, sub, vault, serverHalf.length);
|
|
219
|
-
const padded = xorBytes(serverHalf, deviceHalf);
|
|
220
|
-
let payloadEnd = padded.length;
|
|
221
|
-
while (payloadEnd > 0 && padded[payloadEnd - 1] === 0) payloadEnd--;
|
|
222
|
-
const payloadJson = new TextDecoder().decode(padded.subarray(0, payloadEnd));
|
|
223
|
-
const parsed = JSON.parse(payloadJson);
|
|
224
|
-
const subtle = globalThis.crypto.subtle;
|
|
225
|
-
const deks = /* @__PURE__ */ new Map();
|
|
226
|
-
for (const [collName, rawBase64] of Object.entries(parsed.deks)) {
|
|
227
|
-
const dek = await subtle.importKey(
|
|
228
|
-
"raw",
|
|
229
|
-
(0, import_hub.base64ToBuffer)(rawBase64),
|
|
230
|
-
{ name: "AES-GCM", length: 256 },
|
|
231
|
-
true,
|
|
232
|
-
["encrypt", "decrypt"]
|
|
233
|
-
);
|
|
234
|
-
deks.set(collName, dek);
|
|
235
|
-
}
|
|
236
|
-
return {
|
|
237
|
-
userId: parsed.userId,
|
|
238
|
-
displayName: parsed.displayName,
|
|
239
|
-
role: parsed.role,
|
|
240
|
-
permissions: parsed.permissions,
|
|
241
|
-
deks,
|
|
242
|
-
kek: null,
|
|
243
|
-
salt: (0, import_hub.base64ToBuffer)(parsed.salt),
|
|
244
|
-
authenticators: []
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
var knownProviders = {
|
|
248
|
-
line: (clientId, keyConnectorUrl) => ({
|
|
249
|
-
name: "LINE",
|
|
250
|
-
issuer: "https://access.line.me",
|
|
251
|
-
clientId,
|
|
252
|
-
scopes: ["openid", "profile", "email"],
|
|
253
|
-
authorizationEndpoint: "https://access.line.me/oauth2/v2.1/authorize",
|
|
254
|
-
tokenEndpoint: "https://api.line.me/oauth2/v2.1/token",
|
|
255
|
-
keyConnectorUrl
|
|
256
|
-
}),
|
|
257
|
-
google: (clientId, keyConnectorUrl) => ({
|
|
258
|
-
name: "Google",
|
|
259
|
-
issuer: "https://accounts.google.com",
|
|
260
|
-
clientId,
|
|
261
|
-
scopes: ["openid", "email"],
|
|
262
|
-
keyConnectorUrl
|
|
263
|
-
}),
|
|
264
|
-
apple: (clientId, keyConnectorUrl) => ({
|
|
265
|
-
name: "Apple",
|
|
266
|
-
issuer: "https://appleid.apple.com",
|
|
267
|
-
clientId,
|
|
268
|
-
scopes: ["openid", "email"],
|
|
269
|
-
keyConnectorUrl
|
|
270
|
-
})
|
|
271
|
-
};
|
|
272
|
-
async function deriveKeyFromIdToken(idToken) {
|
|
273
|
-
const subtle = globalThis.crypto.subtle;
|
|
274
|
-
const ikm = await subtle.importKey(
|
|
275
|
-
"raw",
|
|
276
|
-
new TextEncoder().encode(idToken),
|
|
277
|
-
"HKDF",
|
|
278
|
-
false,
|
|
279
|
-
["deriveKey"]
|
|
280
|
-
);
|
|
281
|
-
return subtle.deriveKey(
|
|
282
|
-
{
|
|
283
|
-
name: "HKDF",
|
|
284
|
-
hash: "SHA-256",
|
|
285
|
-
salt: new TextEncoder().encode("noydb-oidc-transport-encrypt-v1"),
|
|
286
|
-
info: new TextEncoder().encode("key-connector-put")
|
|
287
|
-
},
|
|
288
|
-
ikm,
|
|
289
|
-
{ name: "AES-GCM", length: 256 },
|
|
290
|
-
false,
|
|
291
|
-
["encrypt", "decrypt"]
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
295
|
-
0 && (module.exports = {
|
|
296
|
-
KeyConnectorError,
|
|
297
|
-
OidcDeviceSecretNotFoundError,
|
|
298
|
-
OidcTokenError,
|
|
299
|
-
ValidationError,
|
|
300
|
-
enrollOidc,
|
|
301
|
-
isIdTokenExpired,
|
|
302
|
-
knownProviders,
|
|
303
|
-
parseIdTokenClaims,
|
|
304
|
-
unlockOidc
|
|
305
|
-
});
|
|
306
|
-
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @noy-db/on-oidc —\n *\n * OAuth/OIDC bridge for noy-db with split-key key connector.\n *\n * This package enables federated login (LINE, Google, Apple, Microsoft,\n * Okta, Auth0, Keycloak, any OIDC-compliant provider) without the server\n * ever seeing plaintext or the unwrapped KEK.\n *\n * Split-key model (Bitwarden-style key connector)\n * ────────────────────────────────────────────────\n * The KEK is XOR-split into two equal-length key halves:\n *\n * serverHalf ⊕ deviceHalf = KEK\n *\n * At enrollment time:\n * 1. The full KEK is available (user is authenticated via passphrase).\n * 2. `deviceHalf` is derived: HKDF(deviceSecret, \"noydb-oidc-device-v1\", sub)\n * where `deviceSecret` is a per-device random value stored in IndexedDB\n * or localStorage (never transmitted).\n * 3. `serverHalf = KEK ⊕ deviceHalf`.\n * 4. `serverHalf` is encrypted with the OIDC access token and sent to the\n * key-connector server endpoint. The server stores it indexed by `sub`\n * (the OIDC subject claim).\n * 5. The server NEVER sees the KEK — only serverHalf, encrypted.\n *\n * At unlock time:\n * 1. User completes OIDC flow → gets an ID token with `sub` claim.\n * 2. Client presents the ID token to the key-connector server.\n * 3. Server verifies the ID token, decrypts and returns `serverHalf`.\n * 4. Client derives `deviceHalf` (same HKDF, same deviceSecret).\n * 5. Client reconstructs `KEK = serverHalf ⊕ deviceHalf`.\n * 6. Client derives DEKs from the keyring file using the reconstructed KEK.\n *\n * Security properties:\n * - Compromise of the OIDC provider (stolen tokens): attacker has the\n * ID token → can get `serverHalf`. But without `deviceHalf` (which is\n * device-local, never transmitted), they cannot reconstruct the KEK.\n * - Compromise of the key-connector server: attacker gets all `serverHalf`\n * values. Still cannot reconstruct KEKs without the per-device secrets.\n * - Phishing / stolen device: attacker has `deviceHalf` (from the device)\n * but needs a valid OIDC token to get `serverHalf`.\n * - Full compromise (OIDC + key-connector + device): KEK is reconstructed.\n * This is the threat model boundary — NOYDB does not defend against an\n * attacker who controls all three.\n *\n * Key-connector server contract\n * ──────────────────────────────\n * This package handles the CLIENT side only. The server must:\n * 1. Expose a `PUT /kek-fragment` endpoint that accepts:\n * `{ idToken, encryptedServerHalf, iv }` and stores the decrypted\n * serverHalf indexed by the `sub` from the verified ID token.\n * 2. Expose a `GET /kek-fragment` endpoint that accepts a Bearer ID token,\n * verifies it, and returns the encrypted `{ serverHalf, iv }` for that `sub`.\n * 3. Rotate the encryption key used for serverHalf periodically, with\n * re-encryption at login time (beyond NOYDB's scope).\n *\n * Provider configuration\n * ──────────────────────\n * The `OidcProviderConfig` type describes the OIDC provider and the\n * key-connector endpoint URL. See `knownProviders` for pre-built configs\n * for common providers (LINE, Google, Apple).\n */\n\nimport { bufferToBase64, base64ToBuffer } from '@noy-db/hub'\nimport { ValidationError } from '@noy-db/hub'\nimport type { UnlockedKeyring, Role } from '@noy-db/hub'\n\nexport { ValidationError } from '@noy-db/hub'\n\n// ─── Error types ──────────────────────────────────────────────────────\n\n/**\n * Thrown when an OIDC ID token cannot be parsed, is expired, or fails\n * signature verification.\n *\n * This package does NOT perform full server-side verification — that is the\n * responsibility of the key-connector server. `OidcTokenError` is thrown\n * client-side when the token is structurally invalid (missing `sub`, expired\n * `exp`, wrong issuer) before a network call is made.\n */\nexport class OidcTokenError extends Error {\n readonly code = 'OIDC_TOKEN_ERROR'\n constructor(message: string) {\n super(message)\n this.name = 'OidcTokenError'\n }\n}\n\n/**\n * Thrown when the key-connector server returns an error response.\n *\n * The `status` field carries the HTTP status code when available — use it\n * to distinguish 401 Unauthorized (bad token, re-authenticate) from 404\n * (no key fragment stored for this sub, must re-enroll) and 5xx\n * (server-side error, retry).\n */\nexport class KeyConnectorError extends Error {\n readonly code = 'KEY_CONNECTOR_ERROR'\n /** HTTP status code from the key-connector server, if available. */\n readonly status: number | undefined\n constructor(message: string, status?: number) {\n super(message)\n this.name = 'KeyConnectorError'\n this.status = status\n }\n}\n\n/**\n * Thrown by `unlockOidc()` when the per-device secret for the OIDC subject\n * cannot be found in browser storage.\n *\n * The device secret is generated once at `enrollOidc()` time and stored in\n * IndexedDB/localStorage on the enrolling device — it is never transmitted.\n * This error means the device secret was cleared (storage wipe, private\n * browsing session, new device) and the user must re-enroll using their\n * passphrase.\n */\nexport class OidcDeviceSecretNotFoundError extends Error {\n readonly code = 'OIDC_DEVICE_SECRET_NOT_FOUND'\n constructor(sub: string) {\n super(\n `No device secret found for OIDC subject \"${sub}\". ` +\n 'The device secret is generated at enrollment and stored in browser storage. ' +\n 'Re-enroll on this device to restore OIDC unlock.',\n )\n this.name = 'OidcDeviceSecretNotFoundError'\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────\n\n/** OIDC provider configuration. */\nexport interface OidcProviderConfig {\n /** Provider name for display and debugging (e.g. 'LINE', 'Google'). */\n readonly name: string\n /** OIDC issuer URL. Used to discover the JWKS endpoint for token verification. */\n readonly issuer: string\n /** OAuth2 client ID for this application. */\n readonly clientId: string\n /** OAuth2 scopes to request. Default: ['openid', 'email']. */\n readonly scopes?: string[]\n /** Authorization endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly authorizationEndpoint?: string\n /** Token endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly tokenEndpoint?: string\n /**\n * Base URL of the noy-db key-connector server.\n * Must expose `PUT /kek-fragment` and `GET /kek-fragment`.\n */\n readonly keyConnectorUrl: string\n}\n\n/** The enrollment record persisted per-device per-OIDC-provider. */\nexport interface OidcEnrollment {\n readonly _noydb_oidc: 1\n /** OIDC provider name from `OidcProviderConfig.name`. */\n readonly providerName: string\n /** OIDC subject claim (`sub`) of the enrolled user. */\n readonly sub: string\n /** The vault this enrollment unlocks. */\n readonly vault: string\n /** ISO timestamp of enrollment. */\n readonly enrolledAt: string\n /**\n * Device-specific identifier (not the secret itself — the secret lives\n * in IndexedDB/localStorage). Used to look up the device secret at unlock.\n */\n readonly deviceKeyId: string\n /** Number of active enrollments for this sub (for key rotation auditing). */\n readonly enrollmentCount: number\n}\n\n/** Minimal JWT claims we need from an OIDC ID token. */\nexport interface OidcClaims {\n readonly sub: string\n readonly iss: string\n readonly aud: string | string[]\n readonly iat: number\n readonly exp: number\n readonly email?: string\n readonly name?: string\n}\n\n/** Options for `enrollOidc()`. */\nexport interface EnrollOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n/** Options for `unlockOidc()`. */\nexport interface UnlockOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n// ─── Device secret management ─────────────────────────────────────────\n\nconst DEVICE_SECRET_PREFIX = 'noydb:oidc:device-secret:'\n\nfunction getDeviceSecret(sub: string, storage?: Storage): Uint8Array | null {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) return null\n const raw = store.getItem(DEVICE_SECRET_PREFIX + sub)\n if (!raw) return null\n return base64ToBuffer(raw)\n}\n\nfunction saveDeviceSecret(sub: string, secret: Uint8Array, storage?: Storage): void {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) throw new ValidationError('localStorage is not available in this environment')\n store.setItem(DEVICE_SECRET_PREFIX + sub, bufferToBase64(secret))\n}\n\n// ─── HKDF device half derivation ──────────────────────────────────────\n\nasync function deriveDeviceHalf(\n deviceSecret: Uint8Array,\n sub: string,\n vault: string,\n keyLengthBytes: number,\n): Promise<Uint8Array> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey('raw', deviceSecret as Uint8Array<ArrayBuffer>, 'HKDF', false, ['deriveBits'])\n const bits = await subtle.deriveBits(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode(sub),\n info: new TextEncoder().encode(`noydb-oidc-device-v1:${vault}`),\n },\n ikm,\n keyLengthBytes * 8,\n )\n return new Uint8Array(bits)\n}\n\n// ─── XOR key operations ────────────────────────────────────────────────\n\nfunction xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {\n if (a.length !== b.length) {\n throw new Error(`xorBytes: length mismatch (${a.length} vs ${b.length})`)\n }\n const out = new Uint8Array(a.length)\n for (let i = 0; i < a.length; i++) out[i] = a[i]! ^ b[i]!\n return out\n}\n\n// ─── ID token parsing (client-side, no verification) ──────────────────\n\n/**\n * Extract claims from a JWT ID token without cryptographic verification.\n * Verification is the key-connector server's responsibility.\n *\n * We do check the `exp` claim client-side as a fast path to avoid\n * unnecessary network calls with obviously expired tokens.\n */\nexport function parseIdTokenClaims(idToken: string): OidcClaims {\n const parts = idToken.split('.')\n if (parts.length !== 3) {\n throw new OidcTokenError('Invalid ID token format: expected 3 JWT segments')\n }\n const payloadPart = parts[1]!\n // Base64url decode (add padding as needed)\n const padded = payloadPart + '='.repeat((4 - payloadPart.length % 4) % 4)\n const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))\n const claims = JSON.parse(json) as OidcClaims\n if (!claims.sub) throw new OidcTokenError('ID token missing \"sub\" claim')\n if (!claims.exp) throw new OidcTokenError('ID token missing \"exp\" claim')\n return claims\n}\n\n/**\n * Check if an ID token is expired based on its `exp` claim.\n * Does NOT perform cryptographic signature verification.\n */\nexport function isIdTokenExpired(idToken: string): boolean {\n try {\n const claims = parseIdTokenClaims(idToken)\n return Date.now() / 1000 > claims.exp\n } catch {\n return true\n }\n}\n\n// ─── Key-connector HTTP helpers ────────────────────────────────────────\n\nasync function putServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n serverHalfBase64: string,\n ivBase64: string,\n): Promise<void> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n body: JSON.stringify({ encryptedServerHalf: serverHalfBase64, iv: ivBase64 }),\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector PUT failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n}\n\nasync function getServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n): Promise<{ encryptedServerHalf: string; iv: string }> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${idToken}` },\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector GET failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n return resp.json() as Promise<{ encryptedServerHalf: string; iv: string }>\n}\n\n// ─── Enrollment ────────────────────────────────────────────────────────\n\n/**\n * Enroll a device for OIDC unlock (client-side portion).\n *\n * Requires an unlocked keyring (user is already authenticated) and a valid\n * OIDC ID token. Derives the device half, XOR-splits the serialized keyring\n * payload, encrypts the server half with the ID token, and sends it to the\n * key-connector server.\n *\n * Returns an `OidcEnrollment` that should be stored (e.g. in localStorage\n * or a noy-db collection) so `unlockOidc()` can look up the `sub` and\n * `deviceKeyId` later.\n *\n * @param keyring - The currently unlocked keyring.\n * @param vault - The vault to enroll for.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function enrollOidc(\n keyring: UnlockedKeyring,\n vault: string,\n config: OidcProviderConfig,\n idToken: string,\n options: EnrollOidcOptions = {},\n): Promise<OidcEnrollment> {\n const claims = parseIdTokenClaims(idToken)\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before enrolling.')\n }\n\n const subtle = globalThis.crypto.subtle\n\n // Serialize the keyring payload (same as session.ts)\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n const payloadJson = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n const payloadBytes = new TextEncoder().encode(payloadJson)\n\n // Generate a device secret and derive the device half\n const deviceSecret = globalThis.crypto.getRandomValues(new Uint8Array(32))\n const deviceKeyId = bufferToBase64(globalThis.crypto.getRandomValues(new Uint8Array(16)))\n saveDeviceSecret(claims.sub, deviceSecret, options.storage)\n\n // Pad payload to the next 16-byte boundary to avoid leaking length\n const padLen = (16 - (payloadBytes.length % 16)) % 16\n const padded = new Uint8Array(payloadBytes.length + padLen)\n padded.set(payloadBytes)\n\n const deviceHalf = await deriveDeviceHalf(deviceSecret, claims.sub, vault, padded.length)\n const serverHalf = xorBytes(padded, deviceHalf)\n\n // Encrypt serverHalf with the ID token using HKDF(idToken) as the key\n // The server decrypts this with the same derivation, storing only the plaintext serverHalf\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encryptedServerHalf = await subtle.encrypt(\n { name: 'AES-GCM', iv },\n tokenKey,\n serverHalf as Uint8Array<ArrayBuffer>,\n )\n\n await putServerHalf(\n config.keyConnectorUrl,\n idToken,\n bufferToBase64(encryptedServerHalf),\n bufferToBase64(iv),\n )\n\n return {\n _noydb_oidc: 1,\n providerName: config.name,\n sub: claims.sub,\n vault,\n enrolledAt: new Date().toISOString(),\n deviceKeyId,\n enrollmentCount: 1,\n }\n}\n\n/**\n * Unlock a vault using OIDC (client-side portion).\n *\n * Fetches the server half from the key-connector server, derives the device\n * half from the local device secret, XORs them to reconstruct the keyring\n * payload, and returns an UnlockedKeyring.\n *\n * @param enrollment - The enrollment record from `enrollOidc()`.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function unlockOidc(\n enrollment: OidcEnrollment,\n config: OidcProviderConfig,\n idToken: string,\n options: UnlockOidcOptions = {},\n): Promise<UnlockedKeyring> {\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before unlocking.')\n }\n\n const { sub, vault } = enrollment\n\n // Load the device secret\n const deviceSecret = getDeviceSecret(sub, options.storage)\n if (!deviceSecret) {\n throw new OidcDeviceSecretNotFoundError(sub)\n }\n\n // Fetch the server half from the key-connector\n const { encryptedServerHalf, iv: ivBase64 } = await getServerHalf(config.keyConnectorUrl, idToken)\n\n // Decrypt the server half using the ID token\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const serverHalf = new Uint8Array(\n await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: base64ToBuffer(ivBase64) },\n tokenKey,\n base64ToBuffer(encryptedServerHalf),\n ),\n )\n\n // Reconstruct the padded payload\n const deviceHalf = await deriveDeviceHalf(deviceSecret, sub, vault, serverHalf.length)\n const padded = xorBytes(serverHalf, deviceHalf)\n\n // Parse the payload (strip padding: find the last non-null byte + 1)\n let payloadEnd = padded.length\n while (payloadEnd > 0 && padded[payloadEnd - 1] === 0) payloadEnd--\n const payloadJson = new TextDecoder().decode(padded.subarray(0, payloadEnd))\n\n const parsed = JSON.parse(payloadJson) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const subtle = globalThis.crypto.subtle\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null,\n salt: base64ToBuffer(parsed.salt),\n authenticators: [],\n }\n}\n\n// ─── Known provider configs ────────────────────────────────────────────\n\n/**\n * Pre-built provider configs for common OIDC providers.\n * Pass your client ID and key-connector URL to get a complete config.\n */\nexport const knownProviders = {\n line: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'LINE',\n issuer: 'https://access.line.me',\n clientId,\n scopes: ['openid', 'profile', 'email'],\n authorizationEndpoint: 'https://access.line.me/oauth2/v2.1/authorize',\n tokenEndpoint: 'https://api.line.me/oauth2/v2.1/token',\n keyConnectorUrl,\n }),\n google: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Google',\n issuer: 'https://accounts.google.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n apple: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Apple',\n issuer: 'https://appleid.apple.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n}\n\n// ─── Internal ─────────────────────────────────────────────────────────\n\n/**\n * Derive a transient AES-256-GCM key from an OIDC ID token.\n * Used to encrypt the server half before transmitting to the key-connector.\n * The server derives the same key to decrypt and store the plaintext serverHalf.\n */\nasync function deriveKeyFromIdToken(idToken: string): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(idToken),\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode('noydb-oidc-transport-encrypt-v1'),\n info: new TextEncoder().encode('key-connector-put'),\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n"],"mappings":"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","names":["import_hub"]}
|
package/dist/index.d.cts
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
import { UnlockedKeyring } from '@noy-db/hub';
|
|
2
|
-
export { ValidationError } from '@noy-db/hub';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* @noy-db/on-oidc —
|
|
6
|
-
*
|
|
7
|
-
* OAuth/OIDC bridge for noy-db with split-key key connector.
|
|
8
|
-
*
|
|
9
|
-
* This package enables federated login (LINE, Google, Apple, Microsoft,
|
|
10
|
-
* Okta, Auth0, Keycloak, any OIDC-compliant provider) without the server
|
|
11
|
-
* ever seeing plaintext or the unwrapped KEK.
|
|
12
|
-
*
|
|
13
|
-
* Split-key model (Bitwarden-style key connector)
|
|
14
|
-
* ────────────────────────────────────────────────
|
|
15
|
-
* The KEK is XOR-split into two equal-length key halves:
|
|
16
|
-
*
|
|
17
|
-
* serverHalf ⊕ deviceHalf = KEK
|
|
18
|
-
*
|
|
19
|
-
* At enrollment time:
|
|
20
|
-
* 1. The full KEK is available (user is authenticated via passphrase).
|
|
21
|
-
* 2. `deviceHalf` is derived: HKDF(deviceSecret, "noydb-oidc-device-v1", sub)
|
|
22
|
-
* where `deviceSecret` is a per-device random value stored in IndexedDB
|
|
23
|
-
* or localStorage (never transmitted).
|
|
24
|
-
* 3. `serverHalf = KEK ⊕ deviceHalf`.
|
|
25
|
-
* 4. `serverHalf` is encrypted with the OIDC access token and sent to the
|
|
26
|
-
* key-connector server endpoint. The server stores it indexed by `sub`
|
|
27
|
-
* (the OIDC subject claim).
|
|
28
|
-
* 5. The server NEVER sees the KEK — only serverHalf, encrypted.
|
|
29
|
-
*
|
|
30
|
-
* At unlock time:
|
|
31
|
-
* 1. User completes OIDC flow → gets an ID token with `sub` claim.
|
|
32
|
-
* 2. Client presents the ID token to the key-connector server.
|
|
33
|
-
* 3. Server verifies the ID token, decrypts and returns `serverHalf`.
|
|
34
|
-
* 4. Client derives `deviceHalf` (same HKDF, same deviceSecret).
|
|
35
|
-
* 5. Client reconstructs `KEK = serverHalf ⊕ deviceHalf`.
|
|
36
|
-
* 6. Client derives DEKs from the keyring file using the reconstructed KEK.
|
|
37
|
-
*
|
|
38
|
-
* Security properties:
|
|
39
|
-
* - Compromise of the OIDC provider (stolen tokens): attacker has the
|
|
40
|
-
* ID token → can get `serverHalf`. But without `deviceHalf` (which is
|
|
41
|
-
* device-local, never transmitted), they cannot reconstruct the KEK.
|
|
42
|
-
* - Compromise of the key-connector server: attacker gets all `serverHalf`
|
|
43
|
-
* values. Still cannot reconstruct KEKs without the per-device secrets.
|
|
44
|
-
* - Phishing / stolen device: attacker has `deviceHalf` (from the device)
|
|
45
|
-
* but needs a valid OIDC token to get `serverHalf`.
|
|
46
|
-
* - Full compromise (OIDC + key-connector + device): KEK is reconstructed.
|
|
47
|
-
* This is the threat model boundary — NOYDB does not defend against an
|
|
48
|
-
* attacker who controls all three.
|
|
49
|
-
*
|
|
50
|
-
* Key-connector server contract
|
|
51
|
-
* ──────────────────────────────
|
|
52
|
-
* This package handles the CLIENT side only. The server must:
|
|
53
|
-
* 1. Expose a `PUT /kek-fragment` endpoint that accepts:
|
|
54
|
-
* `{ idToken, encryptedServerHalf, iv }` and stores the decrypted
|
|
55
|
-
* serverHalf indexed by the `sub` from the verified ID token.
|
|
56
|
-
* 2. Expose a `GET /kek-fragment` endpoint that accepts a Bearer ID token,
|
|
57
|
-
* verifies it, and returns the encrypted `{ serverHalf, iv }` for that `sub`.
|
|
58
|
-
* 3. Rotate the encryption key used for serverHalf periodically, with
|
|
59
|
-
* re-encryption at login time (beyond NOYDB's scope).
|
|
60
|
-
*
|
|
61
|
-
* Provider configuration
|
|
62
|
-
* ──────────────────────
|
|
63
|
-
* The `OidcProviderConfig` type describes the OIDC provider and the
|
|
64
|
-
* key-connector endpoint URL. See `knownProviders` for pre-built configs
|
|
65
|
-
* for common providers (LINE, Google, Apple).
|
|
66
|
-
*/
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Thrown when an OIDC ID token cannot be parsed, is expired, or fails
|
|
70
|
-
* signature verification.
|
|
71
|
-
*
|
|
72
|
-
* This package does NOT perform full server-side verification — that is the
|
|
73
|
-
* responsibility of the key-connector server. `OidcTokenError` is thrown
|
|
74
|
-
* client-side when the token is structurally invalid (missing `sub`, expired
|
|
75
|
-
* `exp`, wrong issuer) before a network call is made.
|
|
76
|
-
*/
|
|
77
|
-
declare class OidcTokenError extends Error {
|
|
78
|
-
readonly code = "OIDC_TOKEN_ERROR";
|
|
79
|
-
constructor(message: string);
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Thrown when the key-connector server returns an error response.
|
|
83
|
-
*
|
|
84
|
-
* The `status` field carries the HTTP status code when available — use it
|
|
85
|
-
* to distinguish 401 Unauthorized (bad token, re-authenticate) from 404
|
|
86
|
-
* (no key fragment stored for this sub, must re-enroll) and 5xx
|
|
87
|
-
* (server-side error, retry).
|
|
88
|
-
*/
|
|
89
|
-
declare class KeyConnectorError extends Error {
|
|
90
|
-
readonly code = "KEY_CONNECTOR_ERROR";
|
|
91
|
-
/** HTTP status code from the key-connector server, if available. */
|
|
92
|
-
readonly status: number | undefined;
|
|
93
|
-
constructor(message: string, status?: number);
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Thrown by `unlockOidc()` when the per-device secret for the OIDC subject
|
|
97
|
-
* cannot be found in browser storage.
|
|
98
|
-
*
|
|
99
|
-
* The device secret is generated once at `enrollOidc()` time and stored in
|
|
100
|
-
* IndexedDB/localStorage on the enrolling device — it is never transmitted.
|
|
101
|
-
* This error means the device secret was cleared (storage wipe, private
|
|
102
|
-
* browsing session, new device) and the user must re-enroll using their
|
|
103
|
-
* passphrase.
|
|
104
|
-
*/
|
|
105
|
-
declare class OidcDeviceSecretNotFoundError extends Error {
|
|
106
|
-
readonly code = "OIDC_DEVICE_SECRET_NOT_FOUND";
|
|
107
|
-
constructor(sub: string);
|
|
108
|
-
}
|
|
109
|
-
/** OIDC provider configuration. */
|
|
110
|
-
interface OidcProviderConfig {
|
|
111
|
-
/** Provider name for display and debugging (e.g. 'LINE', 'Google'). */
|
|
112
|
-
readonly name: string;
|
|
113
|
-
/** OIDC issuer URL. Used to discover the JWKS endpoint for token verification. */
|
|
114
|
-
readonly issuer: string;
|
|
115
|
-
/** OAuth2 client ID for this application. */
|
|
116
|
-
readonly clientId: string;
|
|
117
|
-
/** OAuth2 scopes to request. Default: ['openid', 'email']. */
|
|
118
|
-
readonly scopes?: string[];
|
|
119
|
-
/** Authorization endpoint URL. If omitted, discovered from issuer's .well-known. */
|
|
120
|
-
readonly authorizationEndpoint?: string;
|
|
121
|
-
/** Token endpoint URL. If omitted, discovered from issuer's .well-known. */
|
|
122
|
-
readonly tokenEndpoint?: string;
|
|
123
|
-
/**
|
|
124
|
-
* Base URL of the noy-db key-connector server.
|
|
125
|
-
* Must expose `PUT /kek-fragment` and `GET /kek-fragment`.
|
|
126
|
-
*/
|
|
127
|
-
readonly keyConnectorUrl: string;
|
|
128
|
-
}
|
|
129
|
-
/** The enrollment record persisted per-device per-OIDC-provider. */
|
|
130
|
-
interface OidcEnrollment {
|
|
131
|
-
readonly _noydb_oidc: 1;
|
|
132
|
-
/** OIDC provider name from `OidcProviderConfig.name`. */
|
|
133
|
-
readonly providerName: string;
|
|
134
|
-
/** OIDC subject claim (`sub`) of the enrolled user. */
|
|
135
|
-
readonly sub: string;
|
|
136
|
-
/** The vault this enrollment unlocks. */
|
|
137
|
-
readonly vault: string;
|
|
138
|
-
/** ISO timestamp of enrollment. */
|
|
139
|
-
readonly enrolledAt: string;
|
|
140
|
-
/**
|
|
141
|
-
* Device-specific identifier (not the secret itself — the secret lives
|
|
142
|
-
* in IndexedDB/localStorage). Used to look up the device secret at unlock.
|
|
143
|
-
*/
|
|
144
|
-
readonly deviceKeyId: string;
|
|
145
|
-
/** Number of active enrollments for this sub (for key rotation auditing). */
|
|
146
|
-
readonly enrollmentCount: number;
|
|
147
|
-
}
|
|
148
|
-
/** Minimal JWT claims we need from an OIDC ID token. */
|
|
149
|
-
interface OidcClaims {
|
|
150
|
-
readonly sub: string;
|
|
151
|
-
readonly iss: string;
|
|
152
|
-
readonly aud: string | string[];
|
|
153
|
-
readonly iat: number;
|
|
154
|
-
readonly exp: number;
|
|
155
|
-
readonly email?: string;
|
|
156
|
-
readonly name?: string;
|
|
157
|
-
}
|
|
158
|
-
/** Options for `enrollOidc()`. */
|
|
159
|
-
interface EnrollOidcOptions {
|
|
160
|
-
/** Optional storage override for the device secret. Default: localStorage. */
|
|
161
|
-
storage?: Storage;
|
|
162
|
-
}
|
|
163
|
-
/** Options for `unlockOidc()`. */
|
|
164
|
-
interface UnlockOidcOptions {
|
|
165
|
-
/** Optional storage override for the device secret. Default: localStorage. */
|
|
166
|
-
storage?: Storage;
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Extract claims from a JWT ID token without cryptographic verification.
|
|
170
|
-
* Verification is the key-connector server's responsibility.
|
|
171
|
-
*
|
|
172
|
-
* We do check the `exp` claim client-side as a fast path to avoid
|
|
173
|
-
* unnecessary network calls with obviously expired tokens.
|
|
174
|
-
*/
|
|
175
|
-
declare function parseIdTokenClaims(idToken: string): OidcClaims;
|
|
176
|
-
/**
|
|
177
|
-
* Check if an ID token is expired based on its `exp` claim.
|
|
178
|
-
* Does NOT perform cryptographic signature verification.
|
|
179
|
-
*/
|
|
180
|
-
declare function isIdTokenExpired(idToken: string): boolean;
|
|
181
|
-
/**
|
|
182
|
-
* Enroll a device for OIDC unlock (client-side portion).
|
|
183
|
-
*
|
|
184
|
-
* Requires an unlocked keyring (user is already authenticated) and a valid
|
|
185
|
-
* OIDC ID token. Derives the device half, XOR-splits the serialized keyring
|
|
186
|
-
* payload, encrypts the server half with the ID token, and sends it to the
|
|
187
|
-
* key-connector server.
|
|
188
|
-
*
|
|
189
|
-
* Returns an `OidcEnrollment` that should be stored (e.g. in localStorage
|
|
190
|
-
* or a noy-db collection) so `unlockOidc()` can look up the `sub` and
|
|
191
|
-
* `deviceKeyId` later.
|
|
192
|
-
*
|
|
193
|
-
* @param keyring - The currently unlocked keyring.
|
|
194
|
-
* @param vault - The vault to enroll for.
|
|
195
|
-
* @param config - OIDC provider + key-connector configuration.
|
|
196
|
-
* @param idToken - A valid OIDC ID token from the completed OIDC flow.
|
|
197
|
-
* @param options - Optional storage override.
|
|
198
|
-
*/
|
|
199
|
-
declare function enrollOidc(keyring: UnlockedKeyring, vault: string, config: OidcProviderConfig, idToken: string, options?: EnrollOidcOptions): Promise<OidcEnrollment>;
|
|
200
|
-
/**
|
|
201
|
-
* Unlock a vault using OIDC (client-side portion).
|
|
202
|
-
*
|
|
203
|
-
* Fetches the server half from the key-connector server, derives the device
|
|
204
|
-
* half from the local device secret, XORs them to reconstruct the keyring
|
|
205
|
-
* payload, and returns an UnlockedKeyring.
|
|
206
|
-
*
|
|
207
|
-
* @param enrollment - The enrollment record from `enrollOidc()`.
|
|
208
|
-
* @param config - OIDC provider + key-connector configuration.
|
|
209
|
-
* @param idToken - A valid OIDC ID token from the completed OIDC flow.
|
|
210
|
-
* @param options - Optional storage override.
|
|
211
|
-
*/
|
|
212
|
-
declare function unlockOidc(enrollment: OidcEnrollment, config: OidcProviderConfig, idToken: string, options?: UnlockOidcOptions): Promise<UnlockedKeyring>;
|
|
213
|
-
/**
|
|
214
|
-
* Pre-built provider configs for common OIDC providers.
|
|
215
|
-
* Pass your client ID and key-connector URL to get a complete config.
|
|
216
|
-
*/
|
|
217
|
-
declare const knownProviders: {
|
|
218
|
-
line: (clientId: string, keyConnectorUrl: string) => OidcProviderConfig;
|
|
219
|
-
google: (clientId: string, keyConnectorUrl: string) => OidcProviderConfig;
|
|
220
|
-
apple: (clientId: string, keyConnectorUrl: string) => OidcProviderConfig;
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
export { type EnrollOidcOptions, KeyConnectorError, type OidcClaims, OidcDeviceSecretNotFoundError, type OidcEnrollment, type OidcProviderConfig, OidcTokenError, type UnlockOidcOptions, enrollOidc, isIdTokenExpired, knownProviders, parseIdTokenClaims, unlockOidc };
|