@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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `keys_issued_credentials`, mirrored from `../migrations/0001_keys_issued.sql`,
|
|
3
|
+
* which is the source of truth: the SQL carries the CHECKs, the self-referencing
|
|
4
|
+
* foreign key and the partial unique indexes that drizzle-kit does not generate.
|
|
5
|
+
*
|
|
6
|
+
* `expiresAt` and `previousValidUntil` are `bigint` in `'number'` mode, not
|
|
7
|
+
* `timestamp`: both are signed as integer Unix milliseconds
|
|
8
|
+
* (`../worker-contract.ts`), and a bigint round-trips that exact integer with
|
|
9
|
+
* no timezone or fractional-second conversion in the way.
|
|
10
|
+
*
|
|
11
|
+
* `signature` is `text`, storing the Worker's Ed25519 signature bytes as
|
|
12
|
+
* base64url (see `codec.ts`) rather than `bytea`, so every driver hands the
|
|
13
|
+
* same JS string back with no Buffer/Uint8Array translation to get wrong.
|
|
14
|
+
*
|
|
15
|
+
* `issuedById` has no `.references()` here: a self-referencing foreign key on
|
|
16
|
+
* a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
|
|
17
|
+
* nothing this package's own tests check, since the constraint that actually
|
|
18
|
+
* runs is the one in the SQL migration.
|
|
19
|
+
*/
|
|
20
|
+
export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
21
|
+
name: "keys_issued_credentials";
|
|
22
|
+
schema: undefined;
|
|
23
|
+
columns: {
|
|
24
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
25
|
+
name: "id";
|
|
26
|
+
tableName: "keys_issued_credentials";
|
|
27
|
+
dataType: "string";
|
|
28
|
+
columnType: "PgText";
|
|
29
|
+
data: string;
|
|
30
|
+
driverParam: string;
|
|
31
|
+
notNull: true;
|
|
32
|
+
hasDefault: false;
|
|
33
|
+
isPrimaryKey: true;
|
|
34
|
+
isAutoincrement: false;
|
|
35
|
+
hasRuntimeDefault: false;
|
|
36
|
+
enumValues: [string, ...string[]];
|
|
37
|
+
baseColumn: never;
|
|
38
|
+
identity: undefined;
|
|
39
|
+
generated: undefined;
|
|
40
|
+
}, {}, {}>;
|
|
41
|
+
issuedById: import("drizzle-orm/pg-core").PgColumn<{
|
|
42
|
+
name: "issued_by_id";
|
|
43
|
+
tableName: "keys_issued_credentials";
|
|
44
|
+
dataType: "string";
|
|
45
|
+
columnType: "PgText";
|
|
46
|
+
data: string;
|
|
47
|
+
driverParam: string;
|
|
48
|
+
notNull: false;
|
|
49
|
+
hasDefault: false;
|
|
50
|
+
isPrimaryKey: false;
|
|
51
|
+
isAutoincrement: false;
|
|
52
|
+
hasRuntimeDefault: false;
|
|
53
|
+
enumValues: [string, ...string[]];
|
|
54
|
+
baseColumn: never;
|
|
55
|
+
identity: undefined;
|
|
56
|
+
generated: undefined;
|
|
57
|
+
}, {}, {}>;
|
|
58
|
+
keyPrefix: import("drizzle-orm/pg-core").PgColumn<{
|
|
59
|
+
name: "key_prefix";
|
|
60
|
+
tableName: "keys_issued_credentials";
|
|
61
|
+
dataType: "string";
|
|
62
|
+
columnType: "PgText";
|
|
63
|
+
data: string;
|
|
64
|
+
driverParam: string;
|
|
65
|
+
notNull: true;
|
|
66
|
+
hasDefault: false;
|
|
67
|
+
isPrimaryKey: false;
|
|
68
|
+
isAutoincrement: false;
|
|
69
|
+
hasRuntimeDefault: false;
|
|
70
|
+
enumValues: [string, ...string[]];
|
|
71
|
+
baseColumn: never;
|
|
72
|
+
identity: undefined;
|
|
73
|
+
generated: undefined;
|
|
74
|
+
}, {}, {}>;
|
|
75
|
+
secretHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
76
|
+
name: "secret_hash";
|
|
77
|
+
tableName: "keys_issued_credentials";
|
|
78
|
+
dataType: "string";
|
|
79
|
+
columnType: "PgText";
|
|
80
|
+
data: string;
|
|
81
|
+
driverParam: string;
|
|
82
|
+
notNull: true;
|
|
83
|
+
hasDefault: false;
|
|
84
|
+
isPrimaryKey: false;
|
|
85
|
+
isAutoincrement: false;
|
|
86
|
+
hasRuntimeDefault: false;
|
|
87
|
+
enumValues: [string, ...string[]];
|
|
88
|
+
baseColumn: never;
|
|
89
|
+
identity: undefined;
|
|
90
|
+
generated: undefined;
|
|
91
|
+
}, {}, {}>;
|
|
92
|
+
previousSecretHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
93
|
+
name: "previous_secret_hash";
|
|
94
|
+
tableName: "keys_issued_credentials";
|
|
95
|
+
dataType: "string";
|
|
96
|
+
columnType: "PgText";
|
|
97
|
+
data: string;
|
|
98
|
+
driverParam: string;
|
|
99
|
+
notNull: false;
|
|
100
|
+
hasDefault: false;
|
|
101
|
+
isPrimaryKey: false;
|
|
102
|
+
isAutoincrement: false;
|
|
103
|
+
hasRuntimeDefault: false;
|
|
104
|
+
enumValues: [string, ...string[]];
|
|
105
|
+
baseColumn: never;
|
|
106
|
+
identity: undefined;
|
|
107
|
+
generated: undefined;
|
|
108
|
+
}, {}, {}>;
|
|
109
|
+
previousValidUntil: import("drizzle-orm/pg-core").PgColumn<{
|
|
110
|
+
name: "previous_valid_until";
|
|
111
|
+
tableName: "keys_issued_credentials";
|
|
112
|
+
dataType: "number";
|
|
113
|
+
columnType: "PgBigInt53";
|
|
114
|
+
data: number;
|
|
115
|
+
driverParam: string | number;
|
|
116
|
+
notNull: false;
|
|
117
|
+
hasDefault: false;
|
|
118
|
+
isPrimaryKey: false;
|
|
119
|
+
isAutoincrement: false;
|
|
120
|
+
hasRuntimeDefault: false;
|
|
121
|
+
enumValues: undefined;
|
|
122
|
+
baseColumn: never;
|
|
123
|
+
identity: undefined;
|
|
124
|
+
generated: undefined;
|
|
125
|
+
}, {}, {}>;
|
|
126
|
+
grants: import("drizzle-orm/pg-core").PgColumn<{
|
|
127
|
+
name: "grants";
|
|
128
|
+
tableName: "keys_issued_credentials";
|
|
129
|
+
dataType: "json";
|
|
130
|
+
columnType: "PgJsonb";
|
|
131
|
+
data: unknown;
|
|
132
|
+
driverParam: unknown;
|
|
133
|
+
notNull: true;
|
|
134
|
+
hasDefault: false;
|
|
135
|
+
isPrimaryKey: false;
|
|
136
|
+
isAutoincrement: false;
|
|
137
|
+
hasRuntimeDefault: false;
|
|
138
|
+
enumValues: undefined;
|
|
139
|
+
baseColumn: never;
|
|
140
|
+
identity: undefined;
|
|
141
|
+
generated: undefined;
|
|
142
|
+
}, {}, {}>;
|
|
143
|
+
expiresAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
144
|
+
name: "expires_at";
|
|
145
|
+
tableName: "keys_issued_credentials";
|
|
146
|
+
dataType: "number";
|
|
147
|
+
columnType: "PgBigInt53";
|
|
148
|
+
data: number;
|
|
149
|
+
driverParam: string | number;
|
|
150
|
+
notNull: true;
|
|
151
|
+
hasDefault: false;
|
|
152
|
+
isPrimaryKey: false;
|
|
153
|
+
isAutoincrement: false;
|
|
154
|
+
hasRuntimeDefault: false;
|
|
155
|
+
enumValues: undefined;
|
|
156
|
+
baseColumn: never;
|
|
157
|
+
identity: undefined;
|
|
158
|
+
generated: undefined;
|
|
159
|
+
}, {}, {}>;
|
|
160
|
+
signature: import("drizzle-orm/pg-core").PgColumn<{
|
|
161
|
+
name: "signature";
|
|
162
|
+
tableName: "keys_issued_credentials";
|
|
163
|
+
dataType: "string";
|
|
164
|
+
columnType: "PgText";
|
|
165
|
+
data: string;
|
|
166
|
+
driverParam: string;
|
|
167
|
+
notNull: true;
|
|
168
|
+
hasDefault: false;
|
|
169
|
+
isPrimaryKey: false;
|
|
170
|
+
isAutoincrement: false;
|
|
171
|
+
hasRuntimeDefault: false;
|
|
172
|
+
enumValues: [string, ...string[]];
|
|
173
|
+
baseColumn: never;
|
|
174
|
+
identity: undefined;
|
|
175
|
+
generated: undefined;
|
|
176
|
+
}, {}, {}>;
|
|
177
|
+
signingGenerationId: import("drizzle-orm/pg-core").PgColumn<{
|
|
178
|
+
name: "signing_generation_id";
|
|
179
|
+
tableName: "keys_issued_credentials";
|
|
180
|
+
dataType: "string";
|
|
181
|
+
columnType: "PgText";
|
|
182
|
+
data: string;
|
|
183
|
+
driverParam: string;
|
|
184
|
+
notNull: true;
|
|
185
|
+
hasDefault: false;
|
|
186
|
+
isPrimaryKey: false;
|
|
187
|
+
isAutoincrement: false;
|
|
188
|
+
hasRuntimeDefault: false;
|
|
189
|
+
enumValues: [string, ...string[]];
|
|
190
|
+
baseColumn: never;
|
|
191
|
+
identity: undefined;
|
|
192
|
+
generated: undefined;
|
|
193
|
+
}, {}, {}>;
|
|
194
|
+
idempotencyKey: import("drizzle-orm/pg-core").PgColumn<{
|
|
195
|
+
name: "idempotency_key";
|
|
196
|
+
tableName: "keys_issued_credentials";
|
|
197
|
+
dataType: "string";
|
|
198
|
+
columnType: "PgText";
|
|
199
|
+
data: string;
|
|
200
|
+
driverParam: string;
|
|
201
|
+
notNull: false;
|
|
202
|
+
hasDefault: false;
|
|
203
|
+
isPrimaryKey: false;
|
|
204
|
+
isAutoincrement: false;
|
|
205
|
+
hasRuntimeDefault: false;
|
|
206
|
+
enumValues: [string, ...string[]];
|
|
207
|
+
baseColumn: never;
|
|
208
|
+
identity: undefined;
|
|
209
|
+
generated: undefined;
|
|
210
|
+
}, {}, {}>;
|
|
211
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
212
|
+
name: "created_at";
|
|
213
|
+
tableName: "keys_issued_credentials";
|
|
214
|
+
dataType: "date";
|
|
215
|
+
columnType: "PgTimestamp";
|
|
216
|
+
data: Date;
|
|
217
|
+
driverParam: string;
|
|
218
|
+
notNull: true;
|
|
219
|
+
hasDefault: true;
|
|
220
|
+
isPrimaryKey: false;
|
|
221
|
+
isAutoincrement: false;
|
|
222
|
+
hasRuntimeDefault: false;
|
|
223
|
+
enumValues: undefined;
|
|
224
|
+
baseColumn: never;
|
|
225
|
+
identity: undefined;
|
|
226
|
+
generated: undefined;
|
|
227
|
+
}, {}, {}>;
|
|
228
|
+
revokedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
229
|
+
name: "revoked_at";
|
|
230
|
+
tableName: "keys_issued_credentials";
|
|
231
|
+
dataType: "date";
|
|
232
|
+
columnType: "PgTimestamp";
|
|
233
|
+
data: Date;
|
|
234
|
+
driverParam: string;
|
|
235
|
+
notNull: false;
|
|
236
|
+
hasDefault: false;
|
|
237
|
+
isPrimaryKey: false;
|
|
238
|
+
isAutoincrement: false;
|
|
239
|
+
hasRuntimeDefault: false;
|
|
240
|
+
enumValues: undefined;
|
|
241
|
+
baseColumn: never;
|
|
242
|
+
identity: undefined;
|
|
243
|
+
generated: undefined;
|
|
244
|
+
}, {}, {}>;
|
|
245
|
+
};
|
|
246
|
+
dialect: "pg";
|
|
247
|
+
}>;
|
|
248
|
+
export type KeysIssuedCredentialsTable = typeof keysIssuedCredentials;
|
|
249
|
+
export type KeysIssuedCredentialRow = typeof keysIssuedCredentials.$inferSelect;
|
|
250
|
+
export type KeysIssuedCredentialInsert = typeof keysIssuedCredentials.$inferInsert;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { bigint, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
|
2
|
+
/**
|
|
3
|
+
* `keys_issued_credentials`, mirrored from `../migrations/0001_keys_issued.sql`,
|
|
4
|
+
* which is the source of truth: the SQL carries the CHECKs, the self-referencing
|
|
5
|
+
* foreign key and the partial unique indexes that drizzle-kit does not generate.
|
|
6
|
+
*
|
|
7
|
+
* `expiresAt` and `previousValidUntil` are `bigint` in `'number'` mode, not
|
|
8
|
+
* `timestamp`: both are signed as integer Unix milliseconds
|
|
9
|
+
* (`../worker-contract.ts`), and a bigint round-trips that exact integer with
|
|
10
|
+
* no timezone or fractional-second conversion in the way.
|
|
11
|
+
*
|
|
12
|
+
* `signature` is `text`, storing the Worker's Ed25519 signature bytes as
|
|
13
|
+
* base64url (see `codec.ts`) rather than `bytea`, so every driver hands the
|
|
14
|
+
* same JS string back with no Buffer/Uint8Array translation to get wrong.
|
|
15
|
+
*
|
|
16
|
+
* `issuedById` has no `.references()` here: a self-referencing foreign key on
|
|
17
|
+
* a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
|
|
18
|
+
* nothing this package's own tests check, since the constraint that actually
|
|
19
|
+
* runs is the one in the SQL migration.
|
|
20
|
+
*/
|
|
21
|
+
export const keysIssuedCredentials = pgTable('keys_issued_credentials', {
|
|
22
|
+
id: text('id').primaryKey(),
|
|
23
|
+
issuedById: text('issued_by_id'),
|
|
24
|
+
keyPrefix: text('key_prefix').notNull(),
|
|
25
|
+
secretHash: text('secret_hash').notNull(),
|
|
26
|
+
previousSecretHash: text('previous_secret_hash'),
|
|
27
|
+
previousValidUntil: bigint('previous_valid_until', { mode: 'number' }),
|
|
28
|
+
grants: jsonb('grants').notNull(),
|
|
29
|
+
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
|
|
30
|
+
signature: text('signature').notNull(),
|
|
31
|
+
signingGenerationId: text('signing_generation_id').notNull(),
|
|
32
|
+
idempotencyKey: text('idempotency_key'),
|
|
33
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
34
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
35
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SignableRow, VerifyKeys } from '../worker-contract.js';
|
|
2
|
+
/**
|
|
3
|
+
* Verifies `signature` over `encodeSigned('issued-row', pickSignable(row))`
|
|
4
|
+
* against `verifyKeys`: `current` first, then `previous` if present. A row
|
|
5
|
+
* signed under either key checks — that is what lets a Worker signing-key
|
|
6
|
+
* rotation happen without re-signing every row before `previous` is dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare function verifyIssuedRowSignature<TGrant>(row: SignableRow<TGrant>, signature: Uint8Array, verifyKeys: VerifyKeys): Promise<boolean>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { encodeSigned, pickSignable } from './encoding.js';
|
|
2
|
+
/**
|
|
3
|
+
* WebCrypto (`crypto.subtle`), not `node:crypto`'s classic API: the same
|
|
4
|
+
* `verify('Ed25519', …)` shape runs in Node 22 and in a Cloudflare Worker,
|
|
5
|
+
* and `check()` verifying a row is exactly the code a Worker-side sweep
|
|
6
|
+
* would want to reuse later.
|
|
7
|
+
*/
|
|
8
|
+
async function importEd25519PublicKey(raw) {
|
|
9
|
+
return crypto.subtle.importKey('raw', raw, { name: 'Ed25519' }, false, [
|
|
10
|
+
'verify',
|
|
11
|
+
]);
|
|
12
|
+
}
|
|
13
|
+
async function verifyEd25519(publicKey, signature, data) {
|
|
14
|
+
const key = await importEd25519PublicKey(publicKey);
|
|
15
|
+
return crypto.subtle.verify({ name: 'Ed25519' }, key, signature, data);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Verifies `signature` over `encodeSigned('issued-row', pickSignable(row))`
|
|
19
|
+
* against `verifyKeys`: `current` first, then `previous` if present. A row
|
|
20
|
+
* signed under either key checks — that is what lets a Worker signing-key
|
|
21
|
+
* rotation happen without re-signing every row before `previous` is dropped.
|
|
22
|
+
*/
|
|
23
|
+
export async function verifyIssuedRowSignature(row, signature, verifyKeys) {
|
|
24
|
+
const data = encodeSigned('issued-row', pickSignable(row));
|
|
25
|
+
const [current, previous] = verifyKeys;
|
|
26
|
+
if (await verifyEd25519(current, signature, data))
|
|
27
|
+
return true;
|
|
28
|
+
if (previous && (await verifyEd25519(previous, signature, data)))
|
|
29
|
+
return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
-- @wtfalch/keys: the ./issued entry's row store. Mirrors src/issued/tables.ts.
|
|
2
|
+
--
|
|
3
|
+
-- One row per issued credential: the eight SignableRow<TGrant> fields the
|
|
4
|
+
-- Worker signs (id, issued_by_id, key_prefix, secret_hash,
|
|
5
|
+
-- previous_secret_hash, previous_valid_until, grants, expires_at), plus the
|
|
6
|
+
-- Worker's own signature and the signing generation it was signed under, and
|
|
7
|
+
-- three columns this table owns alone: an idempotency key, created_at and
|
|
8
|
+
-- revoked_at. check() re-verifies the signature over exactly those eight
|
|
9
|
+
-- fields on every call (src/issued/encoding.ts's pickSignable), so a column
|
|
10
|
+
-- outside that set carries no authority and this file's shape must match
|
|
11
|
+
-- pickSignable's allowlist exactly.
|
|
12
|
+
--
|
|
13
|
+
-- expires_at and previous_valid_until are bigint, not timestamptz: both are
|
|
14
|
+
-- signed as integer Unix milliseconds (worker-contract.ts's SignableRow), and
|
|
15
|
+
-- a bigint round-trips that exact integer with no timezone conversion or
|
|
16
|
+
-- fractional-second rounding in the way. migration.test.ts proves it.
|
|
17
|
+
--
|
|
18
|
+
-- signature is text, not bytea: stored as base64url so every driver
|
|
19
|
+
-- (postgres.js, PGlite, whatever a future host uses) returns the same JS
|
|
20
|
+
-- string type back, with no driver-specific Buffer/Uint8Array translation to
|
|
21
|
+
-- get wrong.
|
|
22
|
+
--
|
|
23
|
+
-- Applied by the host's own migrate script after the host copies this file
|
|
24
|
+
-- into its own migrations directory as the next number (keys-migrations);
|
|
25
|
+
-- never edited there. Every statement is idempotent.
|
|
26
|
+
--
|
|
27
|
+
-- No shred sweep here: that is #232, over ./held's envelope tables. A revoked
|
|
28
|
+
-- issued credential's row stays — it carries no secret in the clear, only a
|
|
29
|
+
-- hash, and the row itself is what lineageOf and reconciliation read.
|
|
30
|
+
|
|
31
|
+
create table if not exists keys_issued_credentials (
|
|
32
|
+
id text not null primary key,
|
|
33
|
+
issued_by_id text references keys_issued_credentials (id),
|
|
34
|
+
key_prefix text not null,
|
|
35
|
+
secret_hash text not null,
|
|
36
|
+
previous_secret_hash text,
|
|
37
|
+
previous_valid_until bigint,
|
|
38
|
+
grants jsonb not null,
|
|
39
|
+
expires_at bigint not null,
|
|
40
|
+
signature text not null,
|
|
41
|
+
signing_generation_id text not null,
|
|
42
|
+
idempotency_key text,
|
|
43
|
+
created_at timestamptz not null default now(),
|
|
44
|
+
revoked_at timestamptz,
|
|
45
|
+
constraint keys_issued_credentials_id_check
|
|
46
|
+
check (id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
|
47
|
+
constraint keys_issued_credentials_issued_by_id_check
|
|
48
|
+
check (issued_by_id is null or issued_by_id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
|
49
|
+
constraint keys_issued_credentials_no_self_parent_check
|
|
50
|
+
check (issued_by_id is distinct from id),
|
|
51
|
+
constraint keys_issued_credentials_key_prefix_check
|
|
52
|
+
check (length(key_prefix) between 1 and 64),
|
|
53
|
+
constraint keys_issued_credentials_secret_hash_check
|
|
54
|
+
check (secret_hash ~ '^[0-9a-f]{64}$'),
|
|
55
|
+
constraint keys_issued_credentials_previous_secret_hash_check
|
|
56
|
+
check (previous_secret_hash is null or previous_secret_hash ~ '^[0-9a-f]{64}$'),
|
|
57
|
+
-- Signed as a pair: both null outside a rotation, both set during one.
|
|
58
|
+
constraint keys_issued_credentials_grace_pair_check
|
|
59
|
+
check ((previous_secret_hash is null) = (previous_valid_until is null)),
|
|
60
|
+
constraint keys_issued_credentials_grants_check
|
|
61
|
+
check (jsonb_typeof(grants) = 'array'),
|
|
62
|
+
constraint keys_issued_credentials_expires_at_check
|
|
63
|
+
check (expires_at > 0),
|
|
64
|
+
constraint keys_issued_credentials_signature_check
|
|
65
|
+
check (length(signature) > 0),
|
|
66
|
+
constraint keys_issued_credentials_signing_generation_id_check
|
|
67
|
+
check (length(signing_generation_id) between 1 and 128)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
create unique index if not exists keys_issued_credentials_key_prefix_idx
|
|
71
|
+
on keys_issued_credentials (key_prefix);
|
|
72
|
+
|
|
73
|
+
-- Cascade revoke walks this column downward via a recursive CTE; index it.
|
|
74
|
+
create index if not exists keys_issued_credentials_issued_by_id_idx
|
|
75
|
+
on keys_issued_credentials (issued_by_id)
|
|
76
|
+
where issued_by_id is not null;
|
|
77
|
+
|
|
78
|
+
create unique index if not exists keys_issued_credentials_idempotency_key_idx
|
|
79
|
+
on keys_issued_credentials (idempotency_key)
|
|
80
|
+
where idempotency_key is not null;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
-- @wtfalch/keys ./held: where a host stores the ciphertext half of a held
|
|
2
|
+
-- key. Mirrors src/held/schema.ts.
|
|
3
|
+
--
|
|
4
|
+
-- Applied by the host's own migrate script after this file is copied into
|
|
5
|
+
-- its drizzle/ directory as the next number (keys-migrations); never edited
|
|
6
|
+
-- there. Every statement is idempotent.
|
|
7
|
+
--
|
|
8
|
+
-- keys_held_entries: one row per (tenant, entry) this host holds a secret
|
|
9
|
+
-- for. keys_held_versions: an immutable row per sealed version -- the value's
|
|
10
|
+
-- own ciphertext plus the wrapped data key the Worker returned for it.
|
|
11
|
+
--
|
|
12
|
+
-- wrapped_key is nullable on purpose: a later migration (#232) nulls it,
|
|
13
|
+
-- through a narrowly privileged function, once a version has been eligible
|
|
14
|
+
-- for destruction (retired past its grace window, its entry revoked, or its
|
|
15
|
+
-- tenant archived) for at least the host's shred-delay window. This
|
|
16
|
+
-- migration only creates the column that later change writes to; it adds no
|
|
17
|
+
-- shred function and no guard trigger -- that is #232's migration, not this
|
|
18
|
+
-- one.
|
|
19
|
+
|
|
20
|
+
create table if not exists keys_held_entries (
|
|
21
|
+
tenant_id text not null,
|
|
22
|
+
entry_id text not null,
|
|
23
|
+
current_version integer not null default 0,
|
|
24
|
+
revoked_at timestamptz,
|
|
25
|
+
created_at timestamptz not null default now(),
|
|
26
|
+
constraint keys_held_entries_pkey primary key (tenant_id, entry_id),
|
|
27
|
+
constraint keys_held_entries_tenant_id_check check (tenant_id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
|
28
|
+
constraint keys_held_entries_entry_id_check check (entry_id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
|
29
|
+
constraint keys_held_entries_current_version_check check (current_version >= 0)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
create table if not exists keys_held_versions (
|
|
33
|
+
tenant_id text not null,
|
|
34
|
+
entry_id text not null,
|
|
35
|
+
version integer not null,
|
|
36
|
+
-- The Worker keyring generation the data key was wrapped under.
|
|
37
|
+
-- worker-contract.ts's ID_PATTERN covers "key generation" ids too.
|
|
38
|
+
kek_id text not null,
|
|
39
|
+
-- The data key, wrapped by the Worker under kek_id. NULL once shredded
|
|
40
|
+
-- (#232); never NULL for a version this migration inserts.
|
|
41
|
+
wrapped_key bytea,
|
|
42
|
+
-- 96-bit GCM nonce, fresh per seal.
|
|
43
|
+
iv bytea not null,
|
|
44
|
+
-- GCM ciphertext with its 16-byte authentication tag appended.
|
|
45
|
+
ciphertext bytea not null,
|
|
46
|
+
-- Set by #232's shred sweep. This migration only reserves the column.
|
|
47
|
+
retired_at timestamptz,
|
|
48
|
+
created_at timestamptz not null default now(),
|
|
49
|
+
constraint keys_held_versions_pkey primary key (tenant_id, entry_id, version),
|
|
50
|
+
constraint keys_held_versions_entry_fkey foreign key (tenant_id, entry_id)
|
|
51
|
+
references keys_held_entries (tenant_id, entry_id),
|
|
52
|
+
constraint keys_held_versions_version_check check (version >= 1),
|
|
53
|
+
constraint keys_held_versions_kek_id_check check (kek_id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
|
54
|
+
constraint keys_held_versions_iv_check check (octet_length(iv) = 12),
|
|
55
|
+
constraint keys_held_versions_ciphertext_check check (octet_length(ciphertext) >= 16)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- The rewrap sweep selects every row still under an outgoing kek_id, across
|
|
59
|
+
-- every tenant this host serves (#217: one wrapping key per host).
|
|
60
|
+
create index if not exists keys_held_versions_kek_idx on keys_held_versions (kek_id);
|