toiljs 0.0.85 → 0.0.86
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/CHANGELOG.md +5 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/generate.js +3 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +44 -5
- package/build/compiler/toil-docs.generated.js +6 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +7 -3
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +170 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/index.md +102 -0
- package/docs/auth/usage.md +188 -0
- package/docs/auth.md +6 -0
- package/examples/basic/client/routes/analytics.tsx +22 -12
- package/examples/basic/server/models/SiteAnalytics.ts +130 -17
- package/examples/basic/server/routes/Analytics.ts +68 -17
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +5 -1
- package/scripts/gen-toil-docs.mjs +15 -6
- package/server/auth/AuthController.ts +335 -0
- package/server/auth/AuthUser.ts +49 -0
- package/server/auth/index.ts +16 -0
- package/server/globals/auth.ts +31 -0
- package/server/globals/userid.ts +107 -0
- package/src/compiler/config.ts +13 -0
- package/src/compiler/generate.ts +3 -2
- package/src/compiler/index.ts +74 -5
- package/src/compiler/toil-docs.generated.ts +6 -1
- package/src/devserver/analytics/index.ts +10 -4
- package/test/analytics-dev.test.ts +2 -1
|
@@ -1,25 +1,138 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
1
|
+
/**
|
|
2
|
+
* This site's own analytics snapshot, mapped 1:1 from the toilscript `Analytics.self()` frame.
|
|
3
|
+
*
|
|
4
|
+
* Every `total*` field is an ALL-TIME CUMULATIVE COUNT — a running total since the site was first
|
|
5
|
+
* seen, monotonically increasing, never reset by a query. They are NOT "last hour" or "last minute":
|
|
6
|
+
* for time-windowed history use the separate `SeriesData` time-series (`/series`).
|
|
7
|
+
*
|
|
8
|
+
* All counter fields are `u64` (the guest exposes `u64` getters, so the mapping needs zero casts). The
|
|
9
|
+
* `live*` gauges are the current instantaneous level (not a total). The request-window fields are the
|
|
10
|
+
* current rate-limit bucket usage paired with the plan cap (cap `0` = unlimited).
|
|
11
|
+
*/
|
|
3
12
|
@data
|
|
4
13
|
export class SiteAnalytics {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// --- Requests / L1 edge (all-time totals) ---
|
|
15
|
+
/** Total HTTP requests served, all-time. */
|
|
16
|
+
totalRequests: u64 = 0;
|
|
17
|
+
/** Total response bytes sent from the L1 edge, all-time. */
|
|
18
|
+
totalBytesOutL1: u64 = 0;
|
|
19
|
+
/** Total request bytes received at the L1 edge, all-time. */
|
|
20
|
+
totalBytesInL1: u64 = 0;
|
|
21
|
+
/** Total 2xx responses, all-time. */
|
|
22
|
+
totalStatus2xx: u64 = 0;
|
|
23
|
+
/** Total 3xx responses, all-time. */
|
|
24
|
+
totalStatus3xx: u64 = 0;
|
|
25
|
+
/** Total 4xx responses, all-time. */
|
|
26
|
+
totalStatus4xx: u64 = 0;
|
|
27
|
+
/** Total 5xx responses, all-time. */
|
|
28
|
+
totalStatus5xx: u64 = 0;
|
|
29
|
+
/** Total static-asset hits (served without a wasm dispatch), all-time. */
|
|
30
|
+
totalStaticHits: u64 = 0;
|
|
31
|
+
/** Total wasm handler dispatches, all-time. */
|
|
32
|
+
totalWasmDispatches: u64 = 0;
|
|
33
|
+
/** Total requests rejected because the executor pool was full, all-time. */
|
|
34
|
+
totalExecutorFullRejects: u64 = 0;
|
|
35
|
+
/** Total requests rejected for an unknown host, all-time. */
|
|
36
|
+
totalUnknownHostRejects: u64 = 0;
|
|
37
|
+
/** Total requests rejected by rate limiting, all-time. */
|
|
38
|
+
totalRateLimitedRejects: u64 = 0;
|
|
39
|
+
/** Total wasm gas consumed across all dispatches, all-time. */
|
|
40
|
+
totalGasUsed: u64 = 0;
|
|
41
|
+
|
|
42
|
+
// --- Database (all-time totals) ---
|
|
43
|
+
/** Total ToilDB operations issued, all-time. */
|
|
44
|
+
totalDbOps: u64 = 0;
|
|
45
|
+
/** Total ToilDB read operations, all-time. */
|
|
46
|
+
totalDbReads: u64 = 0;
|
|
47
|
+
/** Total ToilDB write operations, all-time. */
|
|
48
|
+
totalDbWrites: u64 = 0;
|
|
49
|
+
/** Total ToilDB operations that errored, all-time. */
|
|
50
|
+
totalDbErrors: u64 = 0;
|
|
51
|
+
/** Summed host-observed ToilDB op latency in nanoseconds, all-time (divide by totalDbOps for a mean). */
|
|
52
|
+
totalDbLatencyNsSum: u64 = 0;
|
|
53
|
+
|
|
54
|
+
// --- Streams / WebTransport (all-time totals) ---
|
|
55
|
+
/** Total stream connections accepted, all-time. */
|
|
56
|
+
totalStreamAccepts: u64 = 0;
|
|
57
|
+
/** Total streams rejected because they landed on the wrong node, all-time. */
|
|
58
|
+
totalStreamRejectWrongNode: u64 = 0;
|
|
59
|
+
/** Total streams rejected for capacity, all-time. */
|
|
60
|
+
totalStreamRejectCapacity: u64 = 0;
|
|
61
|
+
/** Total streams rejected for a missing/invalid artifact, all-time. */
|
|
62
|
+
totalStreamRejectArtifact: u64 = 0;
|
|
63
|
+
/** Total streams rejected by the guest, all-time. */
|
|
64
|
+
totalStreamRejectGuest: u64 = 0;
|
|
65
|
+
/** Total stream guest traps, all-time. */
|
|
66
|
+
totalStreamTraps: u64 = 0;
|
|
67
|
+
/** Total streams closed for idle timeout, all-time. */
|
|
68
|
+
totalStreamIdleTimeouts: u64 = 0;
|
|
69
|
+
/** Total bytes received on streams, all-time. */
|
|
70
|
+
totalStreamBytesIn: u64 = 0;
|
|
71
|
+
/** Total bytes sent on streams, all-time. */
|
|
72
|
+
totalStreamBytesOut: u64 = 0;
|
|
73
|
+
/** Total stream backpressure events, all-time. */
|
|
74
|
+
totalStreamBackpressureEvents: u64 = 0;
|
|
75
|
+
/** Total clean stream closes, all-time. */
|
|
76
|
+
totalStreamCloses: u64 = 0;
|
|
77
|
+
/** Total stream disconnects (abrupt), all-time. */
|
|
78
|
+
totalStreamDisconnects: u64 = 0;
|
|
79
|
+
|
|
80
|
+
// --- Daemons / L4 (all-time totals) ---
|
|
81
|
+
/** Total daemon starts, all-time. */
|
|
82
|
+
totalDaemonStarts: u64 = 0;
|
|
83
|
+
/** Total daemon start failures, all-time. */
|
|
84
|
+
totalDaemonStartFailures: u64 = 0;
|
|
85
|
+
/** Total daemon ticks fired, all-time. */
|
|
86
|
+
totalDaemonTicksFired: u64 = 0;
|
|
87
|
+
/** Total daemon ticks skipped because this node was not the leader, all-time. */
|
|
88
|
+
totalDaemonTicksSkippedNotLeader: u64 = 0;
|
|
89
|
+
/** Total daemon ticks that failed, all-time. */
|
|
90
|
+
totalDaemonTicksFailed: u64 = 0;
|
|
91
|
+
/** Total daemon leadership acquisitions, all-time. */
|
|
92
|
+
totalDaemonLeaderAcquires: u64 = 0;
|
|
93
|
+
/** Total daemon leadership fences (lost leadership), all-time. */
|
|
94
|
+
totalDaemonLeaderFenced: u64 = 0;
|
|
95
|
+
/** Total daemon outbound http_call attempts, all-time. */
|
|
96
|
+
totalDaemonHttpCallAttempts: u64 = 0;
|
|
97
|
+
/** Total daemon outbound http_call failures, all-time. */
|
|
98
|
+
totalDaemonHttpCallFailures: u64 = 0;
|
|
99
|
+
|
|
100
|
+
// --- Memory / email / cache (all-time totals) ---
|
|
101
|
+
/** Total wasm linear-memory bytes grown, all-time. */
|
|
102
|
+
totalMemGrownBytes: u64 = 0;
|
|
103
|
+
/** Total emails sent, all-time. */
|
|
104
|
+
totalEmails: u64 = 0;
|
|
105
|
+
/** Total responses served from the edge cache, all-time. */
|
|
106
|
+
totalCacheHits: u64 = 0;
|
|
107
|
+
/** Total cacheable responses that missed the edge cache, all-time. */
|
|
108
|
+
totalCacheMisses: u64 = 0;
|
|
109
|
+
|
|
110
|
+
// --- Derived ---
|
|
111
|
+
/** Fraction of cacheable responses served from cache: hits / (hits + misses), in 0..1. 0 when there
|
|
112
|
+
* were no cacheable responses. */
|
|
113
|
+
cacheHitRatio: f64 = 0;
|
|
114
|
+
|
|
115
|
+
// --- Live gauges (current instantaneous level, NOT a total) ---
|
|
116
|
+
/** Streams currently connected right now. */
|
|
117
|
+
liveConnectedStreams: u64 = 0;
|
|
118
|
+
/** Wasm linear memory currently committed right now, in bytes. */
|
|
119
|
+
liveCommittedMemoryBytes: u64 = 0;
|
|
120
|
+
|
|
121
|
+
// --- Request windows: current rate-limit usage vs plan cap (cap 0 = unlimited) ---
|
|
122
|
+
/** Requests used in the current 1-minute window. */
|
|
123
|
+
requestsThisMinute: u64 = 0;
|
|
124
|
+
/** Plan cap for the 1-minute window (0 = unlimited). */
|
|
125
|
+
requestsThisMinuteCap: u64 = 0;
|
|
126
|
+
/** Requests used in the current 24-hour window. */
|
|
127
|
+
requestsToday: u64 = 0;
|
|
128
|
+
/** Plan cap for the 24-hour window (0 = unlimited). */
|
|
129
|
+
requestsTodayCap: u64 = 0;
|
|
18
130
|
}
|
|
19
131
|
|
|
20
|
-
/** One metric's historical
|
|
132
|
+
/** One metric's historical TIME-SERIES for a range, from `Analytics.series(metric, range)`: the raw
|
|
21
133
|
* per-bucket totals (oldest→newest) plus the bucket width + newest-bucket end, so the client can draw a
|
|
22
|
-
* graph and derive per-second rates.
|
|
134
|
+
* graph and derive per-second rates. This is per-bucket HISTORY — distinct from the all-time totals in
|
|
135
|
+
* `SiteAnalytics`. Demonstrates the typed series API. */
|
|
23
136
|
@data
|
|
24
137
|
export class SeriesData {
|
|
25
138
|
metric: i32 = 0;
|
|
@@ -16,23 +16,74 @@ import { SeriesData, SiteAnalytics } from '../models/SiteAnalytics';
|
|
|
16
16
|
class AnalyticsRoutes {
|
|
17
17
|
@get('/')
|
|
18
18
|
public self(): SiteAnalytics {
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
19
|
+
const stats = Analytics.self();
|
|
20
|
+
const out = new SiteAnalytics();
|
|
21
|
+
|
|
22
|
+
// Requests / L1 edge (all-time totals). Guest getters are u64, model fields are u64: no casts.
|
|
23
|
+
out.totalRequests = stats.requests;
|
|
24
|
+
out.totalBytesOutL1 = stats.bytesOutL1;
|
|
25
|
+
out.totalBytesInL1 = stats.bytesInL1;
|
|
26
|
+
out.totalStatus2xx = stats.status2xx;
|
|
27
|
+
out.totalStatus3xx = stats.status3xx;
|
|
28
|
+
out.totalStatus4xx = stats.status4xx;
|
|
29
|
+
out.totalStatus5xx = stats.status5xx;
|
|
30
|
+
out.totalStaticHits = stats.staticHits;
|
|
31
|
+
out.totalWasmDispatches = stats.wasmDispatches;
|
|
32
|
+
out.totalExecutorFullRejects = stats.executorFullRejects;
|
|
33
|
+
out.totalUnknownHostRejects = stats.unknownHostRejects;
|
|
34
|
+
out.totalRateLimitedRejects = stats.rateLimitedRejects;
|
|
35
|
+
out.totalGasUsed = stats.gasUsed;
|
|
36
|
+
|
|
37
|
+
// Database (all-time totals).
|
|
38
|
+
out.totalDbOps = stats.dbOps;
|
|
39
|
+
out.totalDbReads = stats.dbReads;
|
|
40
|
+
out.totalDbWrites = stats.dbWrites;
|
|
41
|
+
out.totalDbErrors = stats.dbErrors;
|
|
42
|
+
out.totalDbLatencyNsSum = stats.dbLatencyNsSum;
|
|
43
|
+
|
|
44
|
+
// Streams / WebTransport (all-time totals).
|
|
45
|
+
out.totalStreamAccepts = stats.streamAccepts;
|
|
46
|
+
out.totalStreamRejectWrongNode = stats.streamRejectWrongNode;
|
|
47
|
+
out.totalStreamRejectCapacity = stats.streamRejectCapacity;
|
|
48
|
+
out.totalStreamRejectArtifact = stats.streamRejectArtifact;
|
|
49
|
+
out.totalStreamRejectGuest = stats.streamRejectGuest;
|
|
50
|
+
out.totalStreamTraps = stats.streamTraps;
|
|
51
|
+
out.totalStreamIdleTimeouts = stats.streamIdleTimeouts;
|
|
52
|
+
out.totalStreamBytesIn = stats.streamBytesIn;
|
|
53
|
+
out.totalStreamBytesOut = stats.streamBytesOut;
|
|
54
|
+
out.totalStreamBackpressureEvents = stats.streamBackpressureEvents;
|
|
55
|
+
out.totalStreamCloses = stats.streamCloses;
|
|
56
|
+
out.totalStreamDisconnects = stats.streamDisconnects;
|
|
57
|
+
|
|
58
|
+
// Daemons / L4 (all-time totals).
|
|
59
|
+
out.totalDaemonStarts = stats.daemonStarts;
|
|
60
|
+
out.totalDaemonStartFailures = stats.daemonStartFailures;
|
|
61
|
+
out.totalDaemonTicksFired = stats.daemonTicksFired;
|
|
62
|
+
out.totalDaemonTicksSkippedNotLeader = stats.daemonTicksSkippedNotLeader;
|
|
63
|
+
out.totalDaemonTicksFailed = stats.daemonTicksFailed;
|
|
64
|
+
out.totalDaemonLeaderAcquires = stats.daemonLeaderAcquires;
|
|
65
|
+
out.totalDaemonLeaderFenced = stats.daemonLeaderFenced;
|
|
66
|
+
out.totalDaemonHttpCallAttempts = stats.daemonHttpCallAttempts;
|
|
67
|
+
out.totalDaemonHttpCallFailures = stats.daemonHttpCallFailures;
|
|
68
|
+
|
|
69
|
+
// Memory / email / cache (all-time totals) + the derived cache-hit ratio.
|
|
70
|
+
out.totalMemGrownBytes = stats.memGrownBytes;
|
|
71
|
+
out.totalEmails = stats.emails;
|
|
72
|
+
out.totalCacheHits = stats.cacheHits;
|
|
73
|
+
out.totalCacheMisses = stats.cacheMisses;
|
|
74
|
+
out.cacheHitRatio = stats.cacheRatio;
|
|
75
|
+
|
|
76
|
+
// Live gauges (current level, not a total).
|
|
77
|
+
out.liveConnectedStreams = stats.connectedStreams;
|
|
78
|
+
out.liveCommittedMemoryBytes = stats.committedMemory;
|
|
79
|
+
|
|
80
|
+
// Request windows: current usage vs plan cap (cap 0 = unlimited).
|
|
81
|
+
out.requestsThisMinute = stats.reqMinuteUsed;
|
|
82
|
+
out.requestsThisMinuteCap = stats.reqMinuteCap;
|
|
83
|
+
out.requestsToday = stats.reqDayUsed;
|
|
84
|
+
out.requestsTodayCap = stats.reqDayCap;
|
|
85
|
+
|
|
86
|
+
return out;
|
|
36
87
|
}
|
|
37
88
|
|
|
38
89
|
/// Any metric's historical time-series for a range, for graphs:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Response } from 'toiljs/server/runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Demo of `ToilUserId` — the stable 256-bit user identity derived from the ML-DSA login public key +
|
|
5
|
+
* identifier (email/username) + tenant domain. Deterministic, opaque, O(1) to compare with `==` / `!=`.
|
|
6
|
+
*/
|
|
7
|
+
@rest('userid')
|
|
8
|
+
class UserIdDemo {
|
|
9
|
+
@get('/demo')
|
|
10
|
+
public demo(): Response {
|
|
11
|
+
const key = new Uint8Array(1312); // an ML-DSA-44 public key (zeros for the demo)
|
|
12
|
+
const a = ToilUserId.derive(key, 'alice@example.com', 'acme.dacely.com');
|
|
13
|
+
const b = ToilUserId.derive(key, 'alice@example.com', 'acme.dacely.com');
|
|
14
|
+
const c = ToilUserId.derive(key, 'bob@example.com', 'acme.dacely.com');
|
|
15
|
+
// Same inputs => same id (a == b); a different identifier => a different id (a != c).
|
|
16
|
+
const same = a == b;
|
|
17
|
+
const diff = a != c;
|
|
18
|
+
return Response.text(
|
|
19
|
+
a.toHex() + '\nsame=' + same.toString() + ' diff=' + diff.toString() + '\n',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.86",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -67,6 +67,10 @@
|
|
|
67
67
|
"types": "./server/runtime/*.ts",
|
|
68
68
|
"default": "./server/runtime/*.ts"
|
|
69
69
|
},
|
|
70
|
+
"./server/auth": {
|
|
71
|
+
"types": "./server/auth/index.ts",
|
|
72
|
+
"default": "./server/auth/index.ts"
|
|
73
|
+
},
|
|
70
74
|
"./tsconfig": "./presets/tsconfig.json",
|
|
71
75
|
"./eslint": "./presets/eslint.js",
|
|
72
76
|
"./prettier": "./presets/prettier.json",
|
|
@@ -58,13 +58,22 @@ const ORDER = [
|
|
|
58
58
|
];
|
|
59
59
|
|
|
60
60
|
function collect() {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// Recurse one+ levels so a topic FOLDER (e.g. `docs/auth/*.md`) is bundled too, keyed by its
|
|
62
|
+
// repo-relative path (`auth/index.md`). Adding a `docs/**/*.md` is picked up automatically.
|
|
63
|
+
const files = [];
|
|
64
|
+
const walk = (dir, prefix) => {
|
|
65
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
66
|
+
if (ent.isDirectory()) {
|
|
67
|
+
walk(path.join(dir, ent.name), prefix + ent.name + '/');
|
|
68
|
+
} else if (ent.name.endsWith('.md') && !EXCLUDE.has(prefix + ent.name) && !EXCLUDE.has(ent.name)) {
|
|
69
|
+
files.push(prefix + ent.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(docsDir, '');
|
|
65
74
|
const ordered = [
|
|
66
|
-
...ORDER.filter((f) =>
|
|
67
|
-
...
|
|
75
|
+
...ORDER.filter((f) => files.includes(f)),
|
|
76
|
+
...files.filter((f) => !ORDER.includes(f)),
|
|
68
77
|
];
|
|
69
78
|
const out = {};
|
|
70
79
|
for (const name of ordered) {
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
|
+
import { DataReader, DataWriter } from 'data';
|
|
3
|
+
|
|
4
|
+
import { encodeSessionUser } from './AuthUser';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Built-in Toil PQ-Auth controller: the full post-quantum password-login API
|
|
8
|
+
* (`/auth/register|login/start|finish`) plus sessions (`/auth/me`, `/auth/logout`),
|
|
9
|
+
* mounted automatically when an app opts in with `server: { auth: true }` (or
|
|
10
|
+
* `import 'toiljs/server/auth'`). It is a framework-shipped SOURCE file the build
|
|
11
|
+
* APPENDS to the toilscript entry set, so its `@data`/`@database`/`@rest` decorators
|
|
12
|
+
* weave and its `@rest` class self-mounts at `/auth/*` — no hand-written boilerplate.
|
|
13
|
+
*
|
|
14
|
+
* The password never leaves the browser. The client blinds it through the
|
|
15
|
+
* server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
|
|
16
|
+
* output with Argon2id into an ML-DSA-44 keypair, and registers only the public
|
|
17
|
+
* key (+ a proof-of-possession). Login is a challenge-response that also runs an
|
|
18
|
+
* ML-KEM-768 key encapsulation: the server proves its identity by returning a
|
|
19
|
+
* confirmation tag only derivable from the decapsulated shared secret (mutual
|
|
20
|
+
* auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
|
|
21
|
+
* half in `toiljs/client` (`Auth.register` / `Auth.login`).
|
|
22
|
+
*
|
|
23
|
+
* STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
|
|
24
|
+
* collection keyed by username; login challenges are a `record` consumed exactly
|
|
25
|
+
* once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
|
|
26
|
+
* `env.data.*` host imports in process (so register -> login spans requests under
|
|
27
|
+
* `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
|
|
28
|
+
*
|
|
29
|
+
* Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
|
|
30
|
+
*
|
|
31
|
+
* Tuning (audience, TTLs, Argon2id params, rate-limit tuples) is fixed here for
|
|
32
|
+
* now; config-driven overrides are a documented follow-up. The secret trio
|
|
33
|
+
* (`AUTH_SESSION_SECRET` / `AUTH_OPRF_SEED` / `AUTH_KEM_SK`) resolves lazily from
|
|
34
|
+
* the tenant env store with DEV fallbacks, so this runs with zero config.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// Demo-light Argon2id params (responsive in a browser tab). A real deployment
|
|
38
|
+
// uses >= 256 MiB / >= 3 iterations. The client derives against whatever it is
|
|
39
|
+
// handed, so this is the single source of truth.
|
|
40
|
+
const MEM_KIB: u32 = 32768; // 32 MiB
|
|
41
|
+
const ITERS: u32 = 2;
|
|
42
|
+
const PAR: u32 = 1;
|
|
43
|
+
|
|
44
|
+
const CHALLENGE_TTL_SECS: u64 = 120;
|
|
45
|
+
const SESSION_TTL_SECS: u64 = 3600;
|
|
46
|
+
|
|
47
|
+
// Resolved-and-cached per instance; the audience id (server config, never
|
|
48
|
+
// client-echoed). Read lazily (not at module top level) so the env host import
|
|
49
|
+
// runs during a dispatch, when the request host is bound — the same pattern
|
|
50
|
+
// AuthService uses for its secrets.
|
|
51
|
+
let __aud: string | null = null;
|
|
52
|
+
function aud(): string {
|
|
53
|
+
const cached = __aud;
|
|
54
|
+
if (cached != null) return cached;
|
|
55
|
+
const v = Environment.getSecure('TOIL_AUTH_AUDIENCE');
|
|
56
|
+
const resolved = v != null ? v : 'toil';
|
|
57
|
+
__aud = resolved;
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The tenant domain the stable {@link ToilUserId} is scoped to: the configured
|
|
63
|
+
* `TOIL_AUTH_DOMAIN` if set, else the request's `Host` header, else `localhost`.
|
|
64
|
+
* It only needs to be stable per deployment (it is one of the SHA-256 inputs of
|
|
65
|
+
* the user id), so any robust per-deployment host source is fine.
|
|
66
|
+
*/
|
|
67
|
+
function authDomain(ctx: RouteContext): string {
|
|
68
|
+
const configured = Environment.getSecure('TOIL_AUTH_DOMAIN');
|
|
69
|
+
if (configured != null) return configured;
|
|
70
|
+
const host = ctx.request.header('host');
|
|
71
|
+
if (host != null) return host;
|
|
72
|
+
return 'localhost';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function randomBytes(n: i32): Uint8Array {
|
|
76
|
+
const b = new Uint8Array(n);
|
|
77
|
+
crypto.getRandomValues(b);
|
|
78
|
+
return b;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function nowSecs(): u64 {
|
|
82
|
+
return <u64>(Date.now() / 1000);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One generic error on every failure path (anti-enumeration, anti-oracle). */
|
|
86
|
+
function fail(): Response {
|
|
87
|
+
return Response.text('auth: request failed\n', 401);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
|
|
92
|
+
* precomputation resistance, a public/deterministic salt is fine: it only needs
|
|
93
|
+
* to be unique per user (the OPRF output already differs per user). Making it
|
|
94
|
+
* deterministic means register and login agree with NO stored salt, and an
|
|
95
|
+
* unknown user yields the SAME stable salt as a known one would -- no
|
|
96
|
+
* enumeration oracle.
|
|
97
|
+
*/
|
|
98
|
+
function deriveSalt(username: string): Uint8Array {
|
|
99
|
+
return crypto.sha256Text('toil-auth-salt-v1:' + username).slice(0, 16);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@data
|
|
103
|
+
class Username {
|
|
104
|
+
name: string = '';
|
|
105
|
+
constructor(name: string = '') {
|
|
106
|
+
this.name = name;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@data
|
|
111
|
+
class ChallengeId {
|
|
112
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
113
|
+
constructor(cid: Uint8Array = new Uint8Array(0)) {
|
|
114
|
+
this.cid = cid;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@data
|
|
119
|
+
class AuthAccount {
|
|
120
|
+
username: string = '';
|
|
121
|
+
salt: Uint8Array = new Uint8Array(0);
|
|
122
|
+
publicKey: Uint8Array = new Uint8Array(0);
|
|
123
|
+
memKiB: u32 = 0;
|
|
124
|
+
iterations: u32 = 0;
|
|
125
|
+
parallelism: u32 = 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@data
|
|
129
|
+
class Challenge {
|
|
130
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
131
|
+
username: string = '';
|
|
132
|
+
nonce: Uint8Array = new Uint8Array(0);
|
|
133
|
+
iat: u64 = 0;
|
|
134
|
+
exp: u64 = 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@database
|
|
138
|
+
class AuthDb {
|
|
139
|
+
@collection static accounts: Documents<Username, AuthAccount>;
|
|
140
|
+
@collection static challenges: Documents<ChallengeId, Challenge>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@rest('auth')
|
|
144
|
+
class Auth {
|
|
145
|
+
/** POST /auth/register/start body: str(username) bytes(blinded)
|
|
146
|
+
* resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
|
|
147
|
+
* No taken-oracle: always succeeds; register/finish rejects a duplicate. */
|
|
148
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
149
|
+
@post('/register/start')
|
|
150
|
+
public registerStart(ctx: RouteContext): Response {
|
|
151
|
+
const r = new DataReader(ctx.request.body);
|
|
152
|
+
const username = r.readString();
|
|
153
|
+
const blinded = r.readBytes();
|
|
154
|
+
if (!r.ok) return fail();
|
|
155
|
+
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
156
|
+
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
157
|
+
|
|
158
|
+
const w = new DataWriter();
|
|
159
|
+
w.writeU8(0);
|
|
160
|
+
w.writeU32(MEM_KIB);
|
|
161
|
+
w.writeU32(ITERS);
|
|
162
|
+
w.writeU32(PAR);
|
|
163
|
+
w.writeBytes(deriveSalt(username));
|
|
164
|
+
w.writeBytes(evaluated);
|
|
165
|
+
return Response.bytes(w.toBytes());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
|
|
169
|
+
* resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
|
|
170
|
+
* proof-of-possession before storing the key. */
|
|
171
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
172
|
+
@post('/register/finish')
|
|
173
|
+
public registerFinish(ctx: RouteContext): Response {
|
|
174
|
+
const r = new DataReader(ctx.request.body);
|
|
175
|
+
const username = r.readString();
|
|
176
|
+
const pk = r.readBytes();
|
|
177
|
+
const proof = r.readBytes();
|
|
178
|
+
if (!r.ok) return fail();
|
|
179
|
+
if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
|
|
180
|
+
// Already registered: a distinguishable status (not the generic 401) so the
|
|
181
|
+
// client can say "username taken, log in instead" rather than a blank error.
|
|
182
|
+
if (AuthDb.accounts.exists(new Username(username))) {
|
|
183
|
+
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Proof-of-possession: the client signed buildRegisterMessage with the
|
|
187
|
+
// matching secret key, so we confirm it actually holds it.
|
|
188
|
+
const regMsg = AuthService.buildRegisterMessage(username, pk);
|
|
189
|
+
if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
|
|
190
|
+
|
|
191
|
+
const a = new AuthAccount();
|
|
192
|
+
a.username = username;
|
|
193
|
+
a.salt = deriveSalt(username);
|
|
194
|
+
a.publicKey = pk;
|
|
195
|
+
a.memKiB = MEM_KIB;
|
|
196
|
+
a.iterations = ITERS;
|
|
197
|
+
a.parallelism = PAR;
|
|
198
|
+
// create-if-absent: a racing duplicate registration loses here, not above.
|
|
199
|
+
if (!AuthDb.accounts.create(new Username(username), a)) {
|
|
200
|
+
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
201
|
+
}
|
|
202
|
+
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** POST /auth/login/start body: str(username) bytes(blinded)
|
|
206
|
+
* resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
|
|
207
|
+
* bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
|
|
208
|
+
* Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
|
|
209
|
+
* seed+username), returns a deterministic per-user salt + constant params,
|
|
210
|
+
* and a fresh challenge -- a known and an unknown user are indistinguishable. */
|
|
211
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
212
|
+
@post('/login/start')
|
|
213
|
+
public loginStart(ctx: RouteContext): Response {
|
|
214
|
+
const r = new DataReader(ctx.request.body);
|
|
215
|
+
const username = r.readString();
|
|
216
|
+
const blinded = r.readBytes();
|
|
217
|
+
if (!r.ok) return fail();
|
|
218
|
+
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
219
|
+
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
220
|
+
|
|
221
|
+
const known = AuthDb.accounts.exists(new Username(username));
|
|
222
|
+
const cid = randomBytes(16);
|
|
223
|
+
const nonce = randomBytes(32);
|
|
224
|
+
const iat = nowSecs();
|
|
225
|
+
const exp = iat + CHALLENGE_TTL_SECS;
|
|
226
|
+
|
|
227
|
+
// Persist only for a real account; the response is identical either way,
|
|
228
|
+
// and login/finish for an unknown user fails generically at consume.
|
|
229
|
+
if (known) {
|
|
230
|
+
const c = new Challenge();
|
|
231
|
+
c.cid = cid;
|
|
232
|
+
c.username = username;
|
|
233
|
+
c.nonce = nonce;
|
|
234
|
+
c.iat = iat;
|
|
235
|
+
c.exp = exp;
|
|
236
|
+
AuthDb.challenges.create(new ChallengeId(cid), c);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const w = new DataWriter();
|
|
240
|
+
w.writeBytes(cid);
|
|
241
|
+
w.writeString(aud());
|
|
242
|
+
w.writeU32(MEM_KIB);
|
|
243
|
+
w.writeU32(ITERS);
|
|
244
|
+
w.writeU32(PAR);
|
|
245
|
+
w.writeBytes(deriveSalt(username));
|
|
246
|
+
w.writeBytes(nonce);
|
|
247
|
+
w.writeU64(iat);
|
|
248
|
+
w.writeU64(exp);
|
|
249
|
+
w.writeBytes(evaluated);
|
|
250
|
+
return Response.bytes(w.toBytes());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
|
|
254
|
+
* resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
|
|
255
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
256
|
+
@post('/login/finish')
|
|
257
|
+
public loginFinish(ctx: RouteContext): Response {
|
|
258
|
+
const r = new DataReader(ctx.request.body);
|
|
259
|
+
const cid = r.readBytes();
|
|
260
|
+
const ct = r.readBytes();
|
|
261
|
+
const sig = r.readBytes();
|
|
262
|
+
if (!r.ok) return fail();
|
|
263
|
+
|
|
264
|
+
// 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
|
|
265
|
+
const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
|
|
266
|
+
if (ch == null) return fail();
|
|
267
|
+
if (nowSecs() >= ch.exp) return fail();
|
|
268
|
+
|
|
269
|
+
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
270
|
+
// the bound params + server key id), load the account key, verify.
|
|
271
|
+
const acct = AuthDb.accounts.get(new Username(ch.username));
|
|
272
|
+
if (acct == null) return fail();
|
|
273
|
+
const message = AuthService.buildLoginMessage(
|
|
274
|
+
ch.username,
|
|
275
|
+
aud(),
|
|
276
|
+
cid,
|
|
277
|
+
ch.nonce,
|
|
278
|
+
ch.iat,
|
|
279
|
+
ch.exp,
|
|
280
|
+
ct,
|
|
281
|
+
MEM_KIB,
|
|
282
|
+
ITERS,
|
|
283
|
+
PAR,
|
|
284
|
+
AuthService.serverKemKeyId(),
|
|
285
|
+
);
|
|
286
|
+
if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
|
|
287
|
+
|
|
288
|
+
// 3. Decapsulate (proves WE hold the KEM key), derive the session key K
|
|
289
|
+
// bound to the transcript, and build the confirmation tag the client
|
|
290
|
+
// verifies for mutual auth.
|
|
291
|
+
const sharedSecret = AuthService.mlkemDecapsulate(ct);
|
|
292
|
+
if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
|
|
293
|
+
const transcriptHash = AuthService.sha256(message);
|
|
294
|
+
const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
|
|
295
|
+
const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
|
|
296
|
+
|
|
297
|
+
// 4. Success: mint the session (the built-in @user codec, carrying the
|
|
298
|
+
// stable ToilUserId) and return {0, sessionToken, confirm}.
|
|
299
|
+
const domain = authDomain(ctx);
|
|
300
|
+
const userData = encodeSessionUser(acct.publicKey, ch.username, domain);
|
|
301
|
+
const w = new DataWriter();
|
|
302
|
+
w.writeU8(0);
|
|
303
|
+
w.writeBytes(userData); // opaque session token (the readable user payload)
|
|
304
|
+
w.writeBytes(confirm);
|
|
305
|
+
const resp = Response.bytes(w.toBytes());
|
|
306
|
+
resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
|
|
307
|
+
resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
|
|
308
|
+
return resp;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** GET /auth/me (@auth: 401 without a valid session) -> the typed user
|
|
312
|
+
* (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
|
|
313
|
+
* to the built-in `@user` with no type argument. */
|
|
314
|
+
@auth
|
|
315
|
+
@get('/me')
|
|
316
|
+
public me(_ctx: RouteContext): Response {
|
|
317
|
+
const u = AuthService.getUser();
|
|
318
|
+
if (u == null) return Response.text('no session\n', 401);
|
|
319
|
+
const w = new DataWriter();
|
|
320
|
+
w.writeBytes(u.toilUserId);
|
|
321
|
+
w.writeString(u.username);
|
|
322
|
+
return Response.bytes(w.toBytes());
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** POST /auth/logout (@auth) -> clears both the signed session and the
|
|
326
|
+
* readable companion cookie. */
|
|
327
|
+
@auth
|
|
328
|
+
@post('/logout')
|
|
329
|
+
public logout(_ctx: RouteContext): Response {
|
|
330
|
+
const resp = Response.text('bye\n', 200);
|
|
331
|
+
resp.setCookie(AuthService.clearSession());
|
|
332
|
+
resp.setCookie(AuthService.clearUserCookie());
|
|
333
|
+
return resp;
|
|
334
|
+
}
|
|
335
|
+
}
|