@kubun/store-delegation 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api.d.ts +53 -7
- package/lib/api.js +154 -43
- package/lib/index.d.ts +2 -2
- package/lib/index.js +2 -2
- package/lib/migrations.js +13 -3
- package/lib/revocation-checker.d.ts +21 -0
- package/lib/revocation-checker.js +43 -0
- package/package.json +20 -16
- package/lib/revocation-backend.d.ts +0 -3
- package/lib/revocation-backend.js +0 -14
package/lib/api.d.ts
CHANGED
|
@@ -13,27 +13,73 @@ export type DelegationStoreAPI = {
|
|
|
13
13
|
atTime: number;
|
|
14
14
|
}): Promise<Array<DelegationToken>>;
|
|
15
15
|
listIssuedTokens(grantor: string): Promise<Array<DelegationToken>>;
|
|
16
|
-
|
|
16
|
+
getDelegationTokenByGrantorJTI(params: {
|
|
17
|
+
jti: string;
|
|
18
|
+
grantor: string;
|
|
19
|
+
}): Promise<DelegationToken | null>;
|
|
20
|
+
listDelegationTokensByJTI(jti: string): Promise<Array<DelegationToken>>;
|
|
17
21
|
removeDelegationToken(params: {
|
|
18
22
|
jti: string;
|
|
19
23
|
}): Promise<boolean>;
|
|
20
24
|
addRevocation(input: InsertRevokedCapability): Promise<boolean>;
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
getRevocationByIssuer(jti: string, issuer: string): Promise<RevokedCapability | null>;
|
|
26
|
+
listRevocations(jti: string): Promise<Array<RevokedCapability>>;
|
|
27
|
+
/**
|
|
28
|
+
* Diagnostic only — **not** an authorization read. The `verified_at IS NOT NULL`
|
|
29
|
+
* predicate is the inverse of the enforcement rule: a co-member holds no copy
|
|
30
|
+
* of the capability, so its genuine revocation stays pending forever and this
|
|
31
|
+
* answers `false` for it. Authorization goes through
|
|
32
|
+
* `createDelegationRevocationChecker`, which is what closes that fail-open.
|
|
33
|
+
*/
|
|
34
|
+
isRevokedBy(jti: string, issuer: string): Promise<boolean>;
|
|
23
35
|
getHeldRevocations(params: {
|
|
24
36
|
audience: string;
|
|
25
37
|
}): Promise<Array<HeldRevocation>>;
|
|
26
|
-
markRevocationVerified(jti: string, params: {
|
|
38
|
+
markRevocationVerified(jti: string, issuer: string, params: {
|
|
27
39
|
cap_exp: number;
|
|
28
40
|
verified_at?: number;
|
|
29
41
|
}): Promise<boolean>;
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
getPendingRevocationByIssuer(jti: string, issuer: string): Promise<RevokedCapability | null>;
|
|
43
|
+
deletePendingRevocationsFromOtherIssuers(jti: string, issuer: string): Promise<number>;
|
|
32
44
|
purgeExpiredRevocations(params: {
|
|
33
45
|
graceSeconds: number;
|
|
34
46
|
}): Promise<number>;
|
|
35
47
|
purgeDeadPendingRevocations(params: {
|
|
36
|
-
|
|
48
|
+
graceSeconds: number;
|
|
37
49
|
}): Promise<number>;
|
|
38
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Longest lifetime (`exp - iat`, seconds) a `document/write` capability may
|
|
53
|
+
* carry — 30 days.
|
|
54
|
+
*
|
|
55
|
+
* This is a retention bound, not a policy preference. A co-member holds no copy
|
|
56
|
+
* of the capability a revocation names, so the pending-revocation sweep can only
|
|
57
|
+
* bound the row's useful life from the revocation itself:
|
|
58
|
+
* `cap.exp ≤ cap.iat + MAX_CAP_TTL_SECONDS ≤ revoked_iat + MAX_CAP_TTL_SECONDS`.
|
|
59
|
+
* That inequality holds only while every *accepted* capability respects the
|
|
60
|
+
* limit, so it is enforced on receive as well as on mint — a capability minted
|
|
61
|
+
* outside kubun's resolver would otherwise outlive its own revocation's
|
|
62
|
+
* retention and start being honored again.
|
|
63
|
+
*/
|
|
64
|
+
export declare const MAX_CAP_TTL_SECONDS = 2592000;
|
|
65
|
+
/**
|
|
66
|
+
* Verified revocations remain queryable this long past the capability's
|
|
67
|
+
* expiry — enough for audit lookbacks before the row is pruned. Pending rows
|
|
68
|
+
* reuse it as the margin past `revoked_iat + MAX_CAP_TTL_SECONDS`, so both
|
|
69
|
+
* kinds age out on the same policy.
|
|
70
|
+
*/
|
|
71
|
+
export declare const REVOCATION_GC_VERIFIED_GRACE_SECONDS: number;
|
|
72
|
+
/**
|
|
73
|
+
* How far ahead of the local clock a revocation's `iat` may sit before it is
|
|
74
|
+
* floored on write — one hour.
|
|
75
|
+
*
|
|
76
|
+
* A pending row's retention is computed from `revoked_iat`, and `revoked_iat`
|
|
77
|
+
* comes off a broadcast any co-member can sign, so an unbounded stamp buys an
|
|
78
|
+
* immortal row. Floored rather than rejected: dropping a genuine revocation is
|
|
79
|
+
* the failure this store exists to prevent, and a grantor with real clock skew
|
|
80
|
+
* must not lose its record. `KubunEngine`'s `maxDriftMS` (`engine.ts:208`,
|
|
81
|
+
* default `3_600_000`) bounds the same hazard on incoming mutations with the
|
|
82
|
+
* same tolerance, but it rejects — the two are not interchangeable.
|
|
83
|
+
*/
|
|
84
|
+
export declare const MAX_REVOCATION_FUTURE_DRIFT_SECONDS = 3600;
|
|
39
85
|
export declare function createDelegationStore(db: Kysely<DelegationStoreTables>, adapter: Adapter, logger?: Logger): DelegationStoreAPI;
|
package/lib/api.js
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
1
2
|
import { getKubunLogger } from '@kubun/logger';
|
|
2
3
|
// --- Revocation GC defaults ---
|
|
4
|
+
/**
|
|
5
|
+
* Longest lifetime (`exp - iat`, seconds) a `document/write` capability may
|
|
6
|
+
* carry — 30 days.
|
|
7
|
+
*
|
|
8
|
+
* This is a retention bound, not a policy preference. A co-member holds no copy
|
|
9
|
+
* of the capability a revocation names, so the pending-revocation sweep can only
|
|
10
|
+
* bound the row's useful life from the revocation itself:
|
|
11
|
+
* `cap.exp ≤ cap.iat + MAX_CAP_TTL_SECONDS ≤ revoked_iat + MAX_CAP_TTL_SECONDS`.
|
|
12
|
+
* That inequality holds only while every *accepted* capability respects the
|
|
13
|
+
* limit, so it is enforced on receive as well as on mint — a capability minted
|
|
14
|
+
* outside kubun's resolver would otherwise outlive its own revocation's
|
|
15
|
+
* retention and start being honored again.
|
|
16
|
+
*/ export const MAX_CAP_TTL_SECONDS = 2_592_000;
|
|
3
17
|
// Probability that each delegation-token insert triggers a revocation sweep.
|
|
4
18
|
// 1% keeps the sweep amortized cost negligible; over a busy stream it still
|
|
5
19
|
// runs often enough to bound table growth.
|
|
6
20
|
const REVOCATION_GC_SAMPLE_RATE = 0.01;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
21
|
+
/**
|
|
22
|
+
* Verified revocations remain queryable this long past the capability's
|
|
23
|
+
* expiry — enough for audit lookbacks before the row is pruned. Pending rows
|
|
24
|
+
* reuse it as the margin past `revoked_iat + MAX_CAP_TTL_SECONDS`, so both
|
|
25
|
+
* kinds age out on the same policy.
|
|
26
|
+
*/ export const REVOCATION_GC_VERIFIED_GRACE_SECONDS = 86_400 * 30;
|
|
27
|
+
/**
|
|
28
|
+
* How far ahead of the local clock a revocation's `iat` may sit before it is
|
|
29
|
+
* floored on write — one hour.
|
|
30
|
+
*
|
|
31
|
+
* A pending row's retention is computed from `revoked_iat`, and `revoked_iat`
|
|
32
|
+
* comes off a broadcast any co-member can sign, so an unbounded stamp buys an
|
|
33
|
+
* immortal row. Floored rather than rejected: dropping a genuine revocation is
|
|
34
|
+
* the failure this store exists to prevent, and a grantor with real clock skew
|
|
35
|
+
* must not lose its record. `KubunEngine`'s `maxDriftMS` (`engine.ts:208`,
|
|
36
|
+
* default `3_600_000`) bounds the same hazard on incoming mutations with the
|
|
37
|
+
* same tolerance, but it rejects — the two are not interchangeable.
|
|
38
|
+
*/ export const MAX_REVOCATION_FUTURE_DRIFT_SECONDS = 3_600;
|
|
13
39
|
// --- Factory function ---
|
|
40
|
+
// One identity reaches this store in two spellings: a `did:peer:4` signer
|
|
41
|
+
// embeds its long form as the `iss` of its first token to a given audience and
|
|
42
|
+
// its short form thereafter, while `identity.id` and `sub` are always short. So
|
|
43
|
+
// the DID a capability presents at check time and the DID its revocation was
|
|
44
|
+
// filed under are genuinely different strings for the same peer, with no
|
|
45
|
+
// attacker involved. Every DID crossing this boundary — written or compared —
|
|
46
|
+
// is folded through `normalizeDID`, because a raw equality here silently
|
|
47
|
+
// returns "not revoked".
|
|
14
48
|
export function createDelegationStore(db, adapter, logger = getKubunLogger('store-delegation')) {
|
|
15
49
|
const api = {
|
|
16
50
|
// --- Delegation token operations ---
|
|
@@ -26,17 +60,19 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
26
60
|
// for emission gating, and the LWW upsert itself remains correct under the
|
|
27
61
|
// SQL `>` arbiter regardless.
|
|
28
62
|
async addDelegationToken (token) {
|
|
63
|
+
const grantor = normalizeDID(token.grantor);
|
|
64
|
+
const audience = normalizeDID(token.audience);
|
|
29
65
|
const existing = await db.selectFrom('kubun_delegation_tokens').select([
|
|
30
66
|
'jti',
|
|
31
67
|
'token',
|
|
32
68
|
'act',
|
|
33
69
|
'exp',
|
|
34
70
|
'hlc'
|
|
35
|
-
]).where('grantor', '=',
|
|
71
|
+
]).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).executeTakeFirst();
|
|
36
72
|
await db.insertInto('kubun_delegation_tokens').values({
|
|
37
73
|
jti: token.jti,
|
|
38
|
-
grantor
|
|
39
|
-
audience
|
|
74
|
+
grantor,
|
|
75
|
+
audience,
|
|
40
76
|
token: token.token,
|
|
41
77
|
resource: token.resource,
|
|
42
78
|
act: token.act,
|
|
@@ -79,7 +115,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
79
115
|
graceSeconds: REVOCATION_GC_VERIFIED_GRACE_SECONDS
|
|
80
116
|
});
|
|
81
117
|
await api.purgeDeadPendingRevocations({
|
|
82
|
-
|
|
118
|
+
graceSeconds: REVOCATION_GC_VERIFIED_GRACE_SECONDS
|
|
83
119
|
});
|
|
84
120
|
} catch (error) {
|
|
85
121
|
logger.warn('revocation GC sweep failed during addDelegationToken: {error}', {
|
|
@@ -90,10 +126,18 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
90
126
|
return changed;
|
|
91
127
|
},
|
|
92
128
|
async getDelegationTokens (params) {
|
|
93
|
-
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', params.grantor).where('audience', '=', params.audience).execute();
|
|
129
|
+
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(params.grantor)).where('audience', '=', normalizeDID(params.audience)).execute();
|
|
94
130
|
},
|
|
131
|
+
// The join carries the author predicate for two reasons. It is an
|
|
132
|
+
// authorization filter — only the grantor's own claim may withhold a token
|
|
133
|
+
// from the audience holding it. It is also what keeps the result one row
|
|
134
|
+
// per token: `(jti, revoker_did)` is the revocation key, so pinning
|
|
135
|
+
// `revoker_did` to the token's grantor makes the fan-out at most 1. A
|
|
136
|
+
// repeated token would not merely be redundant — `checkCapability` reads
|
|
137
|
+
// the list as a delegation chain, and a duplicate breaks a multi-hop chain
|
|
138
|
+
// outright.
|
|
95
139
|
async getHeldTokens (params) {
|
|
96
|
-
return await db.selectFrom('kubun_delegation_tokens').leftJoin('kubun_revoked_capabilities', 'kubun_revoked_capabilities.jti', 'kubun_delegation_tokens.jti').selectAll('kubun_delegation_tokens').where('audience', '=', params.audience).where('exp', '>', params.atTime)// Pending revocations (verified_at IS NULL) are not binding until the
|
|
140
|
+
return await db.selectFrom('kubun_delegation_tokens').leftJoin('kubun_revoked_capabilities', (join)=>join.onRef('kubun_revoked_capabilities.jti', '=', 'kubun_delegation_tokens.jti').onRef('kubun_revoked_capabilities.revoker_did', '=', 'kubun_delegation_tokens.grantor')).selectAll('kubun_delegation_tokens').where('audience', '=', normalizeDID(params.audience)).where('exp', '>', params.atTime)// Pending revocations (verified_at IS NULL) are not binding until the
|
|
97
141
|
// cap-arrival cross-check verifies them, so they don't filter tokens.
|
|
98
142
|
.where((eb)=>eb.or([
|
|
99
143
|
eb('kubun_revoked_capabilities.jti', 'is', null),
|
|
@@ -101,11 +145,28 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
101
145
|
])).execute();
|
|
102
146
|
},
|
|
103
147
|
async listIssuedTokens (grantor) {
|
|
104
|
-
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', grantor).execute();
|
|
148
|
+
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(grantor)).execute();
|
|
105
149
|
},
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
150
|
+
// `jti` is picked by whoever minted the capability and the grant travels to
|
|
151
|
+
// the whole group, so any co-member can mint one reusing a `jti` it saw. It
|
|
152
|
+
// names a row only inside a single grantor's namespace, and even there it
|
|
153
|
+
// is not a key — the primary key is `(grantor, audience, resource)`. A
|
|
154
|
+
// grantor that reused one `jti` across two of its own grants would leave
|
|
155
|
+
// this read with no representable answer, so it throws rather than pick.
|
|
156
|
+
async getDelegationTokenByGrantorJTI (params) {
|
|
157
|
+
const grantor = normalizeDID(params.grantor);
|
|
158
|
+
const rows = await db.selectFrom('kubun_delegation_tokens').selectAll().where('jti', '=', params.jti).where('grantor', '=', grantor).limit(2).execute();
|
|
159
|
+
if (rows.length > 1) {
|
|
160
|
+
throw new Error(`Ambiguous delegation token: grantor ${grantor} holds more than one row for jti ${params.jti}`);
|
|
161
|
+
}
|
|
162
|
+
return rows[0] ?? null;
|
|
163
|
+
},
|
|
164
|
+
// Every grantor's row for one `jti`. For diagnostics and for absence
|
|
165
|
+
// assertions that must hold across grantors — an authorization decision
|
|
166
|
+
// needs `getDelegationTokenByGrantorJTI`, since any row here may be a
|
|
167
|
+
// co-member's.
|
|
168
|
+
async listDelegationTokensByJTI (jti) {
|
|
169
|
+
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('jti', '=', jti).orderBy('grantor').execute();
|
|
109
170
|
},
|
|
110
171
|
// Hard-delete a held token by `jti`. Used to drop a delegated write
|
|
111
172
|
// capability the server no longer needs — e.g. when a viewer disconnects a
|
|
@@ -122,25 +183,47 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
122
183
|
// per-millisecond counter stays within the pad width. The pre-read drives
|
|
123
184
|
// the boolean change signal that emission gates consume; the upsert is
|
|
124
185
|
// not wrapped in a transaction because callers already run inside one.
|
|
186
|
+
//
|
|
187
|
+
// Both the pre-read and the conflict target are scoped to `(jti,
|
|
188
|
+
// revoker_did)`: several revokers may name the same `jti`, and each may
|
|
189
|
+
// only arbitrate against its own row. A `jti`-only match here would let one
|
|
190
|
+
// author's HLC decide another author's outcome.
|
|
125
191
|
async addRevocation (input) {
|
|
192
|
+
const revokerDID = normalizeDID(input.revoker_did);
|
|
193
|
+
// `revoked_iat` is a claim from a signed broadcast, and on a pending row
|
|
194
|
+
// no issuer has been matched yet — so any co-member can name a `jti` with
|
|
195
|
+
// a far-future stamp and, since the pending sweep is keyed off this
|
|
196
|
+
// column, keep the row past every sweep. Floored, not rejected: the
|
|
197
|
+
// record still binds, only its retention is bounded.
|
|
198
|
+
const maxIat = Math.floor(Date.now() / 1000) + MAX_REVOCATION_FUTURE_DRIFT_SECONDS;
|
|
199
|
+
const revokedIat = Math.min(input.revoked_iat, maxIat);
|
|
200
|
+
if (revokedIat !== input.revoked_iat) {
|
|
201
|
+
logger.warn('revocation {jti} from {revoker} carries a future iat {iat}, floored to {floored}', {
|
|
202
|
+
jti: input.jti,
|
|
203
|
+
revoker: revokerDID,
|
|
204
|
+
iat: input.revoked_iat,
|
|
205
|
+
floored: revokedIat
|
|
206
|
+
});
|
|
207
|
+
}
|
|
126
208
|
const existing = await db.selectFrom('kubun_revoked_capabilities').select([
|
|
127
|
-
'revoker_did',
|
|
128
209
|
'revoked_iat',
|
|
129
210
|
'revocation_token',
|
|
130
211
|
'verified_at',
|
|
131
212
|
'cap_exp',
|
|
132
213
|
'hlc'
|
|
133
|
-
]).where('jti', '=', input.jti).executeTakeFirst();
|
|
214
|
+
]).where('jti', '=', input.jti).where('revoker_did', '=', revokerDID).executeTakeFirst();
|
|
134
215
|
await db.insertInto('kubun_revoked_capabilities').values({
|
|
135
216
|
jti: input.jti,
|
|
136
|
-
revoker_did:
|
|
137
|
-
revoked_iat:
|
|
217
|
+
revoker_did: revokerDID,
|
|
218
|
+
revoked_iat: revokedIat,
|
|
138
219
|
revocation_token: input.revocation_token,
|
|
139
220
|
verified_at: input.verified_at ?? null,
|
|
140
221
|
cap_exp: input.cap_exp ?? null,
|
|
141
222
|
hlc: input.hlc
|
|
142
|
-
}).onConflict((oc)=>oc.
|
|
143
|
-
|
|
223
|
+
}).onConflict((oc)=>oc.columns([
|
|
224
|
+
'jti',
|
|
225
|
+
'revoker_did'
|
|
226
|
+
]).doUpdateSet((eb)=>({
|
|
144
227
|
revoked_iat: eb.ref('excluded.revoked_iat'),
|
|
145
228
|
revocation_token: eb.ref('excluded.revocation_token'),
|
|
146
229
|
verified_at: eb.ref('excluded.verified_at'),
|
|
@@ -156,14 +239,23 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
156
239
|
}
|
|
157
240
|
const incomingVerifiedAt = input.verified_at ?? null;
|
|
158
241
|
const incomingCapExp = input.cap_exp ?? null;
|
|
159
|
-
return existing.
|
|
242
|
+
return existing.revoked_iat !== revokedIat || existing.revocation_token !== input.revocation_token || existing.verified_at !== incomingVerifiedAt || existing.cap_exp !== incomingCapExp;
|
|
160
243
|
},
|
|
161
|
-
|
|
162
|
-
|
|
244
|
+
// `jti` alone does not identify a row: several revokers may name the same
|
|
245
|
+
// capability and only the one signed by its issuer binds. Every read names
|
|
246
|
+
// the author it is asking about.
|
|
247
|
+
async getRevocationByIssuer (jti, issuer) {
|
|
248
|
+
const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).executeTakeFirst();
|
|
163
249
|
return row ?? null;
|
|
164
250
|
},
|
|
165
|
-
|
|
166
|
-
|
|
251
|
+
// Every author's claim about one `jti`. For diagnostics and for absence
|
|
252
|
+
// assertions that must hold across all authors — an enforcement decision
|
|
253
|
+
// needs `getRevocationByIssuer`, since any row here may be a co-member's.
|
|
254
|
+
async listRevocations (jti) {
|
|
255
|
+
return await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).orderBy('revoker_did').execute();
|
|
256
|
+
},
|
|
257
|
+
async isRevokedBy (jti, issuer) {
|
|
258
|
+
const row = await db.selectFrom('kubun_revoked_capabilities').select('jti').where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).where('verified_at', 'is not', null).executeTakeFirst();
|
|
167
259
|
return row != null;
|
|
168
260
|
},
|
|
169
261
|
// Explicit column list (no `selectAll`) because `kubun_revoked_capabilities`
|
|
@@ -171,9 +263,11 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
171
263
|
// `created_at`, `updated_at`); `selectAll` would produce an ambiguous
|
|
172
264
|
// projection. The INNER JOIN drops verified revocations whose cap row is not
|
|
173
265
|
// held locally, which is exactly the desired filter for an audience-scoped
|
|
174
|
-
// query.
|
|
266
|
+
// query. It also joins on `revoker_did = grantor`: a co-member's row for the
|
|
267
|
+
// same `jti` revokes nothing, so surfacing it here would report a held
|
|
268
|
+
// capability as revoked on someone else's say-so.
|
|
175
269
|
async getHeldRevocations (params) {
|
|
176
|
-
const rows = await db.selectFrom('kubun_revoked_capabilities').innerJoin('kubun_delegation_tokens', 'kubun_delegation_tokens.jti', 'kubun_revoked_capabilities.jti').select([
|
|
270
|
+
const rows = await db.selectFrom('kubun_revoked_capabilities').innerJoin('kubun_delegation_tokens', (join)=>join.onRef('kubun_delegation_tokens.jti', '=', 'kubun_revoked_capabilities.jti').onRef('kubun_delegation_tokens.grantor', '=', 'kubun_revoked_capabilities.revoker_did')).select([
|
|
177
271
|
'kubun_revoked_capabilities.jti as jti',
|
|
178
272
|
'kubun_delegation_tokens.grantor as grantor',
|
|
179
273
|
'kubun_delegation_tokens.audience as audience',
|
|
@@ -181,7 +275,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
181
275
|
'kubun_revoked_capabilities.revoked_iat as revoked_iat',
|
|
182
276
|
'kubun_revoked_capabilities.verified_at as verified_at',
|
|
183
277
|
'kubun_revoked_capabilities.cap_exp as cap_exp'
|
|
184
|
-
]).where('kubun_revoked_capabilities.verified_at', 'is not', null).where('kubun_delegation_tokens.audience', '=', params.audience).execute();
|
|
278
|
+
]).where('kubun_revoked_capabilities.verified_at', 'is not', null).where('kubun_delegation_tokens.audience', '=', normalizeDID(params.audience)).execute();
|
|
185
279
|
return rows.map((row)=>({
|
|
186
280
|
jti: row.jti,
|
|
187
281
|
grantor: row.grantor,
|
|
@@ -193,22 +287,30 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
193
287
|
cap_exp: row.cap_exp
|
|
194
288
|
}));
|
|
195
289
|
},
|
|
196
|
-
|
|
290
|
+
// Promotion is an authorization decision about one author's claim: the
|
|
291
|
+
// arriving capability proves only that its own issuer's record is genuine.
|
|
292
|
+
// Scoped to that author, so a co-member's row for the same `jti` is not
|
|
293
|
+
// promoted on the strength of someone else's capability.
|
|
294
|
+
async markRevocationVerified (jti, issuer, params) {
|
|
197
295
|
const verifiedAt = params.verified_at ?? Math.floor(Date.now() / 1000);
|
|
198
296
|
const result = await db.updateTable('kubun_revoked_capabilities').set({
|
|
199
297
|
verified_at: verifiedAt,
|
|
200
298
|
cap_exp: params.cap_exp,
|
|
201
299
|
updated_at: adapter.encodeTimestamp(new Date())
|
|
202
|
-
}).where('jti', '=', jti).where('verified_at', 'is', null).executeTakeFirst();
|
|
300
|
+
}).where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).where('verified_at', 'is', null).executeTakeFirst();
|
|
203
301
|
return (result.numUpdatedRows ?? 0n) > 0n;
|
|
204
302
|
},
|
|
205
|
-
async
|
|
206
|
-
const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).where('verified_at', 'is', null).executeTakeFirst();
|
|
303
|
+
async getPendingRevocationByIssuer (jti, issuer) {
|
|
304
|
+
const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).where('verified_at', 'is', null).executeTakeFirst();
|
|
207
305
|
return row ?? null;
|
|
208
306
|
},
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
307
|
+
// Drop the pending rows a capability from `issuer` proves nothing about.
|
|
308
|
+
// The inverse scoping of `markRevocationVerified`: what that promotes this
|
|
309
|
+
// discards, so the two together settle every pending row for the `jti`
|
|
310
|
+
// without either one answering for an author it did not name.
|
|
311
|
+
async deletePendingRevocationsFromOtherIssuers (jti, issuer) {
|
|
312
|
+
const result = await db.deleteFrom('kubun_revoked_capabilities').where('jti', '=', jti).where('revoker_did', '!=', normalizeDID(issuer)).where('verified_at', 'is', null).executeTakeFirst();
|
|
313
|
+
return Number(result.numDeletedRows ?? 0n);
|
|
212
314
|
},
|
|
213
315
|
// `cap_exp` is a plain integer column of epoch seconds (no adapter
|
|
214
316
|
// coercion on either side); the predicate is rewritten as
|
|
@@ -220,13 +322,22 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
220
322
|
const result = await db.deleteFrom('kubun_revoked_capabilities').where('verified_at', 'is not', null).where('cap_exp', 'is not', null).where('cap_exp', '<', cutoff).executeTakeFirst();
|
|
221
323
|
return Number(result.numDeletedRows ?? 0n);
|
|
222
324
|
},
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
325
|
+
// A pending row names a capability this device does not hold, so `cap_exp`
|
|
326
|
+
// is unknown and the row's useful life can only be bounded from the
|
|
327
|
+
// revocation itself: the capability was live when it was revoked, so
|
|
328
|
+
// `cap.exp ≤ revoked_iat + MAX_CAP_TTL_SECONDS`. Keyed off `revoked_iat`
|
|
329
|
+
// rather than `created_at` because a row dropped while its capability is
|
|
330
|
+
// still honored un-revokes it.
|
|
331
|
+
//
|
|
332
|
+
// `revoked_iat` is a bigint of unix seconds — not an adapter-encoded
|
|
333
|
+
// timestamp like `created_at`, which is integer seconds on SQLite but
|
|
334
|
+
// timestamptz on Postgres. Building this cutoff through
|
|
335
|
+
// `adapter.encodeTimestamp` would compare a Postgres ISO string against a
|
|
336
|
+
// bigint. Written as `revoked_iat < now - ttl - grace` so it stays plain
|
|
337
|
+
// integer arithmetic on both back-ends.
|
|
227
338
|
async purgeDeadPendingRevocations (params) {
|
|
228
|
-
const
|
|
229
|
-
const result = await db.deleteFrom('kubun_revoked_capabilities').where('verified_at', 'is', null).where('
|
|
339
|
+
const cutoff = Math.floor(Date.now() / 1000) - MAX_CAP_TTL_SECONDS - Math.floor(params.graceSeconds);
|
|
340
|
+
const result = await db.deleteFrom('kubun_revoked_capabilities').where('verified_at', 'is', null).where('revoked_iat', '<', cutoff).executeTakeFirst();
|
|
230
341
|
return Number(result.numDeletedRows ?? 0n);
|
|
231
342
|
}
|
|
232
343
|
};
|
package/lib/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
2
|
import type { DelegationStoreAPI } from './api.js';
|
|
3
3
|
export type { DelegationStoreAPI } from './api.js';
|
|
4
|
-
export { createDelegationStore } from './api.js';
|
|
4
|
+
export { createDelegationStore, MAX_CAP_TTL_SECONDS, MAX_REVOCATION_FUTURE_DRIFT_SECONDS, REVOCATION_GC_VERIFIED_GRACE_SECONDS, } from './api.js';
|
|
5
5
|
export { delegationStoreDefinition } from './definition.js';
|
|
6
|
-
export {
|
|
6
|
+
export { createDelegationRevocationChecker, type RevocationClaims, } from './revocation-checker.js';
|
|
7
7
|
export declare const DELEGATION_STORE: "delegation";
|
|
8
8
|
export declare function getDelegationStore(provider: StoreProvider): Promise<DelegationStoreAPI>;
|
|
9
9
|
export type { DelegationStoreTables, DelegationToken, DelegationTokenTable, HeldRevocation, InsertDelegationToken, InsertRevokedCapability, RevokedCapability, RevokedCapabilityTable, } from './tables.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { createDelegationStore } from './api.js';
|
|
1
|
+
export { createDelegationStore, MAX_CAP_TTL_SECONDS, MAX_REVOCATION_FUTURE_DRIFT_SECONDS, REVOCATION_GC_VERIFIED_GRACE_SECONDS } from './api.js';
|
|
2
2
|
export { delegationStoreDefinition } from './definition.js';
|
|
3
|
-
export {
|
|
3
|
+
export { createDelegationRevocationChecker } from './revocation-checker.js';
|
|
4
4
|
export const DELEGATION_STORE = 'delegation';
|
|
5
5
|
export function getDelegationStore(provider) {
|
|
6
6
|
return provider.getStore(DELEGATION_STORE);
|
package/lib/migrations.js
CHANGED
|
@@ -33,12 +33,22 @@ export function getDelegationMigrations(ctx) {
|
|
|
33
33
|
// and drives the GC sweep that prunes revocations once their cap has
|
|
34
34
|
// been expired long enough that no late-arriving mutation can still
|
|
35
35
|
// reference them.
|
|
36
|
-
|
|
36
|
+
//
|
|
37
|
+
// Keyed by `(jti, revoker_did)`, not `jti` alone: one row per `jti`
|
|
38
|
+
// cannot represent two revokers naming the same capability, so the LWW
|
|
39
|
+
// upsert would let any group member's higher-HLC broadcast overwrite the
|
|
40
|
+
// grantor's row. Each author owns its own row and can only displace
|
|
41
|
+
// itself. Every read of this table names an issuer for the same reason.
|
|
42
|
+
await db.schema.createTable('kubun_revoked_capabilities').ifNotExists().addColumn('jti', t.text, (col)=>col.notNull()).addColumn('revoker_did', t.text, (col)=>col.notNull())// Unix-seconds timestamps, bigint for the same reason as `exp` above:
|
|
37
43
|
// int32 seconds ceilings at 2038. int8 parser reads back as a number.
|
|
38
|
-
.addColumn('revoked_iat', t.bigint, (col)=>col.notNull()).addColumn('revocation_token', t.text, (col)=>col.notNull()).addColumn('verified_at', t.bigint).addColumn('cap_exp', t.bigint).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).
|
|
44
|
+
.addColumn('revoked_iat', t.bigint, (col)=>col.notNull()).addColumn('revocation_token', t.text, (col)=>col.notNull()).addColumn('verified_at', t.bigint).addColumn('cap_exp', t.bigint).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_revoked_capabilities', [
|
|
45
|
+
'jti',
|
|
46
|
+
'revoker_did'
|
|
47
|
+
]).execute();
|
|
39
48
|
// GC sweep predicate filters on verified-and-cap-expired; the secondary
|
|
40
49
|
// index keeps that scan cheap at the cost of one extra index on the
|
|
41
|
-
// write path.
|
|
50
|
+
// write path. `jti` leads the primary key, so the per-issuer lookups on
|
|
51
|
+
// the hot path use it as a prefix and need no index of their own.
|
|
42
52
|
await db.schema.createIndex('idx_revoked_capabilities_verified_cap_exp').ifNotExists().on('kubun_revoked_capabilities').columns([
|
|
43
53
|
'verified_at',
|
|
44
54
|
'cap_exp'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import type { DelegationStoreAPI } from './api.js';
|
|
3
|
+
export type RevocationClaims = {
|
|
4
|
+
jti: string;
|
|
5
|
+
iss: string;
|
|
6
|
+
rev: true;
|
|
7
|
+
iat: number;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Revocation hook backed by the delegation store.
|
|
11
|
+
*
|
|
12
|
+
* The store holds one row per (capability, revoker): a `jti` alone names
|
|
13
|
+
* several claims, of which only the one signed by the capability's own issuer
|
|
14
|
+
* binds. The lookup is scoped to that issuer so a co-member's claim about the
|
|
15
|
+
* same `jti` can neither answer in its place nor hide it.
|
|
16
|
+
*
|
|
17
|
+
* kokuin's checker still re-verifies the record's signature and re-compares the
|
|
18
|
+
* issuer, so a row this device could not cross-check against a locally held
|
|
19
|
+
* grant is safe to hand over.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createDelegationRevocationChecker(api: DelegationStoreAPI): VerifyTokenHook;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createRevocationChecker } from '@kokuin/capability';
|
|
2
|
+
import { verifyToken } from '@kokuin/token';
|
|
3
|
+
/**
|
|
4
|
+
* Revocation hook backed by the delegation store.
|
|
5
|
+
*
|
|
6
|
+
* The store holds one row per (capability, revoker): a `jti` alone names
|
|
7
|
+
* several claims, of which only the one signed by the capability's own issuer
|
|
8
|
+
* binds. The lookup is scoped to that issuer so a co-member's claim about the
|
|
9
|
+
* same `jti` can neither answer in its place nor hide it.
|
|
10
|
+
*
|
|
11
|
+
* kokuin's checker still re-verifies the record's signature and re-compares the
|
|
12
|
+
* issuer, so a row this device could not cross-check against a locally held
|
|
13
|
+
* grant is safe to hand over.
|
|
14
|
+
*/ export function createDelegationRevocationChecker(api) {
|
|
15
|
+
// The backend is per-token: its `get` closes over the issuer of the
|
|
16
|
+
// capability being checked, which is only known once the hook is called.
|
|
17
|
+
return async (token, raw)=>{
|
|
18
|
+
const issuer = token.payload.iss;
|
|
19
|
+
const backend = {
|
|
20
|
+
async add () {
|
|
21
|
+
// No-op. Revocations enter the delegation store through explicit local
|
|
22
|
+
// mint and broadcast/invite arrival paths, both of which carry context
|
|
23
|
+
// this adapter does not have (hlc, capability cross-check). The checker
|
|
24
|
+
// only consumes `get`, so this stub satisfies the interface without
|
|
25
|
+
// offering a second, lossy write path.
|
|
26
|
+
},
|
|
27
|
+
async get (jti) {
|
|
28
|
+
const row = await api.getRevocationByIssuer(jti, issuer);
|
|
29
|
+
if (row == null) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return await verifyToken(row.revocation_token);
|
|
34
|
+
} catch {
|
|
35
|
+
// A stored record that no longer verifies revokes nothing. The checker
|
|
36
|
+
// re-verifies too, but failing here keeps a corrupt row from surfacing.
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
await createRevocationChecker(backend)(token, raw);
|
|
42
|
+
};
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,39 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/store-delegation",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "see LICENSE.md",
|
|
3
|
+
"version": "0.12.1",
|
|
5
4
|
"keywords": [],
|
|
5
|
+
"license": "see LICENSE.md",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"type": "module",
|
|
7
|
-
"main": "lib/index.js",
|
|
8
|
-
"types": "lib/index.d.ts",
|
|
9
8
|
"exports": {
|
|
10
9
|
".": "./lib/index.js"
|
|
11
10
|
},
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"types": "lib/index.d.ts",
|
|
12
13
|
"files": [
|
|
13
14
|
"lib/*",
|
|
14
15
|
"LICENSE.md"
|
|
15
16
|
],
|
|
16
|
-
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kokuin/capability": "^0.
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"@kubun/db
|
|
22
|
-
"@kubun/logger": "^0.
|
|
18
|
+
"@kokuin/capability": "^0.2.2",
|
|
19
|
+
"@kokuin/token": "^0.4.0",
|
|
20
|
+
"kysely": "^0.29.5",
|
|
21
|
+
"@kubun/db": "^0.12.1",
|
|
22
|
+
"@kubun/logger": "^0.12.0",
|
|
23
|
+
"@kubun/db-adapter": "^0.13.0"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
|
-
"@testcontainers/postgresql": "^12.0
|
|
26
|
-
"@kubun/db-
|
|
27
|
-
"@kubun/db-
|
|
26
|
+
"@testcontainers/postgresql": "^12.1.0",
|
|
27
|
+
"@kubun/db-postgres": "^0.12.1",
|
|
28
|
+
"@kubun/db-better-sqlite": "^0.12.1"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
28
32
|
},
|
|
29
33
|
"scripts": {
|
|
34
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
30
35
|
"build:clean": "del lib",
|
|
31
36
|
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
32
37
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
33
38
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
34
|
-
"
|
|
39
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
35
40
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
36
|
-
"test:unit": "vitest run"
|
|
37
|
-
"test": "pnpm run test:types && pnpm run test:unit"
|
|
41
|
+
"test:unit": "vitest run"
|
|
38
42
|
}
|
|
39
43
|
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export function createRevocationBackend(api) {
|
|
2
|
-
return {
|
|
3
|
-
async add (_record) {
|
|
4
|
-
// No-op. Revocations enter the delegation store through explicit local
|
|
5
|
-
// mint and broadcast/invite arrival paths, both of which carry context
|
|
6
|
-
// this adapter does not have (signed token, hlc). Enkaku's revocation
|
|
7
|
-
// checker only consumes `isRevoked`, so this stub satisfies the interface
|
|
8
|
-
// without offering a second, lossy write path.
|
|
9
|
-
},
|
|
10
|
-
async isRevoked (jti) {
|
|
11
|
-
return await api.isRevoked(jti);
|
|
12
|
-
}
|
|
13
|
-
};
|
|
14
|
-
}
|