@dreamforgestudiollc/emet 0.2.4 → 0.2.6
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/.env.example +53 -0
- package/CHANGELOG.md +67 -3
- package/CHARTER.md +19 -7
- package/README.md +106 -21
- package/docs/AUTHORSHIP.md +3 -3
- package/docs/BOTS.md +4 -0
- package/docs/COVERAGE.md +2 -2
- package/docs/DEPLOY.md +9 -4
- package/docs/GUARDRAILS-AND-MONORAILS.md +2 -1
- package/docs/GUIDED-SETUP.md +8 -3
- package/docs/PERMISSIONS.md +6 -3
- package/docs/ROADMAP.md +25 -22
- package/docs/SETUP.md +8 -2
- package/docs/SUBKEYS.md +59 -0
- package/gems/artifact-index.json +12 -0
- package/gems/bot-team-backup/floor.json +15 -0
- package/gems/bot-team-backup/gem.json +43 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
- package/gems/fieldnote/floor.json +14 -0
- package/gems/fieldnote/gem.json +70 -0
- package/gems/fieldnote/runes/fieldnote.js +4 -0
- package/gems/lean-six-sigma/CHANGELOG.md +10 -0
- package/gems/lean-six-sigma/README.md +92 -0
- package/gems/lean-six-sigma/floor.json +31 -0
- package/gems/lean-six-sigma/gem.json +1296 -0
- package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
- package/gems/lean-six-sigma/guides/BELTS.md +52 -0
- package/gems/lean-six-sigma/guides/DMADV.md +56 -0
- package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
- package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
- package/gems/lean-six-sigma/guides/LEAN.md +97 -0
- package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
- package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
- package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
- package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
- package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
- package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
- package/gems/neurendipity/floor.json +65 -0
- package/gems/neurendipity/gem.json +42 -0
- package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
- package/{guides → gems/neurendipity/guides}/NEURODIVERGENT_SUPPORT.md +17 -8
- package/guides/README.md +3 -0
- package/package.json +11 -5
- package/server.json +44 -4
- package/src/aleph.js +4 -4
- package/src/annotations.js +38 -3
- package/src/atlas-role.js +1 -1
- package/src/attribution.js +555 -0
- package/src/conformance.js +2 -2
- package/src/database.js +6 -5
- package/src/dispatch.js +116 -13
- package/src/env.js +54 -9
- package/src/floor.js +82 -41
- package/src/gaps.js +39 -6
- package/src/gem-check.js +1359 -0
- package/src/guides.js +31 -4
- package/src/host-reach.js +23 -0
- package/src/http-remote.js +46 -2
- package/src/index.js +9 -2
- package/src/layers.js +1 -1
- package/src/lessons-to-checks.js +3 -3
- package/src/logger.js +2 -1
- package/src/mongo-connect.js +45 -2
- package/src/oauth-provider.js +2 -2
- package/src/owner-approvals.js +281 -0
- package/src/persons.js +20 -0
- package/src/redact.js +10 -4
- package/src/request-log.js +1 -1
- package/src/rune-dispatch.js +360 -0
- package/src/rune-registry.js +71 -0
- package/src/rune-scope.js +42 -0
- package/src/scopes.js +3 -0
- package/src/session-graph.js +25 -23
- package/src/session.js +9 -9
- package/src/setup.js +297 -30
- package/src/socket.js +545 -0
- package/src/startup-pages.js +23 -6
- package/src/startup-payload.js +9 -6
- package/src/subkey-store.js +357 -0
- package/src/subkeys.js +947 -0
- package/src/tools.js +299 -62
- package/src/transcript-session.js +1 -1
- package/src/validation.js +26 -2
- package/src/version.js +1 -1
- package/templates/BOARD.md +2 -2
- package/templates/BOTHANDOFF.md +2 -2
- package/templates/CHARACTER.md +1 -1
- package/templates/HANDOFF.md +1 -1
- package/templates/IDENTITY.md +1 -1
- package/templates/INDEX.md +19 -19
- package/templates/INSTALL.md +1 -1
- package/templates/PROCEDURE.md +1 -1
- package/templates/ROSTER.md +2 -2
- package/templates/SEED.md +2 -2
- package/templates/STAGING.md +2 -2
- package/templates/STANDARD.md +5 -5
- package/templates/THREAD.md +1 -1
- package/templates/USER.md +4 -1
- package/verification/load-gate/corpus.json +1765 -0
- package/verification/load-gate/load-gate.mjs +1246 -0
package/src/subkeys.js
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - delegated client sub-keys (EMET-0001).
|
|
3
|
+
*
|
|
4
|
+
* Off unless EMET_SUBKEYS=1. One owner login, one server. Each AI client
|
|
5
|
+
* writes under a tag the server verifies against an approved key, not a tag
|
|
6
|
+
* the caller names. Keys, claim nonces and session handles are stored only
|
|
7
|
+
* as peppered HMACs. Plaintext is returned once and never written into a
|
|
8
|
+
* record, a receipt, a log line or session_graph.
|
|
9
|
+
*
|
|
10
|
+
* Records live in the subkeys collection and its history collection
|
|
11
|
+
* (src/subkey-store.js), not in emet/CLIENT_APPROVALS.md. Nothing in this
|
|
12
|
+
* module deletes a record. A later state is written beside the earlier one.
|
|
13
|
+
* Each session open is its own record.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { setting } from './env.js';
|
|
18
|
+
import {
|
|
19
|
+
personIsOwner, actorIsOwner, actorIsCos, cosTag, sessionBaseOf,
|
|
20
|
+
rememberSubkeySession
|
|
21
|
+
} from './attribution.js';
|
|
22
|
+
import { parseReceipts } from './session.js';
|
|
23
|
+
import { storeFor, sweepFlagOffLiveSessions } from './subkey-store.js';
|
|
24
|
+
|
|
25
|
+
const KEY_PREFIX = 'emk_';
|
|
26
|
+
const HANDLE_PREFIX = 'emh_';
|
|
27
|
+
const NONCE_PREFIX = 'emn_';
|
|
28
|
+
const CLAIM_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const HANDLE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
30
|
+
const OPEN_STALE_MS = 5000;
|
|
31
|
+
const EXPIRY_NOTICE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
const DEFAULT_FAIL_MAX = 5;
|
|
33
|
+
const DEFAULT_FAIL_WINDOW_MS = 15 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
export const SUBKEY_TOOLS = Object.freeze([
|
|
36
|
+
'emet_subkey_request', 'emet_subkey_claim', 'emet_subkey_revoke', 'emet_subkey_end_session'
|
|
37
|
+
]);
|
|
38
|
+
/** Tools a verified sub-key session must not call. Settings and access stay on the owner login. */
|
|
39
|
+
const OWNER_ONLY_TOOLS = Object.freeze(new Set([
|
|
40
|
+
'emet_client_approve',
|
|
41
|
+
'emet_subkey_request', 'emet_subkey_claim', 'emet_subkey_revoke', 'emet_subkey_end_session',
|
|
42
|
+
'emet_invite_create', 'emet_member_access_create', 'emet_setup_complete'
|
|
43
|
+
]));
|
|
44
|
+
|
|
45
|
+
const KEY_RE = /^emk_([0-9a-f]{32})_([A-Za-z0-9_-]{43})$/;
|
|
46
|
+
const HANDLE_RE = /^emh_[A-Za-z0-9_-]{43}$/;
|
|
47
|
+
const NONCE_RE = /^emn_[A-Za-z0-9_-]{43}$/;
|
|
48
|
+
|
|
49
|
+
let openTokens = new WeakMap();
|
|
50
|
+
const throttle = createThrottle();
|
|
51
|
+
|
|
52
|
+
function subkeysEnabled() { return setting('SUBKEYS') === '1'; }
|
|
53
|
+
|
|
54
|
+
export function _resetForTests() {
|
|
55
|
+
openTokens = new WeakMap();
|
|
56
|
+
throttle.reset();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function refusal(msg) {
|
|
60
|
+
const e = new Error(msg);
|
|
61
|
+
e.attributionRefusal = true;
|
|
62
|
+
return e;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function b64(bytes) { return crypto.randomBytes(bytes).toString('base64url'); }
|
|
66
|
+
function hexId() { return crypto.randomBytes(16).toString('hex'); }
|
|
67
|
+
|
|
68
|
+
export function splitKeyToken(token) {
|
|
69
|
+
const m = KEY_RE.exec(typeof token === 'string' ? token.trim() : '');
|
|
70
|
+
return m ? { key_id: m[1], secret: m[2] } : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Current pepper, and the previous one while a rotation is in progress. */
|
|
74
|
+
export function pepperState() {
|
|
75
|
+
const cur = setting('SUBKEY_PEPPER');
|
|
76
|
+
if (typeof cur !== 'string' || Buffer.byteLength(cur) < 32) return null;
|
|
77
|
+
const verRaw = Number(setting('SUBKEY_PEPPER_VERSION'));
|
|
78
|
+
const version = Number.isInteger(verRaw) && verRaw > 0 ? verRaw : 1;
|
|
79
|
+
const prev = setting('SUBKEY_PEPPER_PREV');
|
|
80
|
+
const prevVerRaw = Number(setting('SUBKEY_PEPPER_PREV_VERSION'));
|
|
81
|
+
const prevVersion = Number.isInteger(prevVerRaw) && prevVerRaw > 0 ? prevVerRaw : version - 1;
|
|
82
|
+
const previous = typeof prev === 'string' && Buffer.byteLength(prev) >= 32 && prevVersion !== version
|
|
83
|
+
? { version: prevVersion, pepper: prev }
|
|
84
|
+
: null;
|
|
85
|
+
return { current: { version, pepper: cur }, previous };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requirePepper() {
|
|
89
|
+
const p = pepperState();
|
|
90
|
+
if (!p) throw refusal('sub-keys are unavailable: EMET_SUBKEY_PEPPER is missing or shorter than 32 bytes. Nothing was changed.');
|
|
91
|
+
return p;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function macOf(pepper, value) {
|
|
95
|
+
return crypto.createHmac('sha256', pepper).update(String(value)).digest('hex');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Constant-time compare of two hex HMACs. A length mismatch is a miss, not a throw. */
|
|
99
|
+
export function macEqual(a, b) {
|
|
100
|
+
const ab = Buffer.from(String(a));
|
|
101
|
+
const bb = Buffer.from(String(b));
|
|
102
|
+
if (ab.length !== bb.length) {
|
|
103
|
+
crypto.timingSafeEqual(ab, ab);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compare `value` with a stored `{ mac, pepper_version }`. Both the current
|
|
111
|
+
* and previous peppers are hashed so a version check does not skip a compare.
|
|
112
|
+
*/
|
|
113
|
+
export function verifyMac(record, value, peppers) {
|
|
114
|
+
const cur = macOf(peppers.current.pepper, value);
|
|
115
|
+
const prevPepper = peppers.previous ? peppers.previous.pepper : peppers.current.pepper;
|
|
116
|
+
const prev = macOf(prevPepper, value);
|
|
117
|
+
const mac = record && typeof record.mac === 'string' ? record.mac : cur.replace(/[0-9a-f]/g, '0');
|
|
118
|
+
const eqCur = macEqual(cur, mac);
|
|
119
|
+
const eqPrev = macEqual(prev, mac);
|
|
120
|
+
const wantCur = !!(record && record.pepper_version === peppers.current.version);
|
|
121
|
+
const wantPrev = !!(peppers.previous && record && record.pepper_version === peppers.previous.version);
|
|
122
|
+
return (eqCur && wantCur) || (eqPrev && wantPrev);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function seal(value, peppers) {
|
|
126
|
+
return { mac: macOf(peppers.current.pepper, value), pepper_version: peppers.current.version };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ttlMs() {
|
|
130
|
+
const raw = setting('SUBKEY_TTL_DAYS');
|
|
131
|
+
let days = 30;
|
|
132
|
+
if (raw !== undefined && String(raw).trim() !== '') {
|
|
133
|
+
const n = Number(raw);
|
|
134
|
+
if (Number.isFinite(n) && n >= 1) days = Math.floor(n);
|
|
135
|
+
}
|
|
136
|
+
if (days > 90) days = 90;
|
|
137
|
+
return days * 24 * 60 * 60 * 1000;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function graceMs() {
|
|
141
|
+
const raw = setting('SUBKEY_GRACE_MINUTES');
|
|
142
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return 10 * 60 * 1000;
|
|
143
|
+
const n = Number(raw);
|
|
144
|
+
if (!Number.isFinite(n) || n < 0) return 10 * 60 * 1000;
|
|
145
|
+
return Math.floor(n) * 60 * 1000;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function failMax() {
|
|
149
|
+
const n = Number(setting('SUBKEY_FAIL_MAX'));
|
|
150
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_FAIL_MAX;
|
|
151
|
+
}
|
|
152
|
+
function failWindowMs() {
|
|
153
|
+
const n = Number(setting('SUBKEY_FAIL_WINDOW_MS'));
|
|
154
|
+
return Number.isFinite(n) && n >= 1000 ? Math.floor(n) : DEFAULT_FAIL_WINDOW_MS;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function bucketKeys({ keyId, clientId, sessionId }) {
|
|
158
|
+
const out = [];
|
|
159
|
+
if (keyId) out.push(`key:${keyId}`);
|
|
160
|
+
if (clientId) out.push(`client:${clientId}`);
|
|
161
|
+
if (sessionId) out.push(`session:${sessionId}`);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Failed presentations only. A later correct handle or nonce is not refused:
|
|
167
|
+
* the secret is checked first, and a match clears the buckets it touches.
|
|
168
|
+
* Buckets are per key, per client, and per session.
|
|
169
|
+
*/
|
|
170
|
+
function createThrottle() {
|
|
171
|
+
const buckets = new Map();
|
|
172
|
+
const live = (key, now) => {
|
|
173
|
+
const windowMs = failWindowMs();
|
|
174
|
+
const kept = (buckets.get(key) || []).filter((ts) => now - ts < windowMs);
|
|
175
|
+
if (kept.length) buckets.set(key, kept); else buckets.delete(key);
|
|
176
|
+
return kept;
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
reset() { buckets.clear(); },
|
|
180
|
+
blocked(parts, now = Date.now()) {
|
|
181
|
+
const max = failMax();
|
|
182
|
+
return bucketKeys(parts).some((k) => live(k, now).length >= max);
|
|
183
|
+
},
|
|
184
|
+
fail(parts, now = Date.now()) {
|
|
185
|
+
for (const k of bucketKeys(parts)) {
|
|
186
|
+
const list = live(k, now);
|
|
187
|
+
list.push(now);
|
|
188
|
+
buckets.set(k, list);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
clear(parts) {
|
|
192
|
+
for (const k of bucketKeys(parts)) buckets.delete(k);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const THROTTLE_MSG = 'too many failed sub-key presentations. Try again later. Nothing was written.';
|
|
198
|
+
|
|
199
|
+
/** Record a failed present. Returns the throttle sentence once a bucket is already full, else null. */
|
|
200
|
+
function noteFailure(parts, now) {
|
|
201
|
+
const t = now.getTime();
|
|
202
|
+
if (throttle.blocked(parts, t)) return THROTTLE_MSG;
|
|
203
|
+
throttle.fail(parts, t);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function clearPresent(parts) { throttle.clear(parts); }
|
|
208
|
+
|
|
209
|
+
function assertHttpOwner(actor, ownerId) {
|
|
210
|
+
if (!actor || actor.transport !== 'http' || !actor.authenticated) {
|
|
211
|
+
throw refusal('sub-keys apply only to authenticated HTTP. Stdio keeps the registry rules. Nothing was changed.');
|
|
212
|
+
}
|
|
213
|
+
if (!personIsOwner(actor, ownerId || actor.ownerId)) {
|
|
214
|
+
throw refusal('sub-keys are limited to the owner\'s own login. Invite and Member Access logins cannot request, claim or present one. Nothing was changed.');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function assertOwnerActor(actor, ownerId, what) {
|
|
219
|
+
assertHttpOwner(actor, ownerId);
|
|
220
|
+
if (!actorIsOwner(actor, ownerId || actor.ownerId)) {
|
|
221
|
+
throw refusal(`${what}: only the owner's own login may do this. Nothing was changed.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function tagAllowed(tag, registry) {
|
|
226
|
+
const t = typeof tag === 'string' ? tag.trim() : '';
|
|
227
|
+
if (!t || t.includes('/') || t.length > 80) return null;
|
|
228
|
+
const list = Array.isArray(registry) ? registry : [];
|
|
229
|
+
if (!list.includes(t)) return null;
|
|
230
|
+
return t;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function past(iso, now) {
|
|
234
|
+
if (!iso) return false;
|
|
235
|
+
const ms = new Date(iso).getTime();
|
|
236
|
+
return Number.isFinite(ms) && ms <= now.getTime();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function pushHistory(row, event, extra = {}) {
|
|
240
|
+
row.history = Array.isArray(row.history) ? row.history : [];
|
|
241
|
+
const event_id = extra.event_id || `h:${row.key_id || 'key'}:${hexId()}`;
|
|
242
|
+
row.history.push({ ...extra, event_id, event, at: extra.at || new Date().toISOString() });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ensureMaps(data) {
|
|
246
|
+
if (!data.keys || typeof data.keys !== 'object' || Array.isArray(data.keys)) data.keys = {};
|
|
247
|
+
if (!Array.isArray(data.sessions)) data.sessions = [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function keyById(data, keyId) {
|
|
251
|
+
const row = data.keys && data.keys[keyId];
|
|
252
|
+
return row && typeof row === 'object' ? row : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function pendingForTag(data, tag) {
|
|
256
|
+
return Object.values(data.keys || {}).find((k) => k && k.tag === tag && k.status === 'pending') || null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function usableKey(row, now) {
|
|
260
|
+
if (!row) return false;
|
|
261
|
+
if (row.status === 'active') return !past(row.expires_at, now);
|
|
262
|
+
if (row.status === 'grace') return !past(row.grace_until, now) && !past(row.expires_at, now);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sessionByOpen(data, openId) {
|
|
267
|
+
if (!openId) return null;
|
|
268
|
+
return (data.sessions || []).find((s) => s && s.open_id === openId) || null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sessionsForBase(data, base) {
|
|
272
|
+
return (data.sessions || []).filter((s) => s && s.session_id === base);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The unexpired live open for a base, or the latest open when none is. */
|
|
276
|
+
function currentSession(data, base, now) {
|
|
277
|
+
const rows = sessionsForBase(data, base);
|
|
278
|
+
const live = rows.filter((s) => s.state === 'live' && !past(s.expires_at, now));
|
|
279
|
+
if (live.length) return live[live.length - 1];
|
|
280
|
+
return rows.length ? rows[rows.length - 1] : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function openIsStale(iso, now) {
|
|
284
|
+
const ms = iso ? new Date(iso).getTime() : NaN;
|
|
285
|
+
return !Number.isFinite(ms) || now.getTime() - ms >= OPEN_STALE_MS;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function livePointer(row, data, now) {
|
|
289
|
+
if (!row || !row.live_session_id) return null;
|
|
290
|
+
const s = sessionByOpen(data, row.live_session_id);
|
|
291
|
+
if (!s) {
|
|
292
|
+
if (openIsStale(row.live_set_at, now)) return null;
|
|
293
|
+
return { open_id: row.live_session_id, key_id: row.key_id, state: 'opening' };
|
|
294
|
+
}
|
|
295
|
+
if (s.key_id !== row.key_id) return null;
|
|
296
|
+
if (s.state === 'opening') return openIsStale(s.opened_at || row.live_set_at, now) ? null : s;
|
|
297
|
+
if (s.state !== 'live') return null;
|
|
298
|
+
if (past(s.expires_at, now)) return null;
|
|
299
|
+
return s;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function registryOf(envList) {
|
|
303
|
+
if (Array.isArray(envList)) return envList;
|
|
304
|
+
return String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function requireStore(deps) {
|
|
308
|
+
const store = await storeFor(deps);
|
|
309
|
+
if (!store) throw refusal('sub-key store is unavailable. Nothing was written.');
|
|
310
|
+
return store;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function edit(deps, fn) {
|
|
314
|
+
try {
|
|
315
|
+
const store = await requireStore(deps);
|
|
316
|
+
return await store.mutate(fn);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
if (e && e.attributionRefusal) throw e;
|
|
319
|
+
const message = e && e.message ? e.message : 'unknown error';
|
|
320
|
+
if (e && e.partial) throw refusal(`sub-key store failed. ${message}`);
|
|
321
|
+
throw refusal(`sub-key store failed. Nothing was written. (${message})`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Snapshot of keys, session opens, and history. Server-side. Callers that show it use subkeysView or readSubkeyHistory. */
|
|
326
|
+
export async function loadSubkeyRecords(deps) {
|
|
327
|
+
return (await requireStore(deps)).snapshot();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** One flag-off sweep. Null when it failed. Never throws. */
|
|
331
|
+
export async function sweepFlagOffSessions(log) {
|
|
332
|
+
if (subkeysEnabled()) return 0;
|
|
333
|
+
try {
|
|
334
|
+
const ended = await sweepFlagOffLiveSessions((base) => rememberSubkeySession(base));
|
|
335
|
+
if (ended > 0 && typeof log === 'function') log(`sub-key flag-off sweep ended ${ended} live session${ended === 1 ? '' : 's'}`);
|
|
336
|
+
return ended || 0;
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (typeof log === 'function') log(`sub-key flag-off sweep failed: ${e && e.message ? e.message : 'unknown error'}`);
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const SWEEP_BACKOFF_MS = [1000, 2000, 4000];
|
|
344
|
+
|
|
345
|
+
/** One sweep after listen, then a few retries. It does not retry forever. */
|
|
346
|
+
export function startFlagOffSweep(log) {
|
|
347
|
+
if (subkeysEnabled()) return;
|
|
348
|
+
const go = (n) => {
|
|
349
|
+
sweepFlagOffSessions(log).then((ended) => {
|
|
350
|
+
if (ended !== null || n >= SWEEP_BACKOFF_MS.length) return;
|
|
351
|
+
const t = setTimeout(() => go(n + 1), SWEEP_BACKOFF_MS[n]);
|
|
352
|
+
if (t.unref) t.unref();
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
go(0);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The session a close releases. `receipts.transcript_session_id` is the field
|
|
360
|
+
* the close check compares. When that field is present it wins. A session_id
|
|
361
|
+
* that names a different session does not release either one.
|
|
362
|
+
*/
|
|
363
|
+
export function sessionIdForClose(args) {
|
|
364
|
+
const a = args || {};
|
|
365
|
+
const receipts = parseReceipts(a.receipts);
|
|
366
|
+
const receiptRaw = receipts && typeof receipts.transcript_session_id === 'string' ? receipts.transcript_session_id.trim() : '';
|
|
367
|
+
const argRaw = typeof a.session_id === 'string' ? a.session_id.trim() : '';
|
|
368
|
+
const receiptBase = receiptRaw ? sessionBaseOf({ session_id: receiptRaw }) : null;
|
|
369
|
+
const argBase = argRaw ? sessionBaseOf({ session_id: argRaw }) : null;
|
|
370
|
+
if (receiptBase && argBase && receiptBase !== argBase) return { mismatch: true, base: null };
|
|
371
|
+
return { mismatch: false, base: receiptBase || argBase };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Request a sub-key for one registered tag. The response carries the claim
|
|
376
|
+
* nonce once. The record stores only its HMAC.
|
|
377
|
+
*/
|
|
378
|
+
export async function requestSubkey(deps, actor, { tag } = {}, registry = [], now = new Date()) {
|
|
379
|
+
if (!subkeysEnabled()) throw refusal('sub-keys are off (EMET_SUBKEYS is not 1). Nothing was changed.');
|
|
380
|
+
const peppers = requirePepper();
|
|
381
|
+
assertHttpOwner(actor, actor && actor.ownerId);
|
|
382
|
+
const list = registryOf(registry);
|
|
383
|
+
const t = tagAllowed(tag, list);
|
|
384
|
+
if (!t) throw refusal('emet_subkey_request: "tag" must be an exact member of EMET_SOURCE_TAGS. Nothing was changed.');
|
|
385
|
+
const nonce = `${NONCE_PREFIX}${b64(32)}`;
|
|
386
|
+
const key_id = hexId();
|
|
387
|
+
let created = null;
|
|
388
|
+
await edit(deps, (data) => {
|
|
389
|
+
ensureMaps(data);
|
|
390
|
+
if (pendingForTag(data, t)) throw refusal(`a sub-key for "${t}" is already awaiting approval. Nothing was changed.`);
|
|
391
|
+
const current = Object.values(data.keys).filter((k) => k && k.tag === t && (k.status === 'active' || k.status === 'grace') && usableKey(k, now));
|
|
392
|
+
const replaces = current.length ? current[current.length - 1].key_id : null;
|
|
393
|
+
const row = {
|
|
394
|
+
key_id, tag: t, status: 'pending',
|
|
395
|
+
client_id: actor.clientId || null, grant_id: actor.grantId || null,
|
|
396
|
+
person_id: actor.personId || null, login_tag: actor.sourceTag || null,
|
|
397
|
+
requested_at: now.toISOString(),
|
|
398
|
+
nonce_hmac: seal(nonce, peppers), nonce_spent: false,
|
|
399
|
+
replaces, collisions: [], history: []
|
|
400
|
+
};
|
|
401
|
+
pushHistory(row, 'request', { at: row.requested_at, by: actor.clientId || null, client_id: actor.clientId || null });
|
|
402
|
+
data.keys[key_id] = row;
|
|
403
|
+
created = { key_id, tag: t, replaces };
|
|
404
|
+
return true;
|
|
405
|
+
});
|
|
406
|
+
return { key_id, tag: t, nonce, claim_expires_after: '24 hours from approval', replaces: created && created.replaces, status: 'pending' };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Approve or deny a pending sub-key. Same client_id or grant as the request
|
|
411
|
+
* is refused. A key_id decides that row; a different pending row for the tag
|
|
412
|
+
* is a mismatch and is refused. With no key_id, the pending row for the tag
|
|
413
|
+
* is decided. Returns null when nothing is pending, so the ordinary client
|
|
414
|
+
* approval can proceed.
|
|
415
|
+
*/
|
|
416
|
+
export async function decideSubkeyIfPending(deps, actor, ownerId, { tag, decision, reason, key_id, via } = {}, now = new Date()) {
|
|
417
|
+
if (!subkeysEnabled()) return null;
|
|
418
|
+
const id = typeof key_id === 'string' ? key_id.trim() : '';
|
|
419
|
+
const t = typeof tag === 'string' ? tag.trim() : '';
|
|
420
|
+
if (!id && !t) return null;
|
|
421
|
+
const cur = await loadSubkeyRecords(deps);
|
|
422
|
+
if (id) {
|
|
423
|
+
const row = keyById(cur, id);
|
|
424
|
+
if (!row || row.status !== 'pending') return null;
|
|
425
|
+
} else if (!pendingForTag(cur, t)) return null;
|
|
426
|
+
requirePepper();
|
|
427
|
+
assertHttpOwner(actor, ownerId);
|
|
428
|
+
const ownerOk = actorIsOwner(actor, ownerId);
|
|
429
|
+
const cosOk = actorIsCos(actor);
|
|
430
|
+
if (!(ownerOk || cosOk)) throw refusal(`emet_client_approve: only the owner${cosTag() ? ` or the chief of staff (${cosTag()})` : ''} may decide a sub-key. Nothing was changed.`);
|
|
431
|
+
const why = typeof reason === 'string' ? reason.trim() : '';
|
|
432
|
+
const channel = typeof via === 'string' ? via.trim() : '';
|
|
433
|
+
let result = null;
|
|
434
|
+
await edit(deps, (data) => {
|
|
435
|
+
ensureMaps(data);
|
|
436
|
+
const row = id ? keyById(data, id) : pendingForTag(data, t);
|
|
437
|
+
if (!row || row.status !== 'pending') { result = null; return false; }
|
|
438
|
+
if (t && row.tag !== t) throw refusal('emet_client_approve: that sub-key does not match the tag. Nothing was changed.');
|
|
439
|
+
const label = row.tag;
|
|
440
|
+
const sameClient = !!row.client_id && row.client_id === (actor.clientId || null);
|
|
441
|
+
const sameGrant = !!row.grant_id && row.grant_id === (actor.grantId || null);
|
|
442
|
+
if (actor.transport === 'http' && (sameClient || sameGrant)) {
|
|
443
|
+
throw refusal(`emet_client_approve: this connection requested the sub-key for "${label}", so it may not decide it. Decide from the owner's own login. Nothing was changed.`);
|
|
444
|
+
}
|
|
445
|
+
const by = ownerOk ? `owner (${actor.personId || 'local'})` : `cos ${cosTag()}`;
|
|
446
|
+
const hist = { at: now.toISOString(), by, client_id: actor.clientId || null };
|
|
447
|
+
if (why) hist.reason = why;
|
|
448
|
+
if (channel) hist.via = channel;
|
|
449
|
+
if (decision === 'approve') {
|
|
450
|
+
row.status = 'approved';
|
|
451
|
+
row.approved_by = by;
|
|
452
|
+
row.approved_at = hist.at;
|
|
453
|
+
row.approve_reason = why || null;
|
|
454
|
+
pushHistory(row, 'approve', hist);
|
|
455
|
+
} else if (decision === 'deny') {
|
|
456
|
+
row.status = 'denied';
|
|
457
|
+
row.approved_by = by;
|
|
458
|
+
row.approved_at = hist.at;
|
|
459
|
+
row.approve_reason = why || null;
|
|
460
|
+
pushHistory(row, 'deny', hist);
|
|
461
|
+
} else {
|
|
462
|
+
throw refusal('emet_client_approve: "decision" must be "approve" or "deny". Nothing was changed.');
|
|
463
|
+
}
|
|
464
|
+
const replaces = row.replaces || null;
|
|
465
|
+
result = {
|
|
466
|
+
kind: 'subkey', tag: label, key_id: row.key_id, decision, by, at: row.approved_at,
|
|
467
|
+
replaces,
|
|
468
|
+
replaces_existing_key: !!replaces,
|
|
469
|
+
...(replaces ? { approval_note: `This approval replaces the existing key ${replaces} for "${label}". The old key stays in its grace window, then stops.` } : {})
|
|
470
|
+
};
|
|
471
|
+
return true;
|
|
472
|
+
});
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export async function claimSubkey(deps, actor, { key_id, nonce } = {}, now = new Date()) {
|
|
477
|
+
if (!subkeysEnabled()) throw refusal('sub-keys are off (EMET_SUBKEYS is not 1). Nothing was changed.');
|
|
478
|
+
const peppers = requirePepper();
|
|
479
|
+
assertHttpOwner(actor, actor && actor.ownerId);
|
|
480
|
+
const id = typeof key_id === 'string' ? key_id.trim() : '';
|
|
481
|
+
const presented = typeof nonce === 'string' ? nonce.trim() : '';
|
|
482
|
+
const parts = { keyId: id || null, clientId: actor.clientId || null, sessionId: null };
|
|
483
|
+
if (!id || !NONCE_RE.test(presented)) {
|
|
484
|
+
const throttled = noteFailure(parts, now);
|
|
485
|
+
throw refusal(throttled || 'emet_subkey_claim: pass "key_id" and the one-time "nonce" from emet_subkey_request. Nothing was changed.');
|
|
486
|
+
}
|
|
487
|
+
const secret = b64(32);
|
|
488
|
+
const token = `${KEY_PREFIX}${id}_${secret}`;
|
|
489
|
+
let result = null;
|
|
490
|
+
let mismatch = false;
|
|
491
|
+
await edit(deps, (data) => {
|
|
492
|
+
ensureMaps(data);
|
|
493
|
+
const row = keyById(data, id);
|
|
494
|
+
const nonceOk = !!(row && verifyMac(row.nonce_hmac, presented, peppers));
|
|
495
|
+
if (!row || row.nonce_spent || row.status !== 'approved' || !nonceOk) {
|
|
496
|
+
mismatch = true;
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
if (past(new Date(new Date(row.approved_at).getTime() + CLAIM_TTL_MS).toISOString(), now)) {
|
|
500
|
+
row.status = 'expired';
|
|
501
|
+
pushHistory(row, 'claim_expired', { at: now.toISOString(), client_id: actor.clientId || null });
|
|
502
|
+
result = { expired: true };
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
const grace = graceMs();
|
|
506
|
+
if (row.replaces && data.keys[row.replaces]) {
|
|
507
|
+
const old = data.keys[row.replaces];
|
|
508
|
+
if (grace === 0) {
|
|
509
|
+
old.status = 'revoked';
|
|
510
|
+
old.revoked_at = now.toISOString();
|
|
511
|
+
old.revoke_reason = 'rotated';
|
|
512
|
+
old.replaced_by = id;
|
|
513
|
+
endLive(data, old, 'revoked', now, 'rotated');
|
|
514
|
+
pushHistory(old, 'revoked', { at: old.revoked_at, by: 'rotation', reason: 'rotated' });
|
|
515
|
+
} else {
|
|
516
|
+
old.status = 'grace';
|
|
517
|
+
old.grace_until = new Date(now.getTime() + grace).toISOString();
|
|
518
|
+
old.replaced_by = id;
|
|
519
|
+
pushHistory(old, 'grace', { at: now.toISOString(), until: old.grace_until, replaced_by: id });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
row.nonce_spent = true;
|
|
523
|
+
row.status = 'active';
|
|
524
|
+
row.secret_hmac = seal(secret, peppers);
|
|
525
|
+
row.owner_subject = actor.personId || null;
|
|
526
|
+
row.created_at = now.toISOString();
|
|
527
|
+
row.expires_at = new Date(now.getTime() + ttlMs()).toISOString();
|
|
528
|
+
row.claimed_at = row.created_at;
|
|
529
|
+
row.last_used = null;
|
|
530
|
+
pushHistory(row, 'claim', { at: row.claimed_at, client_id: actor.clientId || null, pepper_version: peppers.current.version });
|
|
531
|
+
result = { key_id: id, tag: row.tag, expires_at: row.expires_at };
|
|
532
|
+
return true;
|
|
533
|
+
});
|
|
534
|
+
if (mismatch) {
|
|
535
|
+
const throttled = noteFailure(parts, now);
|
|
536
|
+
throw refusal(throttled || 'emet_subkey_claim: the key id or nonce does not match an unclaimed approval, or this nonce was already used. Nothing was changed.');
|
|
537
|
+
}
|
|
538
|
+
if (result && result.expired) throw refusal('emet_subkey_claim: this approval expired after 24 hours unclaimed. Request it again. Nothing was changed.');
|
|
539
|
+
clearPresent(parts);
|
|
540
|
+
return { ...result, subkey: token };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function endLive(data, row, state, now, reason) {
|
|
544
|
+
if (!row || !row.live_session_id) return;
|
|
545
|
+
const openId = row.live_session_id;
|
|
546
|
+
const s = sessionByOpen(data, openId);
|
|
547
|
+
if (s && s.key_id === row.key_id && s.state === 'live') {
|
|
548
|
+
s.state = state;
|
|
549
|
+
s.ended_at = now.toISOString();
|
|
550
|
+
s.end_reason = reason || null;
|
|
551
|
+
} else {
|
|
552
|
+
if (s && s.key_id === row.key_id && s.state === 'opening') {
|
|
553
|
+
s.state = 'ended';
|
|
554
|
+
s.ended_at = now.toISOString();
|
|
555
|
+
s.end_reason = reason || 'session lost';
|
|
556
|
+
}
|
|
557
|
+
pushHistory(row, 'session_lost', { at: now.toISOString(), open_id: openId, session_id: s ? s.session_id : null, reason: reason || null });
|
|
558
|
+
}
|
|
559
|
+
row.live_session_id = null;
|
|
560
|
+
row.live_set_at = null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export async function revokeSubkey(deps, actor, ownerId, { tag, key_id, reason } = {}, now = new Date()) {
|
|
564
|
+
if (!subkeysEnabled()) throw refusal('sub-keys are off (EMET_SUBKEYS is not 1). Nothing was changed.');
|
|
565
|
+
requirePepper();
|
|
566
|
+
assertHttpOwner(actor, ownerId);
|
|
567
|
+
const ownerOk = actorIsOwner(actor, ownerId);
|
|
568
|
+
const cosOk = actorIsCos(actor);
|
|
569
|
+
if (!(ownerOk || cosOk)) throw refusal('emet_subkey_revoke: only the owner\'s own login may revoke a sub-key. Nothing was changed.');
|
|
570
|
+
const id = typeof key_id === 'string' ? key_id.trim() : '';
|
|
571
|
+
const t = typeof tag === 'string' ? tag.trim() : '';
|
|
572
|
+
if (!id && !t) throw refusal('emet_subkey_revoke: pass "tag" or "key_id". Nothing was changed.');
|
|
573
|
+
let result = null;
|
|
574
|
+
await edit(deps, (data) => {
|
|
575
|
+
ensureMaps(data);
|
|
576
|
+
const rows = id
|
|
577
|
+
? [keyById(data, id)].filter(Boolean)
|
|
578
|
+
: Object.values(data.keys).filter((k) => k && k.tag === t && k.status !== 'revoked' && k.status !== 'denied');
|
|
579
|
+
if (!rows.length) throw refusal('emet_subkey_revoke: no sub-key matched. Nothing was changed.');
|
|
580
|
+
for (const row of rows) {
|
|
581
|
+
if (!ownerOk && cosOk) {
|
|
582
|
+
const same = (!!row.client_id && row.client_id === actor.clientId) || (!!row.grant_id && row.grant_id === actor.grantId);
|
|
583
|
+
const live = sessionByOpen(data, row.live_session_id);
|
|
584
|
+
const sameSession = live && ((live.client_id && live.client_id === actor.clientId) || (live.grant_id && live.grant_id === actor.grantId));
|
|
585
|
+
if (same || sameSession) throw refusal('emet_subkey_revoke: the chief of staff can revoke only from a different OAuth client than the key\'s sessions. On this shared login, revoke from the owner\'s own login. Nothing was changed.');
|
|
586
|
+
}
|
|
587
|
+
if (row.status === 'revoked') continue;
|
|
588
|
+
row.status = 'revoked';
|
|
589
|
+
row.revoked_at = now.toISOString();
|
|
590
|
+
row.revoked_by = ownerOk ? `owner (${actor.personId || 'local'})` : `cos ${cosTag()}`;
|
|
591
|
+
row.revoke_reason = reason || null;
|
|
592
|
+
endLive(data, row, 'revoked', now, reason || 'revoked');
|
|
593
|
+
pushHistory(row, 'revoked', { at: row.revoked_at, by: row.revoked_by, reason: reason || null, client_id: actor.clientId || null });
|
|
594
|
+
result = { key_id: row.key_id, tag: row.tag, status: 'revoked', at: row.revoked_at };
|
|
595
|
+
}
|
|
596
|
+
return true;
|
|
597
|
+
});
|
|
598
|
+
return result;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** End the key's live session without revoking the key. Owner login only. */
|
|
602
|
+
export async function endSubkeySession(deps, actor, ownerId, { tag, key_id, reason } = {}, now = new Date()) {
|
|
603
|
+
if (!subkeysEnabled()) throw refusal('sub-keys are off (EMET_SUBKEYS is not 1). Nothing was changed.');
|
|
604
|
+
requirePepper();
|
|
605
|
+
assertOwnerActor(actor, ownerId, 'emet_subkey_end_session');
|
|
606
|
+
const id = typeof key_id === 'string' ? key_id.trim() : '';
|
|
607
|
+
const t = typeof tag === 'string' ? tag.trim() : '';
|
|
608
|
+
if (!id && !t) throw refusal('emet_subkey_end_session: pass "tag" or "key_id". Nothing was changed.');
|
|
609
|
+
let result = null;
|
|
610
|
+
await edit(deps, (data) => {
|
|
611
|
+
ensureMaps(data);
|
|
612
|
+
const row = id
|
|
613
|
+
? keyById(data, id)
|
|
614
|
+
: Object.values(data.keys).find((k) => k && k.tag === t && k.live_session_id);
|
|
615
|
+
if (!row) throw refusal('emet_subkey_end_session: no sub-key matched. Nothing was changed.');
|
|
616
|
+
if (!row.live_session_id) throw refusal(`emet_subkey_end_session: "${row.tag}" has no live session. The key was not changed.`);
|
|
617
|
+
const live = sessionByOpen(data, row.live_session_id);
|
|
618
|
+
const base = live ? live.session_id : null;
|
|
619
|
+
endLive(data, row, 'ended', now, reason || 'owner ended the live session');
|
|
620
|
+
pushHistory(row, 'session_ended', { at: now.toISOString(), by: `owner (${actor.personId || 'local'})`, session_id: base, reason: reason || null });
|
|
621
|
+
result = { key_id: row.key_id, tag: row.tag, status: row.status, session_id: base, ended: true };
|
|
622
|
+
return true;
|
|
623
|
+
});
|
|
624
|
+
return result;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function lookupUsable(data, keyId, secret, peppers, now) {
|
|
628
|
+
const row = keyById(data, keyId);
|
|
629
|
+
const macOk = !!(row && row.secret_hmac && verifyMac(row.secret_hmac, secret, peppers));
|
|
630
|
+
if (!row || !macOk) return { row: row || null, ok: false, changed: false, reason: 'mismatch' };
|
|
631
|
+
if (row.status === 'grace' && past(row.grace_until, now)) {
|
|
632
|
+
row.status = 'revoked';
|
|
633
|
+
row.revoked_at = now.toISOString();
|
|
634
|
+
row.revoke_reason = 'grace ended';
|
|
635
|
+
endLive(data, row, 'revoked', now, 'grace ended');
|
|
636
|
+
pushHistory(row, 'revoked', { at: row.revoked_at, by: 'grace', reason: 'grace ended' });
|
|
637
|
+
return { row, ok: false, changed: true, reason: 'grace' };
|
|
638
|
+
}
|
|
639
|
+
if ((row.status === 'active' || row.status === 'grace') && past(row.expires_at, now)) {
|
|
640
|
+
row.status = 'expired';
|
|
641
|
+
endLive(data, row, 'expired', now, 'key expired');
|
|
642
|
+
pushHistory(row, 'expired', { at: now.toISOString() });
|
|
643
|
+
return { row, ok: false, changed: true, reason: 'expired' };
|
|
644
|
+
}
|
|
645
|
+
if (!usableKey(row, now)) return { row, ok: false, changed: false, reason: row.status };
|
|
646
|
+
return { row, ok: true, changed: false };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function presentFailure(found) {
|
|
650
|
+
if (found.reason === 'expired') return 'this sub-key is expired. Ask the owner to reissue it. Nothing was opened.';
|
|
651
|
+
if (found.reason === 'grace' || (found.row && found.row.status === 'revoked')) return 'sub-key revoked; open a new session with a current key. Nothing was opened.';
|
|
652
|
+
return 'emet_session_open: this sub-key does not match an active key. Nothing was opened.';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Validate a presented key or handle before the strict login check.
|
|
657
|
+
* The secret is checked before any throttle. A correct handle or key is
|
|
658
|
+
* let through. Only a miss is counted, per key, per client, and per session.
|
|
659
|
+
* Sets actor.subkeySession / subkeyTag / subkeyId. Stashes the raw key only
|
|
660
|
+
* in a WeakMap for emet_session_open to commit, never on a logged field.
|
|
661
|
+
*/
|
|
662
|
+
export async function prepareSubkeyCall(ctx, actor, args, toolName) {
|
|
663
|
+
if (!subkeysEnabled()) return;
|
|
664
|
+
const now = (ctx && ctx.now) || new Date();
|
|
665
|
+
const a = args || {};
|
|
666
|
+
const handle = typeof a.session_handle === 'string' ? a.session_handle.trim() : '';
|
|
667
|
+
const token = typeof a.subkey === 'string' ? a.subkey.trim() : '';
|
|
668
|
+
const presentingKey = KEY_RE.test(token);
|
|
669
|
+
const presentingHandle = handle.length > 0;
|
|
670
|
+
|
|
671
|
+
if (SUBKEY_TOOLS.includes(toolName) && !presentingHandle && !presentingKey) {
|
|
672
|
+
requirePepper();
|
|
673
|
+
assertHttpOwner(actor, ctx && ctx.ownerId);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (presentingKey && toolName !== 'emet_session_open') {
|
|
678
|
+
throw refusal('a sub-key is presented only at emet_session_open. Pass session_handle on later writes. Nothing was written.');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const base = toolName === 'emet_session_open' ? null : sessionBaseOf(a);
|
|
682
|
+
|
|
683
|
+
if (toolName === 'emet_session_open' && presentingKey) {
|
|
684
|
+
const peppers = requirePepper();
|
|
685
|
+
assertHttpOwner(actor, ctx && ctx.ownerId);
|
|
686
|
+
const parts = splitKeyToken(token);
|
|
687
|
+
const bucket = { keyId: parts.key_id, clientId: actor.clientId || null, sessionId: null };
|
|
688
|
+
let outcome = null;
|
|
689
|
+
await edit(ctx.deps, (data) => {
|
|
690
|
+
ensureMaps(data);
|
|
691
|
+
const found = lookupUsable(data, parts.key_id, parts.secret, peppers, now);
|
|
692
|
+
if (!found.ok) { outcome = { fail: found }; return found.changed === true; }
|
|
693
|
+
const live = livePointer(found.row, data, now);
|
|
694
|
+
if (live) {
|
|
695
|
+
found.row.collisions = Array.isArray(found.row.collisions) ? found.row.collisions : [];
|
|
696
|
+
found.row.collisions.push({ at: now.toISOString(), client_id: actor.clientId || null, grant_id: actor.grantId || null });
|
|
697
|
+
pushHistory(found.row, 'collision', { at: now.toISOString(), client_id: actor.clientId || null, grant_id: actor.grantId || null });
|
|
698
|
+
outcome = { collision: true, key_id: found.row.key_id, tag: found.row.tag };
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
outcome = { key_id: found.row.key_id, tag: found.row.tag };
|
|
702
|
+
return false;
|
|
703
|
+
});
|
|
704
|
+
if (!outcome || (outcome.fail && outcome.fail.reason === 'mismatch')) {
|
|
705
|
+
const throttled = noteFailure(bucket, now);
|
|
706
|
+
throw refusal(throttled || presentFailure(outcome ? outcome.fail : { reason: 'mismatch' }));
|
|
707
|
+
}
|
|
708
|
+
if (outcome.fail) throw refusal(presentFailure(outcome.fail));
|
|
709
|
+
clearPresent(bucket);
|
|
710
|
+
if (outcome.collision) throw refusal('this sub-key already has a live session. Close it, or ask the owner to end it, before opening another. Nothing was opened.');
|
|
711
|
+
actor.subkeySession = true;
|
|
712
|
+
actor.subkeyTag = outcome.tag;
|
|
713
|
+
actor.subkeyId = outcome.key_id;
|
|
714
|
+
openTokens.set(actor, token);
|
|
715
|
+
const declared = typeof a.source === 'string' && a.source.trim() ? a.source.trim() : '';
|
|
716
|
+
if (declared && declared !== outcome.tag) {
|
|
717
|
+
throw refusal(`this sub-key is bound to "${outcome.tag}"; "${declared}" does not match. Nothing was opened.`);
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (toolName === 'emet_session_open' && token && !presentingKey) {
|
|
723
|
+
const throttled = noteFailure({ keyId: null, clientId: actor.clientId || null, sessionId: null }, now);
|
|
724
|
+
throw refusal(throttled || 'emet_session_open: this sub-key does not match an active key. Nothing was opened.');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!ctx || !ctx.deps) {
|
|
728
|
+
if (presentingHandle) throw refusal('sub-keys are unavailable. Nothing was written.');
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let session = null;
|
|
733
|
+
await edit(ctx.deps, (data) => {
|
|
734
|
+
ensureMaps(data);
|
|
735
|
+
if (base) session = currentSession(data, base, now);
|
|
736
|
+
return false;
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
if (session) {
|
|
740
|
+
const peppers = requirePepper();
|
|
741
|
+
assertHttpOwner(actor, ctx && ctx.ownerId);
|
|
742
|
+
const bucket = { keyId: session.key_id, clientId: actor.clientId || null, sessionId: base };
|
|
743
|
+
if (session.state !== 'live') {
|
|
744
|
+
const why = session.state === 'revoked'
|
|
745
|
+
? 'sub-key revoked; open a new session with a current key'
|
|
746
|
+
: session.state === 'ended'
|
|
747
|
+
? 'this sub-key session was ended; open a new session'
|
|
748
|
+
: session.state === 'closed'
|
|
749
|
+
? 'this sub-key session is closed; open a new session'
|
|
750
|
+
: 'this sub-key session is no longer live; open a new session';
|
|
751
|
+
throw refusal(`${why}. Nothing was written.`);
|
|
752
|
+
}
|
|
753
|
+
if (past(session.expires_at, now)) {
|
|
754
|
+
await edit(ctx.deps, (data) => {
|
|
755
|
+
const s = currentSession(data, base, now);
|
|
756
|
+
const row = s && keyById(data, s.key_id);
|
|
757
|
+
if (s && s.state === 'live') { s.state = 'expired'; s.ended_at = now.toISOString(); s.end_reason = 'handle expired'; }
|
|
758
|
+
if (row && row.live_session_id === s.open_id) row.live_session_id = null;
|
|
759
|
+
if (row) pushHistory(row, 'handle_expired', { at: now.toISOString(), session_id: base });
|
|
760
|
+
return true;
|
|
761
|
+
});
|
|
762
|
+
throw refusal('this session handle has expired. Open a new session. Nothing was written.');
|
|
763
|
+
}
|
|
764
|
+
if (!HANDLE_RE.test(handle)) {
|
|
765
|
+
const throttled = noteFailure(bucket, now);
|
|
766
|
+
throw refusal(throttled || `session ${base} is a sub-key session; pass its session_handle. Nothing was written.`);
|
|
767
|
+
}
|
|
768
|
+
if (!verifyMac(session.handle_hmac, handle, peppers)) {
|
|
769
|
+
const throttled = noteFailure(bucket, now);
|
|
770
|
+
throw refusal(throttled || 'session handle does not match this sub-key session. Nothing was written.');
|
|
771
|
+
}
|
|
772
|
+
clearPresent(bucket);
|
|
773
|
+
if (OWNER_ONLY_TOOLS.has(toolName)) {
|
|
774
|
+
throw refusal('a sub-key session cannot call this tool. Nothing was changed.');
|
|
775
|
+
}
|
|
776
|
+
const cur = await loadSubkeyRecords(ctx.deps);
|
|
777
|
+
const row = keyById(cur, session.key_id);
|
|
778
|
+
if (!row || row.status === 'revoked') throw refusal('sub-key revoked; open a new session with a current key. Nothing was written.');
|
|
779
|
+
if (row.live_session_id !== session.open_id) throw refusal('this sub-key session is no longer the live session for its key. Nothing was written.');
|
|
780
|
+
if (!usableKey(row, now)) throw refusal('this sub-key is no longer active. Nothing was written.');
|
|
781
|
+
const declared = typeof a.source === 'string' && a.source.trim() ? a.source.trim() : (a.metadata && typeof a.metadata.source === 'string' ? a.metadata.source.trim() : '');
|
|
782
|
+
if (declared && declared !== row.tag) {
|
|
783
|
+
throw refusal(`this session is bound to "${row.tag}"; "${declared}" does not match. Nothing was written.`);
|
|
784
|
+
}
|
|
785
|
+
await (await storeFor(ctx.deps)).touchLastUsed(session.key_id, now.toISOString());
|
|
786
|
+
actor.subkeySession = true;
|
|
787
|
+
actor.subkeyTag = row.tag;
|
|
788
|
+
actor.subkeyId = row.key_id;
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (presentingHandle) {
|
|
793
|
+
const throttled = noteFailure({ keyId: null, clientId: actor.clientId || null, sessionId: base }, now);
|
|
794
|
+
throw refusal(throttled || 'session handle does not match a sub-key session. Nothing was written.');
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Bind the resolved session base to the key and return the handle once. One new record per open. */
|
|
799
|
+
export async function commitLiveSession(deps, actor, base, now = new Date()) {
|
|
800
|
+
const token = openTokens.get(actor);
|
|
801
|
+
if (!token || !base) return null;
|
|
802
|
+
const peppers = requirePepper();
|
|
803
|
+
const parts = splitKeyToken(token);
|
|
804
|
+
if (!parts) throw refusal('emet_session_open: this sub-key does not match an active key. Nothing was opened.');
|
|
805
|
+
const handle = `${HANDLE_PREFIX}${b64(32)}`;
|
|
806
|
+
let collision = false;
|
|
807
|
+
let failed = null;
|
|
808
|
+
await edit(deps, (data) => {
|
|
809
|
+
ensureMaps(data);
|
|
810
|
+
const found = lookupUsable(data, parts.key_id, parts.secret, peppers, now);
|
|
811
|
+
if (!found.ok) { failed = found; return found.changed === true; }
|
|
812
|
+
const live = livePointer(found.row, data, now);
|
|
813
|
+
if (live) {
|
|
814
|
+
found.row.collisions = Array.isArray(found.row.collisions) ? found.row.collisions : [];
|
|
815
|
+
found.row.collisions.push({ at: now.toISOString(), client_id: actor.clientId || null, grant_id: actor.grantId || null });
|
|
816
|
+
pushHistory(found.row, 'collision', { at: now.toISOString(), client_id: actor.clientId || null });
|
|
817
|
+
collision = true;
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
if (found.row.live_session_id) endLive(data, found.row, 'ended', now, 'session lost');
|
|
821
|
+
const open_id = hexId();
|
|
822
|
+
const expires_at = new Date(now.getTime() + HANDLE_TTL_MS).toISOString();
|
|
823
|
+
data.sessions.push({
|
|
824
|
+
open_id, session_id: base,
|
|
825
|
+
key_id: found.row.key_id, tag: found.row.tag, state: 'live',
|
|
826
|
+
handle_hmac: seal(handle, peppers),
|
|
827
|
+
opened_at: now.toISOString(), expires_at,
|
|
828
|
+
client_id: actor.clientId || null, grant_id: actor.grantId || null
|
|
829
|
+
});
|
|
830
|
+
found.row.live_session_id = open_id;
|
|
831
|
+
found.row.live_set_at = now.toISOString();
|
|
832
|
+
found.row.last_used = now.toISOString();
|
|
833
|
+
pushHistory(found.row, 'session_open', { at: now.toISOString(), session_id: base, open_id, client_id: actor.clientId || null, expires_at });
|
|
834
|
+
return true;
|
|
835
|
+
});
|
|
836
|
+
if (failed) throw refusal(presentFailure(failed));
|
|
837
|
+
if (collision) throw refusal('this sub-key already has a live session. Nothing was opened.');
|
|
838
|
+
openTokens.delete(actor);
|
|
839
|
+
return handle;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export function takeOpenToken(actor) { return openTokens.get(actor) || null; }
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Release a live sub-key session when its own close completes. The caller
|
|
846
|
+
* must present that session's sub-key and its session handle. A close that
|
|
847
|
+
* names another session, or that lacks either secret, leaves the record live.
|
|
848
|
+
*/
|
|
849
|
+
export async function releaseClosedSession(deps, base, now = new Date(), proof = null) {
|
|
850
|
+
if (!subkeysEnabled() || !base) return null;
|
|
851
|
+
const handle = proof && typeof proof.handle === 'string' ? proof.handle.trim() : '';
|
|
852
|
+
const token = proof && typeof proof.subkey === 'string' ? proof.subkey.trim() : '';
|
|
853
|
+
const parts = splitKeyToken(token);
|
|
854
|
+
if (!HANDLE_RE.test(handle) || !parts) return null;
|
|
855
|
+
const peppers = pepperState();
|
|
856
|
+
if (!peppers) return null;
|
|
857
|
+
let released = null;
|
|
858
|
+
await edit(deps, (data) => {
|
|
859
|
+
ensureMaps(data);
|
|
860
|
+
const s = (data.sessions || []).find((row) => row && row.session_id === base && row.state === 'live' && row.key_id === parts.key_id && !past(row.expires_at, now));
|
|
861
|
+
if (!s) return false;
|
|
862
|
+
const row = keyById(data, s.key_id);
|
|
863
|
+
if (!row || !verifyMac(s.handle_hmac, handle, peppers) || !verifyMac(row.secret_hmac, parts.secret, peppers)) return false;
|
|
864
|
+
s.state = 'closed';
|
|
865
|
+
s.ended_at = now.toISOString();
|
|
866
|
+
s.end_reason = 'session closed';
|
|
867
|
+
if (row.live_session_id === s.open_id) row.live_session_id = null;
|
|
868
|
+
pushHistory(row, 'session_closed', { at: s.ended_at, session_id: base, open_id: s.open_id });
|
|
869
|
+
released = { key_id: s.key_id, session_id: base, open_id: s.open_id };
|
|
870
|
+
return true;
|
|
871
|
+
});
|
|
872
|
+
return released;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function keyMapOf(data) {
|
|
876
|
+
if (!data) return {};
|
|
877
|
+
if (data.keys && typeof data.keys === 'object' && !Array.isArray(data.keys)) return data.keys;
|
|
878
|
+
if (data.subkeys && typeof data.subkeys === 'object' && !Array.isArray(data.subkeys)) return data.subkeys;
|
|
879
|
+
return {};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Key list for the owner and the COS. Everyone else gets null. No secrets. */
|
|
883
|
+
export function subkeysView(data, actor, ownerId, now = new Date()) {
|
|
884
|
+
if (!subkeysEnabled()) return null;
|
|
885
|
+
if (!data) return null;
|
|
886
|
+
const owner = actorIsOwner(actor, ownerId);
|
|
887
|
+
const cos = actorIsCos(actor);
|
|
888
|
+
if (!(owner || cos)) return null;
|
|
889
|
+
const keys = Object.values(keyMapOf(data)).map((k) => {
|
|
890
|
+
const expires_at = k.expires_at || null;
|
|
891
|
+
const soon = !!(expires_at && !past(expires_at, now) && (new Date(expires_at).getTime() - now.getTime()) <= EXPIRY_NOTICE_MS);
|
|
892
|
+
const replaces = k.replaces || null;
|
|
893
|
+
const pendingReplacement = !!replaces && (k.status === 'pending' || k.status === 'approved');
|
|
894
|
+
return {
|
|
895
|
+
tag: k.tag, key_id: k.key_id, status: k.status,
|
|
896
|
+
requested_at: k.requested_at || null,
|
|
897
|
+
approved_at: k.approved_at || null,
|
|
898
|
+
created_at: k.created_at || null,
|
|
899
|
+
expires_at, last_used: k.last_used || null,
|
|
900
|
+
grace_until: k.grace_until || null,
|
|
901
|
+
live_session_id: k.live_session_id || null,
|
|
902
|
+
replaces,
|
|
903
|
+
replaces_existing_key: pendingReplacement,
|
|
904
|
+
...(pendingReplacement ? { approval_note: `Replaces the existing key ${replaces} for "${k.tag}". Approving starts that key's grace window.` } : {}),
|
|
905
|
+
collision_flags: Array.isArray(k.collisions) ? k.collisions.map((c) => ({ at: c.at, client_id: c.client_id || null })) : [],
|
|
906
|
+
expires_within_7_days: soon
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
return {
|
|
910
|
+
keys,
|
|
911
|
+
expiring_within_7_days: keys.filter((k) => k.expires_within_7_days).map((k) => k.key_id),
|
|
912
|
+
replacements_pending: keys.filter((k) => k.replaces_existing_key).map((k) => ({ tag: k.tag, key_id: k.key_id, replaces: k.replaces }))
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* History of the sub-key collection. Owner and COS only. A bot, a guest, and
|
|
918
|
+
* any other read token get null. The events carry no HMAC and no secret.
|
|
919
|
+
*/
|
|
920
|
+
export async function readSubkeyHistory(deps, actor, ownerId) {
|
|
921
|
+
if (!subkeysEnabled()) return null;
|
|
922
|
+
if (!(actorIsOwner(actor, ownerId) || actorIsCos(actor))) return null;
|
|
923
|
+
const snap = await loadSubkeyRecords(deps);
|
|
924
|
+
return (snap.history || []).map((ev) => ({
|
|
925
|
+
key_id: ev.key_id || null,
|
|
926
|
+
event: ev.event || null,
|
|
927
|
+
at: ev.at || null,
|
|
928
|
+
client_id: ev.client_id || null,
|
|
929
|
+
session_id: ev.session_id || null,
|
|
930
|
+
open_id: ev.open_id || null
|
|
931
|
+
}));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export async function dispatchSubkey(name, deps, actor, ownerId, args, registry, now) {
|
|
935
|
+
const a = args || {};
|
|
936
|
+
if (name === 'emet_subkey_request') return requestSubkey(deps, actor, a, registry, now);
|
|
937
|
+
if (name === 'emet_subkey_claim') return claimSubkey(deps, actor, a, now);
|
|
938
|
+
if (name === 'emet_subkey_revoke') return revokeSubkey(deps, actor, ownerId, a, now);
|
|
939
|
+
if (name === 'emet_subkey_end_session') return endSubkeySession(deps, actor, ownerId, a, now);
|
|
940
|
+
throw refusal(`unknown sub-key tool ${name}. Nothing was changed.`);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export default {
|
|
944
|
+
requestSubkey, claimSubkey, revokeSubkey, endSubkeySession,
|
|
945
|
+
decideSubkeyIfPending, prepareSubkeyCall, commitLiveSession, releaseClosedSession,
|
|
946
|
+
subkeysView, readSubkeyHistory, dispatchSubkey, verifyMac, macEqual, splitKeyToken, pepperState
|
|
947
|
+
};
|