@wtfalch/keys 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/bin/copy.d.ts +31 -0
- package/dist/bin/copy.js +61 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +18 -0
- package/dist/held/aad.d.ts +13 -0
- package/dist/held/aad.js +25 -0
- package/dist/held/index.d.ts +134 -0
- package/dist/held/index.js +308 -0
- package/dist/held/schema.d.ts +278 -0
- package/dist/held/schema.js +43 -0
- package/dist/held/shred.d.ts +34 -0
- package/dist/held/shred.js +76 -0
- package/dist/issued/codec.d.ts +3 -0
- package/dist/issued/codec.js +7 -0
- package/dist/issued/encoding.d.ts +48 -0
- package/dist/issued/encoding.js +132 -0
- package/dist/issued/index.d.ts +88 -0
- package/dist/issued/index.js +273 -0
- package/dist/issued/secret.d.ts +30 -0
- package/dist/issued/secret.js +69 -0
- package/dist/issued/tables.d.ts +250 -0
- package/dist/issued/tables.js +35 -0
- package/dist/issued/verify.d.ts +8 -0
- package/dist/issued/verify.js +31 -0
- package/dist/migrations/0001_keys_issued.sql +80 -0
- package/dist/migrations/0002_keys_held.sql +60 -0
- package/dist/migrations/0003_keys_shred.sql +361 -0
- package/dist/worker-contract.d.ts +360 -0
- package/dist/worker-contract.js +94 -0
- package/package.json +52 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
export declare const keysHeldEntries: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
2
|
+
name: "keys_held_entries";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
tenantId: import("drizzle-orm/pg-core").PgColumn<{
|
|
6
|
+
name: "tenant_id";
|
|
7
|
+
tableName: "keys_held_entries";
|
|
8
|
+
dataType: "string";
|
|
9
|
+
columnType: "PgText";
|
|
10
|
+
data: string;
|
|
11
|
+
driverParam: string;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: false;
|
|
14
|
+
isPrimaryKey: false;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: [string, ...string[]];
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {}>;
|
|
22
|
+
entryId: import("drizzle-orm/pg-core").PgColumn<{
|
|
23
|
+
name: "entry_id";
|
|
24
|
+
tableName: "keys_held_entries";
|
|
25
|
+
dataType: "string";
|
|
26
|
+
columnType: "PgText";
|
|
27
|
+
data: string;
|
|
28
|
+
driverParam: string;
|
|
29
|
+
notNull: true;
|
|
30
|
+
hasDefault: false;
|
|
31
|
+
isPrimaryKey: false;
|
|
32
|
+
isAutoincrement: false;
|
|
33
|
+
hasRuntimeDefault: false;
|
|
34
|
+
enumValues: [string, ...string[]];
|
|
35
|
+
baseColumn: never;
|
|
36
|
+
identity: undefined;
|
|
37
|
+
generated: undefined;
|
|
38
|
+
}, {}, {}>;
|
|
39
|
+
currentVersion: import("drizzle-orm/pg-core").PgColumn<{
|
|
40
|
+
name: "current_version";
|
|
41
|
+
tableName: "keys_held_entries";
|
|
42
|
+
dataType: "number";
|
|
43
|
+
columnType: "PgInteger";
|
|
44
|
+
data: number;
|
|
45
|
+
driverParam: string | number;
|
|
46
|
+
notNull: true;
|
|
47
|
+
hasDefault: true;
|
|
48
|
+
isPrimaryKey: false;
|
|
49
|
+
isAutoincrement: false;
|
|
50
|
+
hasRuntimeDefault: false;
|
|
51
|
+
enumValues: undefined;
|
|
52
|
+
baseColumn: never;
|
|
53
|
+
identity: undefined;
|
|
54
|
+
generated: undefined;
|
|
55
|
+
}, {}, {}>;
|
|
56
|
+
revokedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
57
|
+
name: "revoked_at";
|
|
58
|
+
tableName: "keys_held_entries";
|
|
59
|
+
dataType: "date";
|
|
60
|
+
columnType: "PgTimestamp";
|
|
61
|
+
data: Date;
|
|
62
|
+
driverParam: string;
|
|
63
|
+
notNull: false;
|
|
64
|
+
hasDefault: false;
|
|
65
|
+
isPrimaryKey: false;
|
|
66
|
+
isAutoincrement: false;
|
|
67
|
+
hasRuntimeDefault: false;
|
|
68
|
+
enumValues: undefined;
|
|
69
|
+
baseColumn: never;
|
|
70
|
+
identity: undefined;
|
|
71
|
+
generated: undefined;
|
|
72
|
+
}, {}, {}>;
|
|
73
|
+
tenantArchivedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
74
|
+
name: "tenant_archived_at";
|
|
75
|
+
tableName: "keys_held_entries";
|
|
76
|
+
dataType: "date";
|
|
77
|
+
columnType: "PgTimestamp";
|
|
78
|
+
data: Date;
|
|
79
|
+
driverParam: string;
|
|
80
|
+
notNull: false;
|
|
81
|
+
hasDefault: false;
|
|
82
|
+
isPrimaryKey: false;
|
|
83
|
+
isAutoincrement: false;
|
|
84
|
+
hasRuntimeDefault: false;
|
|
85
|
+
enumValues: undefined;
|
|
86
|
+
baseColumn: never;
|
|
87
|
+
identity: undefined;
|
|
88
|
+
generated: undefined;
|
|
89
|
+
}, {}, {}>;
|
|
90
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
91
|
+
name: "created_at";
|
|
92
|
+
tableName: "keys_held_entries";
|
|
93
|
+
dataType: "date";
|
|
94
|
+
columnType: "PgTimestamp";
|
|
95
|
+
data: Date;
|
|
96
|
+
driverParam: string;
|
|
97
|
+
notNull: true;
|
|
98
|
+
hasDefault: true;
|
|
99
|
+
isPrimaryKey: false;
|
|
100
|
+
isAutoincrement: false;
|
|
101
|
+
hasRuntimeDefault: false;
|
|
102
|
+
enumValues: undefined;
|
|
103
|
+
baseColumn: never;
|
|
104
|
+
identity: undefined;
|
|
105
|
+
generated: undefined;
|
|
106
|
+
}, {}, {}>;
|
|
107
|
+
};
|
|
108
|
+
dialect: "pg";
|
|
109
|
+
}>;
|
|
110
|
+
/** Append-only in spirit: nothing here updates a row except `current_version`, `revoked_at` and, from #232 on, `wrapped_key`. */
|
|
111
|
+
export declare const keysHeldVersions: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
112
|
+
name: "keys_held_versions";
|
|
113
|
+
schema: undefined;
|
|
114
|
+
columns: {
|
|
115
|
+
tenantId: import("drizzle-orm/pg-core").PgColumn<{
|
|
116
|
+
name: "tenant_id";
|
|
117
|
+
tableName: "keys_held_versions";
|
|
118
|
+
dataType: "string";
|
|
119
|
+
columnType: "PgText";
|
|
120
|
+
data: string;
|
|
121
|
+
driverParam: string;
|
|
122
|
+
notNull: true;
|
|
123
|
+
hasDefault: false;
|
|
124
|
+
isPrimaryKey: false;
|
|
125
|
+
isAutoincrement: false;
|
|
126
|
+
hasRuntimeDefault: false;
|
|
127
|
+
enumValues: [string, ...string[]];
|
|
128
|
+
baseColumn: never;
|
|
129
|
+
identity: undefined;
|
|
130
|
+
generated: undefined;
|
|
131
|
+
}, {}, {}>;
|
|
132
|
+
entryId: import("drizzle-orm/pg-core").PgColumn<{
|
|
133
|
+
name: "entry_id";
|
|
134
|
+
tableName: "keys_held_versions";
|
|
135
|
+
dataType: "string";
|
|
136
|
+
columnType: "PgText";
|
|
137
|
+
data: string;
|
|
138
|
+
driverParam: string;
|
|
139
|
+
notNull: true;
|
|
140
|
+
hasDefault: false;
|
|
141
|
+
isPrimaryKey: false;
|
|
142
|
+
isAutoincrement: false;
|
|
143
|
+
hasRuntimeDefault: false;
|
|
144
|
+
enumValues: [string, ...string[]];
|
|
145
|
+
baseColumn: never;
|
|
146
|
+
identity: undefined;
|
|
147
|
+
generated: undefined;
|
|
148
|
+
}, {}, {}>;
|
|
149
|
+
version: import("drizzle-orm/pg-core").PgColumn<{
|
|
150
|
+
name: "version";
|
|
151
|
+
tableName: "keys_held_versions";
|
|
152
|
+
dataType: "number";
|
|
153
|
+
columnType: "PgInteger";
|
|
154
|
+
data: number;
|
|
155
|
+
driverParam: string | number;
|
|
156
|
+
notNull: true;
|
|
157
|
+
hasDefault: false;
|
|
158
|
+
isPrimaryKey: false;
|
|
159
|
+
isAutoincrement: false;
|
|
160
|
+
hasRuntimeDefault: false;
|
|
161
|
+
enumValues: undefined;
|
|
162
|
+
baseColumn: never;
|
|
163
|
+
identity: undefined;
|
|
164
|
+
generated: undefined;
|
|
165
|
+
}, {}, {}>;
|
|
166
|
+
kekId: import("drizzle-orm/pg-core").PgColumn<{
|
|
167
|
+
name: "kek_id";
|
|
168
|
+
tableName: "keys_held_versions";
|
|
169
|
+
dataType: "string";
|
|
170
|
+
columnType: "PgText";
|
|
171
|
+
data: string;
|
|
172
|
+
driverParam: string;
|
|
173
|
+
notNull: true;
|
|
174
|
+
hasDefault: false;
|
|
175
|
+
isPrimaryKey: false;
|
|
176
|
+
isAutoincrement: false;
|
|
177
|
+
hasRuntimeDefault: false;
|
|
178
|
+
enumValues: [string, ...string[]];
|
|
179
|
+
baseColumn: never;
|
|
180
|
+
identity: undefined;
|
|
181
|
+
generated: undefined;
|
|
182
|
+
}, {}, {}>;
|
|
183
|
+
wrappedKey: import("drizzle-orm/pg-core").PgColumn<{
|
|
184
|
+
name: "wrapped_key";
|
|
185
|
+
tableName: "keys_held_versions";
|
|
186
|
+
dataType: "custom";
|
|
187
|
+
columnType: "PgCustomColumn";
|
|
188
|
+
data: Buffer<ArrayBufferLike>;
|
|
189
|
+
driverParam: Buffer<ArrayBufferLike>;
|
|
190
|
+
notNull: false;
|
|
191
|
+
hasDefault: false;
|
|
192
|
+
isPrimaryKey: false;
|
|
193
|
+
isAutoincrement: false;
|
|
194
|
+
hasRuntimeDefault: false;
|
|
195
|
+
enumValues: undefined;
|
|
196
|
+
baseColumn: never;
|
|
197
|
+
identity: undefined;
|
|
198
|
+
generated: undefined;
|
|
199
|
+
}, {}, {
|
|
200
|
+
pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
|
|
201
|
+
}>;
|
|
202
|
+
iv: import("drizzle-orm/pg-core").PgColumn<{
|
|
203
|
+
name: "iv";
|
|
204
|
+
tableName: "keys_held_versions";
|
|
205
|
+
dataType: "custom";
|
|
206
|
+
columnType: "PgCustomColumn";
|
|
207
|
+
data: Buffer<ArrayBufferLike>;
|
|
208
|
+
driverParam: Buffer<ArrayBufferLike>;
|
|
209
|
+
notNull: true;
|
|
210
|
+
hasDefault: false;
|
|
211
|
+
isPrimaryKey: false;
|
|
212
|
+
isAutoincrement: false;
|
|
213
|
+
hasRuntimeDefault: false;
|
|
214
|
+
enumValues: undefined;
|
|
215
|
+
baseColumn: never;
|
|
216
|
+
identity: undefined;
|
|
217
|
+
generated: undefined;
|
|
218
|
+
}, {}, {
|
|
219
|
+
pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
|
|
220
|
+
}>;
|
|
221
|
+
ciphertext: import("drizzle-orm/pg-core").PgColumn<{
|
|
222
|
+
name: "ciphertext";
|
|
223
|
+
tableName: "keys_held_versions";
|
|
224
|
+
dataType: "custom";
|
|
225
|
+
columnType: "PgCustomColumn";
|
|
226
|
+
data: Buffer<ArrayBufferLike>;
|
|
227
|
+
driverParam: Buffer<ArrayBufferLike>;
|
|
228
|
+
notNull: true;
|
|
229
|
+
hasDefault: false;
|
|
230
|
+
isPrimaryKey: false;
|
|
231
|
+
isAutoincrement: false;
|
|
232
|
+
hasRuntimeDefault: false;
|
|
233
|
+
enumValues: undefined;
|
|
234
|
+
baseColumn: never;
|
|
235
|
+
identity: undefined;
|
|
236
|
+
generated: undefined;
|
|
237
|
+
}, {}, {
|
|
238
|
+
pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
|
|
239
|
+
}>;
|
|
240
|
+
retiredAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
241
|
+
name: "retired_at";
|
|
242
|
+
tableName: "keys_held_versions";
|
|
243
|
+
dataType: "date";
|
|
244
|
+
columnType: "PgTimestamp";
|
|
245
|
+
data: Date;
|
|
246
|
+
driverParam: string;
|
|
247
|
+
notNull: false;
|
|
248
|
+
hasDefault: false;
|
|
249
|
+
isPrimaryKey: false;
|
|
250
|
+
isAutoincrement: false;
|
|
251
|
+
hasRuntimeDefault: false;
|
|
252
|
+
enumValues: undefined;
|
|
253
|
+
baseColumn: never;
|
|
254
|
+
identity: undefined;
|
|
255
|
+
generated: undefined;
|
|
256
|
+
}, {}, {}>;
|
|
257
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
258
|
+
name: "created_at";
|
|
259
|
+
tableName: "keys_held_versions";
|
|
260
|
+
dataType: "date";
|
|
261
|
+
columnType: "PgTimestamp";
|
|
262
|
+
data: Date;
|
|
263
|
+
driverParam: string;
|
|
264
|
+
notNull: true;
|
|
265
|
+
hasDefault: true;
|
|
266
|
+
isPrimaryKey: false;
|
|
267
|
+
isAutoincrement: false;
|
|
268
|
+
hasRuntimeDefault: false;
|
|
269
|
+
enumValues: undefined;
|
|
270
|
+
baseColumn: never;
|
|
271
|
+
identity: undefined;
|
|
272
|
+
generated: undefined;
|
|
273
|
+
}, {}, {}>;
|
|
274
|
+
};
|
|
275
|
+
dialect: "pg";
|
|
276
|
+
}>;
|
|
277
|
+
export type KeysHeldEntryRow = typeof keysHeldEntries.$inferSelect;
|
|
278
|
+
export type KeysHeldVersionRow = typeof keysHeldVersions.$inferSelect;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { customType, index, integer, pgTable, primaryKey, text, timestamp, } from 'drizzle-orm/pg-core';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors `migrations/0002_keys_held.sql`, which is the source of truth: the
|
|
4
|
+
* SQL carries the CHECKs and the foreign key that drizzle-kit does not
|
|
5
|
+
* generate. Unlike `@wtfalch/audit`'s tables, a host never declares its own
|
|
6
|
+
* columns alongside these -- `./held` owns the whole row, so the tables stay
|
|
7
|
+
* internal to this module rather than a parameter a host supplies.
|
|
8
|
+
*
|
|
9
|
+
* `bytea`, the same way `app-template/src/lib/db/keys.ts` does it: drizzle's
|
|
10
|
+
* Postgres dialect has no built-in bytea column.
|
|
11
|
+
*/
|
|
12
|
+
const bytea = customType({
|
|
13
|
+
dataType: () => 'bytea',
|
|
14
|
+
});
|
|
15
|
+
export const keysHeldEntries = pgTable('keys_held_entries', {
|
|
16
|
+
tenantId: text('tenant_id').notNull(),
|
|
17
|
+
entryId: text('entry_id').notNull(),
|
|
18
|
+
currentVersion: integer('current_version').notNull().default(0),
|
|
19
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
20
|
+
/**
|
|
21
|
+
* Set once, by `archiveTenant`, on every entry row this tenant holds.
|
|
22
|
+
* This package has no `tenants` table of its own, so "tenant archived"
|
|
23
|
+
* -- one of #232's three shred-eligibility clocks -- is recorded here,
|
|
24
|
+
* per entry, rather than on a row this package doesn't own.
|
|
25
|
+
*/
|
|
26
|
+
tenantArchivedAt: timestamp('tenant_archived_at', { withTimezone: true }),
|
|
27
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
28
|
+
}, (table) => [primaryKey({ columns: [table.tenantId, table.entryId] })]);
|
|
29
|
+
/** Append-only in spirit: nothing here updates a row except `current_version`, `revoked_at` and, from #232 on, `wrapped_key`. */
|
|
30
|
+
export const keysHeldVersions = pgTable('keys_held_versions', {
|
|
31
|
+
tenantId: text('tenant_id').notNull(),
|
|
32
|
+
entryId: text('entry_id').notNull(),
|
|
33
|
+
version: integer('version').notNull(),
|
|
34
|
+
kekId: text('kek_id').notNull(),
|
|
35
|
+
wrappedKey: bytea('wrapped_key'),
|
|
36
|
+
iv: bytea('iv').notNull(),
|
|
37
|
+
ciphertext: bytea('ciphertext').notNull(),
|
|
38
|
+
retiredAt: timestamp('retired_at', { withTimezone: true }),
|
|
39
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
40
|
+
}, (table) => [
|
|
41
|
+
primaryKey({ columns: [table.tenantId, table.entryId, table.version] }),
|
|
42
|
+
index('keys_held_versions_kek_idx').on(table.kekId),
|
|
43
|
+
]);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { HeldKeysDb } from './index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calls `keys_shred_expired()` -- 0003_keys_shred.sql's `SECURITY DEFINER`
|
|
4
|
+
* sweep -- and returns how many `keys_held_versions` rows it shredded. Takes
|
|
5
|
+
* no row-targeting argument itself, matching the function: the function
|
|
6
|
+
* decides eligibility on its own from `retired_at`, `revoked_at` and
|
|
7
|
+
* `tenant_archived_at`, never from anything this caller supplies.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shredExpired(db: HeldKeysDb): Promise<number>;
|
|
10
|
+
/** Which of #232's three shred-eligibility clocks a version's is. */
|
|
11
|
+
export type ShredClockReason = 'retired' | 'entry_revoked' | 'tenant_archived';
|
|
12
|
+
export interface ShredClock {
|
|
13
|
+
readonly tenantId: string;
|
|
14
|
+
readonly entryId: string;
|
|
15
|
+
readonly version: number;
|
|
16
|
+
readonly reason: ShredClockReason;
|
|
17
|
+
readonly startedAt: Date;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Every still-wrapped version whose shred clock started after `since` -- the
|
|
21
|
+
* earliest of its own `retired_at`, its entry's `revoked_at`, or its
|
|
22
|
+
* tenant's `tenant_archived_at`, whichever is set. #225's revised answer:
|
|
23
|
+
* "the write that starts the clock is itself an alert" -- a host's
|
|
24
|
+
* housekeeping tick calls this once a tick, with `since` set to the last
|
|
25
|
+
* tick's time, and alerts on a non-empty result, so an operator can reverse
|
|
26
|
+
* a forged retire/revoke/archive inside the 30-day
|
|
27
|
+
* `KEYSTORE_SHRED_DELAY_DAYS` window before `keys_shred_expired()` fires.
|
|
28
|
+
*
|
|
29
|
+
* A row already shredded (`wrapped_key is null`) has no clock left to alert
|
|
30
|
+
* on and is excluded.
|
|
31
|
+
*/
|
|
32
|
+
export declare function shredClocks(db: HeldKeysDb, options: {
|
|
33
|
+
readonly since: Date;
|
|
34
|
+
}): Promise<readonly ShredClock[]>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { keysHeldEntries, keysHeldVersions } from './schema.js';
|
|
3
|
+
/**
|
|
4
|
+
* Calls `keys_shred_expired()` -- 0003_keys_shred.sql's `SECURITY DEFINER`
|
|
5
|
+
* sweep -- and returns how many `keys_held_versions` rows it shredded. Takes
|
|
6
|
+
* no row-targeting argument itself, matching the function: the function
|
|
7
|
+
* decides eligibility on its own from `retired_at`, `revoked_at` and
|
|
8
|
+
* `tenant_archived_at`, never from anything this caller supplies.
|
|
9
|
+
*/
|
|
10
|
+
export async function shredExpired(db) {
|
|
11
|
+
const result = await db.execute(sql `select keys_shred_expired() as n`);
|
|
12
|
+
const [row] = rowsOf(result);
|
|
13
|
+
return Number(row?.n ?? 0);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every still-wrapped version whose shred clock started after `since` -- the
|
|
17
|
+
* earliest of its own `retired_at`, its entry's `revoked_at`, or its
|
|
18
|
+
* tenant's `tenant_archived_at`, whichever is set. #225's revised answer:
|
|
19
|
+
* "the write that starts the clock is itself an alert" -- a host's
|
|
20
|
+
* housekeeping tick calls this once a tick, with `since` set to the last
|
|
21
|
+
* tick's time, and alerts on a non-empty result, so an operator can reverse
|
|
22
|
+
* a forged retire/revoke/archive inside the 30-day
|
|
23
|
+
* `KEYSTORE_SHRED_DELAY_DAYS` window before `keys_shred_expired()` fires.
|
|
24
|
+
*
|
|
25
|
+
* A row already shredded (`wrapped_key is null`) has no clock left to alert
|
|
26
|
+
* on and is excluded.
|
|
27
|
+
*/
|
|
28
|
+
export async function shredClocks(db, options) {
|
|
29
|
+
const rows = await db
|
|
30
|
+
.select({
|
|
31
|
+
tenantId: keysHeldVersions.tenantId,
|
|
32
|
+
entryId: keysHeldVersions.entryId,
|
|
33
|
+
version: keysHeldVersions.version,
|
|
34
|
+
retiredAt: keysHeldVersions.retiredAt,
|
|
35
|
+
revokedAt: keysHeldEntries.revokedAt,
|
|
36
|
+
tenantArchivedAt: keysHeldEntries.tenantArchivedAt,
|
|
37
|
+
})
|
|
38
|
+
.from(keysHeldVersions)
|
|
39
|
+
.innerJoin(keysHeldEntries, and(eq(keysHeldVersions.tenantId, keysHeldEntries.tenantId), eq(keysHeldVersions.entryId, keysHeldEntries.entryId)))
|
|
40
|
+
.where(sql `${keysHeldVersions.wrappedKey} is not null`);
|
|
41
|
+
const clocks = [];
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
const candidates = [
|
|
44
|
+
{ reason: 'retired', at: row.retiredAt },
|
|
45
|
+
{ reason: 'entry_revoked', at: row.revokedAt },
|
|
46
|
+
{ reason: 'tenant_archived', at: row.tenantArchivedAt },
|
|
47
|
+
];
|
|
48
|
+
let earliest;
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
if (candidate.at === null)
|
|
51
|
+
continue;
|
|
52
|
+
if (!earliest || candidate.at.getTime() < earliest.at.getTime()) {
|
|
53
|
+
earliest = { reason: candidate.reason, at: candidate.at };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (earliest && earliest.at.getTime() > options.since.getTime()) {
|
|
57
|
+
clocks.push({
|
|
58
|
+
tenantId: row.tenantId,
|
|
59
|
+
entryId: row.entryId,
|
|
60
|
+
version: row.version,
|
|
61
|
+
reason: earliest.reason,
|
|
62
|
+
startedAt: earliest.at,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return clocks;
|
|
67
|
+
}
|
|
68
|
+
// Mirrors the same driver-shape helper in src/issued/index.ts: postgres-js's
|
|
69
|
+
// db.execute returns an array-like directly, PGlite's returns `{ rows }`.
|
|
70
|
+
// Kept as its own copy rather than a shared import -- ./issued and ./held
|
|
71
|
+
// store nothing in common by design (map Notes, 2026-09-13).
|
|
72
|
+
function rowsOf(result) {
|
|
73
|
+
if (Array.isArray(result))
|
|
74
|
+
return result;
|
|
75
|
+
return (result.rows ?? []);
|
|
76
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Base64url, used only to store a `Uint8Array` (the Worker's signature) in a `text` column. */
|
|
2
|
+
export function toBase64Url(bytes) {
|
|
3
|
+
return Buffer.from(bytes).toString('base64url');
|
|
4
|
+
}
|
|
5
|
+
export function fromBase64Url(value) {
|
|
6
|
+
return new Uint8Array(Buffer.from(value, 'base64url'));
|
|
7
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bytes a signature is over, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* No Node-only APIs in this file: the Worker (#233) imports it too, and a
|
|
5
|
+
* Cloudflare Worker has `TextEncoder` but not `Buffer` or `node:crypto`.
|
|
6
|
+
*/
|
|
7
|
+
import { type SignableRow, type SignedKind } from '../worker-contract.js';
|
|
8
|
+
/**
|
|
9
|
+
* RFC 8785 JSON Canonicalization: object keys sorted by UTF-16 code unit, no
|
|
10
|
+
* insignificant whitespace, numbers in ECMAScript's shortest round-trip form,
|
|
11
|
+
* strings escaped exactly as `JSON.stringify` already escapes them.
|
|
12
|
+
*
|
|
13
|
+
* Refuses `undefined`, functions, symbols, non-finite numbers and any object
|
|
14
|
+
* that is not a plain object or an array — silently coercing one of these
|
|
15
|
+
* (the way `JSON.stringify` drops an `undefined` property, or turns `NaN`
|
|
16
|
+
* into `null`) would change what gets signed without changing what the
|
|
17
|
+
* caller thinks it asked to sign.
|
|
18
|
+
*
|
|
19
|
+
* A JS `number`'s own `String()` already implements ECMA-262's
|
|
20
|
+
* `Number::toString` for radix 10 — the same "shortest string that parses
|
|
21
|
+
* back to this value" algorithm JCS specifies — so no separate formatting
|
|
22
|
+
* step is needed. `String(-0)` and `String(0)` both give `"0"`, matching
|
|
23
|
+
* JCS's own worked example.
|
|
24
|
+
*
|
|
25
|
+
* Default `Array.prototype.sort()` on strings compares by UTF-16 code unit
|
|
26
|
+
* already (JS strings are UTF-16 sequences and the default comparator
|
|
27
|
+
* converts to strings and compares them lexicographically by code unit), so
|
|
28
|
+
* `Object.keys(obj).sort()` alone gives JCS's key order.
|
|
29
|
+
*/
|
|
30
|
+
export declare function canonicalJson(value: unknown): string;
|
|
31
|
+
/**
|
|
32
|
+
* `wtfalch-keys:${kind}:v1\n` + `canonicalJson(value)`, as UTF-8 bytes.
|
|
33
|
+
*
|
|
34
|
+
* The domain tag separates what an `'issued-row'` signature can authenticate
|
|
35
|
+
* from what a `'caller-credential'` signature can, so the same Ed25519 key
|
|
36
|
+
* signing both kinds never lets one message's bytes be replayed as the
|
|
37
|
+
* other's.
|
|
38
|
+
*/
|
|
39
|
+
export declare function encodeSigned(kind: SignedKind, value: unknown): Uint8Array;
|
|
40
|
+
/**
|
|
41
|
+
* The eight `SignableRow` fields, by explicit allowlist — never the stored
|
|
42
|
+
* row object itself. A column added to the table later (or a driver that
|
|
43
|
+
* hands back extra properties) must not silently join the signed bytes.
|
|
44
|
+
*/
|
|
45
|
+
export declare function pickSignable<TGrant>(row: SignableRow<TGrant>): SignableRow<TGrant>;
|
|
46
|
+
/** Every id in the contract matches `ID_PATTERN`. Throws with `label` naming the field. */
|
|
47
|
+
export declare function assertValidId(id: string, label: string): void;
|
|
48
|
+
export declare function isValidId(id: string): boolean;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bytes a signature is over, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* No Node-only APIs in this file: the Worker (#233) imports it too, and a
|
|
5
|
+
* Cloudflare Worker has `TextEncoder` but not `Buffer` or `node:crypto`.
|
|
6
|
+
*/
|
|
7
|
+
import { ID_PATTERN, SIGNED_PREFIX, } from '../worker-contract.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// canonicalJson — RFC 8785 (JCS)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* RFC 8785 JSON Canonicalization: object keys sorted by UTF-16 code unit, no
|
|
13
|
+
* insignificant whitespace, numbers in ECMAScript's shortest round-trip form,
|
|
14
|
+
* strings escaped exactly as `JSON.stringify` already escapes them.
|
|
15
|
+
*
|
|
16
|
+
* Refuses `undefined`, functions, symbols, non-finite numbers and any object
|
|
17
|
+
* that is not a plain object or an array — silently coercing one of these
|
|
18
|
+
* (the way `JSON.stringify` drops an `undefined` property, or turns `NaN`
|
|
19
|
+
* into `null`) would change what gets signed without changing what the
|
|
20
|
+
* caller thinks it asked to sign.
|
|
21
|
+
*
|
|
22
|
+
* A JS `number`'s own `String()` already implements ECMA-262's
|
|
23
|
+
* `Number::toString` for radix 10 — the same "shortest string that parses
|
|
24
|
+
* back to this value" algorithm JCS specifies — so no separate formatting
|
|
25
|
+
* step is needed. `String(-0)` and `String(0)` both give `"0"`, matching
|
|
26
|
+
* JCS's own worked example.
|
|
27
|
+
*
|
|
28
|
+
* Default `Array.prototype.sort()` on strings compares by UTF-16 code unit
|
|
29
|
+
* already (JS strings are UTF-16 sequences and the default comparator
|
|
30
|
+
* converts to strings and compares them lexicographically by code unit), so
|
|
31
|
+
* `Object.keys(obj).sort()` alone gives JCS's key order.
|
|
32
|
+
*/
|
|
33
|
+
export function canonicalJson(value) {
|
|
34
|
+
return encodeValue(value);
|
|
35
|
+
}
|
|
36
|
+
function encodeValue(value) {
|
|
37
|
+
if (value === null)
|
|
38
|
+
return 'null';
|
|
39
|
+
const t = typeof value;
|
|
40
|
+
if (t === 'boolean')
|
|
41
|
+
return value ? 'true' : 'false';
|
|
42
|
+
if (t === 'number') {
|
|
43
|
+
const n = value;
|
|
44
|
+
if (!Number.isFinite(n)) {
|
|
45
|
+
throw new TypeError('canonicalJson: non-finite numbers (NaN, Infinity) are refused');
|
|
46
|
+
}
|
|
47
|
+
return String(n);
|
|
48
|
+
}
|
|
49
|
+
if (t === 'string')
|
|
50
|
+
return encodeString(value);
|
|
51
|
+
if (t === 'undefined')
|
|
52
|
+
throw new TypeError('canonicalJson: undefined is refused');
|
|
53
|
+
if (t === 'function')
|
|
54
|
+
throw new TypeError('canonicalJson: a function is refused');
|
|
55
|
+
if (t === 'symbol')
|
|
56
|
+
throw new TypeError('canonicalJson: a symbol is refused');
|
|
57
|
+
if (t === 'bigint')
|
|
58
|
+
throw new TypeError('canonicalJson: a bigint is refused');
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return `[${value.map((item) => encodeValue(item)).join(',')}]`;
|
|
61
|
+
}
|
|
62
|
+
if (t === 'object') {
|
|
63
|
+
if (!isPlainObject(value)) {
|
|
64
|
+
throw new TypeError('canonicalJson: only plain objects and arrays are refused otherwise');
|
|
65
|
+
}
|
|
66
|
+
const keys = Object.keys(value).sort();
|
|
67
|
+
const parts = keys.map((key) => `${encodeString(key)}:${encodeValue(value[key])}`);
|
|
68
|
+
return `{${parts.join(',')}}`;
|
|
69
|
+
}
|
|
70
|
+
throw new TypeError(`canonicalJson: unsupported type ${t}`);
|
|
71
|
+
}
|
|
72
|
+
function isPlainObject(value) {
|
|
73
|
+
if (value === null || typeof value !== 'object')
|
|
74
|
+
return false;
|
|
75
|
+
const proto = Object.getPrototypeOf(value);
|
|
76
|
+
return proto === Object.prototype || proto === null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* String literals use the same escaping `JSON.stringify` already produces —
|
|
80
|
+
* control characters, `"` and `\` escaped, `/` left alone — which is exactly
|
|
81
|
+
* what RFC 8785 specifies, so no separate escaper is written here.
|
|
82
|
+
*/
|
|
83
|
+
function encodeString(value) {
|
|
84
|
+
return JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// encodeSigned
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* `wtfalch-keys:${kind}:v1\n` + `canonicalJson(value)`, as UTF-8 bytes.
|
|
91
|
+
*
|
|
92
|
+
* The domain tag separates what an `'issued-row'` signature can authenticate
|
|
93
|
+
* from what a `'caller-credential'` signature can, so the same Ed25519 key
|
|
94
|
+
* signing both kinds never lets one message's bytes be replayed as the
|
|
95
|
+
* other's.
|
|
96
|
+
*/
|
|
97
|
+
export function encodeSigned(kind, value) {
|
|
98
|
+
const header = `${SIGNED_PREFIX}${kind}:v1\n`;
|
|
99
|
+
return new TextEncoder().encode(header + canonicalJson(value));
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// pickSignable
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
/**
|
|
105
|
+
* The eight `SignableRow` fields, by explicit allowlist — never the stored
|
|
106
|
+
* row object itself. A column added to the table later (or a driver that
|
|
107
|
+
* hands back extra properties) must not silently join the signed bytes.
|
|
108
|
+
*/
|
|
109
|
+
export function pickSignable(row) {
|
|
110
|
+
return {
|
|
111
|
+
id: row.id,
|
|
112
|
+
issuedById: row.issuedById,
|
|
113
|
+
keyPrefix: row.keyPrefix,
|
|
114
|
+
secretHash: row.secretHash,
|
|
115
|
+
previousSecretHash: row.previousSecretHash,
|
|
116
|
+
previousValidUntil: row.previousValidUntil,
|
|
117
|
+
grants: row.grants,
|
|
118
|
+
expiresAt: row.expiresAt,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Id validation
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** Every id in the contract matches `ID_PATTERN`. Throws with `label` naming the field. */
|
|
125
|
+
export function assertValidId(id, label) {
|
|
126
|
+
if (!ID_PATTERN.test(id)) {
|
|
127
|
+
throw new TypeError(`${label} does not match ID_PATTERN: ${JSON.stringify(id)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function isValidId(id) {
|
|
131
|
+
return ID_PATTERN.test(id);
|
|
132
|
+
}
|