@kubun/store-credential 0.13.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/LICENSE.md ADDED
@@ -0,0 +1,57 @@
1
+ # The Prosperity Public License 3.0.0
2
+
3
+ Contributor: Paul Le Cam
4
+
5
+ Source Code: https://github.com/PaulLeCam/kubun
6
+
7
+ ## Purpose
8
+
9
+ This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days.
10
+
11
+ ## Agreement
12
+
13
+ In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow.
14
+
15
+ ## Notices
16
+
17
+ Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above.
18
+
19
+ ## Commercial Trial
20
+
21
+ Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person.
22
+
23
+ ## Contributions Back
24
+
25
+ Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose.
26
+
27
+ ## Personal Uses
28
+
29
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose.
30
+
31
+ ## Noncommercial Organizations
32
+
33
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding.
34
+
35
+ ## Defense
36
+
37
+ Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent.
38
+
39
+ ## Copyright
40
+
41
+ The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it.
42
+
43
+ ## Patent
44
+
45
+ The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license.
46
+
47
+ ## Reliability
48
+
49
+ The contributor can't revoke this license.
50
+
51
+ ## Excuse
52
+
53
+ You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule.
54
+
55
+ ## No Liability
56
+
57
+ ***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.***
package/lib/api.d.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import type { Kysely } from 'kysely';
3
+ import type { CredentialEntry, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialKey, CredentialKeyBranch, CredentialKeyState, CredentialOp, CredentialOpSubjectKind, CredentialTables, CredentialWrapping, CredentialWrappingWithBranch } from './tables.js';
4
+ /**
5
+ * The signed op that produced a row's current state, as the store holds it.
6
+ *
7
+ * `opJWT` is opaque here — this package neither signs nor verifies, the same way
8
+ * it neither encrypts nor decrypts. `@kubun/credential` mints these and is the
9
+ * only thing that can read one.
10
+ */
11
+ export type CredentialOpInput = {
12
+ keyID: string;
13
+ authorDID: string;
14
+ hlc: string;
15
+ opJWT: string;
16
+ opHash: string;
17
+ /**
18
+ * The content-key branch this op joins — `${hlc}:${opHash}` of the branch's
19
+ * introducing key-op. Empty when the op is minted outside a rotation/create
20
+ * and so does not resolve the branch it belongs to.
21
+ */
22
+ branchID: string;
23
+ };
24
+ /** A tombstone for one subject of a multi-row delete, which names its own. */
25
+ export type CredentialTombstoneInput = CredentialOpInput & {
26
+ subjectID: string;
27
+ };
28
+ export type PutCredentialKeyParams = {
29
+ keyID: string;
30
+ ownerDID: string;
31
+ suite: number;
32
+ version: number;
33
+ state: CredentialKeyState;
34
+ };
35
+ export type PutCredentialWrappingParams = {
36
+ wrappingID: string;
37
+ keyID: string;
38
+ keyVersion: number;
39
+ factors: Array<CredentialFactorDescriptor>;
40
+ iv: Uint8Array;
41
+ wrappedKey: Uint8Array;
42
+ recipientDID?: string | null;
43
+ };
44
+ export type PutCredentialEntryParams = {
45
+ entryID: string;
46
+ keyID: string;
47
+ keyVersion: number;
48
+ iv: Uint8Array;
49
+ ciphertext: Uint8Array;
50
+ hlc: string;
51
+ };
52
+ export type HeldCredentialSubject = {
53
+ subjectKind: CredentialOpSubjectKind;
54
+ subjectID: string;
55
+ opHash: string;
56
+ };
57
+ /**
58
+ * Persistence for credential keys, wrappings and entries.
59
+ *
60
+ * Storage only: nothing here wraps, unwraps, encrypts or decrypts, and nothing
61
+ * here interprets a ciphertext. `ciphertext` and `wrapped_key` are opaque byte
62
+ * strings that come back exactly as they went in — not parsed as JSON, not
63
+ * decoded as UTF-8, not validated in any way. A caller owning the crypto is what
64
+ * lets the suite change without a migration.
65
+ *
66
+ * A deletion is NOT the absence of a row. It is a tombstone in
67
+ * `kubun_credential_ops`, and the row is gone from its own table — so asking
68
+ * whether a wrapping exists means reading two tables, not one. Absence alone
69
+ * cannot travel over a lane that only adds: a peer holding the deleted row would
70
+ * ship it back and this device would store it again. So a `put` carrying an op no
71
+ * newer than the tombstone writes NOTHING, silently — that is the resend.
72
+ *
73
+ * The op is REQUIRED, and `null` is how a writer says it has none rather than
74
+ * how it says nothing. A row written with `null` is stored but speaks for
75
+ * itself, invisible to the sync lane; making the parameter mandatory is what
76
+ * stops a write path added later from being silently unsigned. Only the GraphQL
77
+ * query context passes it today: it holds a viewer's DID and no signing key.
78
+ */
79
+ export type CredentialStoreAPI = {
80
+ putKey(params: PutCredentialKeyParams, op: CredentialOpInput | null): Promise<void>;
81
+ getKey(keyID: string): Promise<CredentialKey | undefined>;
82
+ listKeys(ownerDID: string): Promise<Array<CredentialKey>>;
83
+ setKeyState(keyID: string, state: CredentialKeyState, op: CredentialOpInput | null): Promise<void>;
84
+ /** Bump a key to a new version. Wrappings and entries at earlier versions stay. */
85
+ setKeyVersion(keyID: string, version: number, op: CredentialOpInput | null): Promise<void>;
86
+ deleteKey(keyID: string, tombstone: CredentialOpInput | null): Promise<void>;
87
+ recordKeyBranch(params: {
88
+ keyID: string;
89
+ keyVersion: number;
90
+ authorDID: string;
91
+ hlc: string;
92
+ opJWT: string;
93
+ opHash: string;
94
+ branchID: string;
95
+ }): Promise<void>;
96
+ listKeyBranches(keyID: string, keyVersion: number): Promise<Array<CredentialKeyBranch>>;
97
+ putWrapping(params: PutCredentialWrappingParams, op: CredentialOpInput | null): Promise<void>;
98
+ getWrapping(wrappingID: string): Promise<CredentialWrapping | undefined>;
99
+ /** Every wrapping for a key, or only those at one version. */
100
+ listWrappings(keyID: string, keyVersion?: number): Promise<Array<CredentialWrapping>>;
101
+ /**
102
+ * As {@link listWrappings}, each row carrying its op's `branch_id`. The
103
+ * fork-aware read path needs the label to resolve which content-key branch a
104
+ * device opens; every other caller reads none, so the join stays off the
105
+ * common path.
106
+ */
107
+ listWrappingsWithBranch(keyID: string, keyVersion?: number): Promise<Array<CredentialWrappingWithBranch>>;
108
+ deleteWrapping(wrappingID: string, tombstone: CredentialOpInput | null): Promise<void>;
109
+ /**
110
+ * Drop every wrapping for a key below `keyVersion` — what a rotation leaves
111
+ * behind. The tombstones name their own subjects, because the caller decides
112
+ * which rows it is accounting for and the delete predicate spans many.
113
+ */
114
+ deleteWrappingsBelowVersion(keyID: string, keyVersion: number, tombstones: Array<CredentialTombstoneInput> | null): Promise<void>;
115
+ putEntry(params: PutCredentialEntryParams, op: CredentialOpInput | null): Promise<void>;
116
+ /**
117
+ * Explicitly change an entry's key association. Callers must authorize both
118
+ * source and target keys before invoking this capability.
119
+ */
120
+ moveEntry(params: PutCredentialEntryParams, op: CredentialOpInput | null): Promise<void>;
121
+ getEntry(entryID: string): Promise<CredentialEntry | undefined>;
122
+ getEntryWithProvenance(entryID: string): Promise<CredentialEntryWithProvenance | undefined>;
123
+ listEntries(keyID: string): Promise<Array<CredentialEntry>>;
124
+ /**
125
+ * Greatest stored entry HLC, or `null` when there are none. Credential writes
126
+ * advance the device clock but leave no row in the graph mutation log, so this
127
+ * is the only trace they leave for a restart floor to read.
128
+ */
129
+ getMaxEntryHLC(): Promise<string | null>;
130
+ deleteEntry(entryID: string, tombstone: CredentialOpInput | null): Promise<void>;
131
+ /** The op a subject's current state came from, tombstone included. */
132
+ getOp(subjectKind: CredentialOpSubjectKind, subjectID: string): Promise<CredentialOp | undefined>;
133
+ /**
134
+ * Ops in stamp order — every one, or one key's. This is the merkle lane's leaf
135
+ * scan, which is why the ops of all three subject kinds share a table.
136
+ */
137
+ listOps(keyID?: string): Promise<Array<CredentialOp>>;
138
+ /**
139
+ * The leaf scan scoped to what a requesting peer may hold.
140
+ *
141
+ * The document lane serves a tree scoped by read-ACL, but a credential is not
142
+ * readable by scope: a group co-member holds no wrapping under someone else's
143
+ * key, so serving them its rows ships ciphertext they can never open and reveals
144
+ * the key exists. The predicate is recipient-addressed instead. A key's row and
145
+ * its entry ops travel to any recipient of the key — a peer holding a wrapping
146
+ * under it. A wrapping's op travels only to the DID it names, so a recipient
147
+ * never receives a co-recipient's wrapping. Recipient match folds both sides
148
+ * with `normalizeDID`, because `recipient_did` is stored un-normalized.
149
+ */
150
+ listOpsForRequester(requesterDID: string, keyID?: string): Promise<Array<CredentialOp>>;
151
+ /**
152
+ * The live (non-tombstoned) subject ids this device holds — keys, wrappings and
153
+ * entries alike. What a reconcile caller advertises as `held`: the server reads
154
+ * it to skip rows the caller already has and to route a tombstone for a held id
155
+ * that is now revoked.
156
+ */
157
+ listHeldSubjects(): Promise<Array<HeldCredentialSubject>>;
158
+ };
159
+ export declare function createCredentialStore(db: Kysely<CredentialTables>, adapter: Adapter): CredentialStoreAPI;
package/lib/api.js ADDED
@@ -0,0 +1,424 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ /**
3
+ * Run `write` as one atomic unit.
4
+ *
5
+ * A row and the op that speaks for it must land together or not at all: a crash
6
+ * between a delete and its tombstone loses the deletion, and one between a put
7
+ * and its op leaves a row nothing authenticates. Kysely rejects a nested
8
+ * `transaction()`, and an ambient one already provides the atomicity, so inside
9
+ * a caller's transaction the statements run inline.
10
+ */ async function writeAtomically(db, write) {
11
+ if (db.isTransaction) {
12
+ await write(db);
13
+ return;
14
+ }
15
+ await db.transaction().execute(async (tx)=>{
16
+ await write(tx);
17
+ });
18
+ }
19
+ const OP_REJECTED = Symbol('credential op rejected');
20
+ async function writeOp(params) {
21
+ const { db, subjectKind, subjectID, op, deleted, allowKeyMove = false } = params;
22
+ const values = {
23
+ subject_kind: subjectKind,
24
+ subject_id: subjectID,
25
+ key_id: op.keyID,
26
+ author_did: op.authorDID,
27
+ hlc: op.hlc,
28
+ op_jwt: op.opJWT,
29
+ op_hash: op.opHash,
30
+ branch_id: op.branchID,
31
+ deleted
32
+ };
33
+ const result = await db.insertInto('kubun_credential_ops').values(values).onConflict((oc)=>{
34
+ const update = oc.columns([
35
+ 'subject_kind',
36
+ 'subject_id'
37
+ ]).doUpdateSet({
38
+ key_id: values.key_id,
39
+ author_did: values.author_did,
40
+ hlc: values.hlc,
41
+ op_jwt: values.op_jwt,
42
+ op_hash: values.op_hash,
43
+ branch_id: values.branch_id,
44
+ deleted: values.deleted
45
+ })// Ownership and the LWW comparison live in the conflict statement so
46
+ // concurrent replicas cannot both pass a preceding read.
47
+ .where((eb)=>eb.or([
48
+ eb('kubun_credential_ops.hlc', '<', values.hlc),
49
+ eb.and([
50
+ eb('kubun_credential_ops.hlc', '=', values.hlc),
51
+ eb('kubun_credential_ops.op_hash', '<', values.op_hash)
52
+ ]),
53
+ eb.and([
54
+ eb('kubun_credential_ops.hlc', '=', values.hlc),
55
+ eb('kubun_credential_ops.op_hash', '=', values.op_hash),
56
+ eb('kubun_credential_ops.deleted', '<=', values.deleted)
57
+ ])
58
+ ]));
59
+ return allowKeyMove ? update : update.where('kubun_credential_ops.key_id', '=', values.key_id);
60
+ }).executeTakeFirst();
61
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
62
+ }
63
+ export function createCredentialStore(db, adapter) {
64
+ /**
65
+ * Write a row and its op together, unless a tombstone already covers it.
66
+ *
67
+ * A `null` op writes the row on its own: the op is what a writer able to sign
68
+ * supplies, and nothing here can mint one.
69
+ */ async function writeSubject(params) {
70
+ const { subjectKind, subjectID, op, write, accept, allowKeyMove = false } = params;
71
+ if (op == null) {
72
+ if (accept == null) {
73
+ await write(db);
74
+ } else {
75
+ await writeAtomically(db, async (tx)=>{
76
+ if (await accept(tx)) await write(tx);
77
+ });
78
+ }
79
+ return;
80
+ }
81
+ const ownsTransaction = !db.isTransaction;
82
+ try {
83
+ await writeAtomically(db, async (tx)=>{
84
+ if (!await writeOp({
85
+ db: tx,
86
+ subjectKind,
87
+ subjectID,
88
+ op,
89
+ deleted: 0,
90
+ allowKeyMove
91
+ })) return;
92
+ // The op conflict serializes concurrent writers before this read. A
93
+ // rejected version must roll its provisional op update back too.
94
+ if (accept != null && !await accept(tx)) throw OP_REJECTED;
95
+ await write(tx);
96
+ });
97
+ } catch (error) {
98
+ if (error === OP_REJECTED && ownsTransaction) return;
99
+ throw error;
100
+ }
101
+ }
102
+ async function deleteSubject(params) {
103
+ const { subjectKind, subjectID, tombstone, remove } = params;
104
+ if (tombstone == null) {
105
+ await remove(db);
106
+ return;
107
+ }
108
+ await writeAtomically(db, async (tx)=>{
109
+ if (!await writeOp({
110
+ db: tx,
111
+ subjectKind,
112
+ subjectID,
113
+ op: tombstone,
114
+ deleted: 1
115
+ })) return;
116
+ await remove(tx);
117
+ });
118
+ }
119
+ async function writeEntry(params, op, allowMove) {
120
+ const values = {
121
+ entry_id: params.entryID,
122
+ key_id: params.keyID,
123
+ key_version: params.keyVersion,
124
+ iv: adapter.encodeBinary(params.iv),
125
+ ciphertext: adapter.encodeBinary(params.ciphertext),
126
+ hlc: params.hlc
127
+ };
128
+ await writeSubject({
129
+ subjectKind: 'entry',
130
+ subjectID: params.entryID,
131
+ op,
132
+ write: async (tx)=>{
133
+ if (!allowMove) {
134
+ const existing = await tx.selectFrom('kubun_credential_entries').select('key_id').where('entry_id', '=', params.entryID).executeTakeFirst();
135
+ if (existing != null && existing.key_id !== params.keyID) {
136
+ throw new Error(`Credential entry ${params.entryID} already belongs to key ${existing.key_id}`);
137
+ }
138
+ }
139
+ const result = await tx.insertInto('kubun_credential_entries').values(values).onConflict((oc)=>{
140
+ const update = oc.column('entry_id').doUpdateSet({
141
+ key_id: values.key_id,
142
+ key_version: values.key_version,
143
+ iv: values.iv,
144
+ ciphertext: values.ciphertext,
145
+ hlc: values.hlc
146
+ });
147
+ return allowMove ? update : update.where('kubun_credential_entries.key_id', '=', values.key_id);
148
+ }).executeTakeFirst();
149
+ if (!allowMove && (result.numInsertedOrUpdatedRows ?? 0n) === 0n) {
150
+ throw new Error(`Credential entry ${params.entryID} belongs to another key`);
151
+ }
152
+ },
153
+ allowKeyMove: allowMove
154
+ });
155
+ }
156
+ return {
157
+ async putKey (params, op) {
158
+ await writeSubject({
159
+ subjectKind: 'key',
160
+ subjectID: params.keyID,
161
+ op,
162
+ write: async (tx)=>{
163
+ await tx.insertInto('kubun_credential_keys').values({
164
+ key_id: params.keyID,
165
+ owner_did: params.ownerDID,
166
+ suite: params.suite,
167
+ version: params.version,
168
+ state: params.state
169
+ }).onConflict((oc)=>oc.column('key_id').doUpdateSet({
170
+ owner_did: params.ownerDID,
171
+ suite: params.suite,
172
+ version: params.version,
173
+ state: params.state
174
+ })).execute();
175
+ },
176
+ accept: async (tx)=>{
177
+ const existing = await tx.selectFrom('kubun_credential_keys').select('version').where('key_id', '=', params.keyID).executeTakeFirst();
178
+ return existing == null || params.version >= existing.version;
179
+ }
180
+ });
181
+ },
182
+ async getKey (keyID) {
183
+ return await db.selectFrom('kubun_credential_keys').selectAll().where('key_id', '=', keyID).executeTakeFirst();
184
+ },
185
+ async listKeys (ownerDID) {
186
+ return await db.selectFrom('kubun_credential_keys').selectAll().where('owner_did', '=', ownerDID).orderBy('created_at', 'asc').orderBy('key_id', 'asc').execute();
187
+ },
188
+ async setKeyState (keyID, state, op) {
189
+ await writeSubject({
190
+ subjectKind: 'key',
191
+ subjectID: keyID,
192
+ op,
193
+ write: async (tx)=>{
194
+ await tx.updateTable('kubun_credential_keys').set({
195
+ state
196
+ }).where('key_id', '=', keyID).execute();
197
+ }
198
+ });
199
+ },
200
+ async setKeyVersion (keyID, version, op) {
201
+ await writeSubject({
202
+ subjectKind: 'key',
203
+ subjectID: keyID,
204
+ op,
205
+ write: async (tx)=>{
206
+ await tx.updateTable('kubun_credential_keys').set({
207
+ version
208
+ }).where('key_id', '=', keyID).execute();
209
+ },
210
+ accept: async (tx)=>{
211
+ const existing = await tx.selectFrom('kubun_credential_keys').select('version').where('key_id', '=', keyID).executeTakeFirst();
212
+ return existing == null || version >= existing.version;
213
+ }
214
+ });
215
+ },
216
+ async deleteKey (keyID, tombstone) {
217
+ await deleteSubject({
218
+ subjectKind: 'key',
219
+ subjectID: keyID,
220
+ tombstone,
221
+ remove: async (tx)=>{
222
+ await tx.deleteFrom('kubun_credential_keys').where('key_id', '=', keyID).execute();
223
+ }
224
+ });
225
+ },
226
+ async recordKeyBranch (params) {
227
+ await db.insertInto('kubun_credential_key_branches').values({
228
+ key_id: params.keyID,
229
+ key_version: params.keyVersion,
230
+ op_hash: params.opHash,
231
+ author_did: params.authorDID,
232
+ hlc: params.hlc,
233
+ op_jwt: params.opJWT,
234
+ branch_id: params.branchID
235
+ }).onConflict((oc)=>oc.columns([
236
+ 'key_id',
237
+ 'key_version',
238
+ 'op_hash'
239
+ ]).doNothing()).execute();
240
+ },
241
+ async listKeyBranches (keyID, keyVersion) {
242
+ return await db.selectFrom('kubun_credential_key_branches').selectAll().where('key_id', '=', keyID).where('key_version', '=', keyVersion).orderBy('branch_id', 'asc').execute();
243
+ },
244
+ async putWrapping (params, op) {
245
+ const values = {
246
+ wrapping_id: params.wrappingID,
247
+ key_id: params.keyID,
248
+ key_version: params.keyVersion,
249
+ factors: adapter.encodeJSON(params.factors),
250
+ iv: adapter.encodeBinary(params.iv),
251
+ wrapped_key: adapter.encodeBinary(params.wrappedKey),
252
+ recipient_did: params.recipientDID ?? null
253
+ };
254
+ await writeSubject({
255
+ subjectKind: 'wrapping',
256
+ subjectID: params.wrappingID,
257
+ op,
258
+ write: async (tx)=>{
259
+ const existing = await tx.selectFrom('kubun_credential_wrappings').select('key_id').where('wrapping_id', '=', params.wrappingID).executeTakeFirst();
260
+ if (existing != null && existing.key_id !== params.keyID) {
261
+ throw new Error(`Credential wrapping ${params.wrappingID} already belongs to key ${existing.key_id}`);
262
+ }
263
+ const result = await tx.insertInto('kubun_credential_wrappings').values(values).onConflict((oc)=>oc.column('wrapping_id').doUpdateSet({
264
+ key_id: values.key_id,
265
+ key_version: values.key_version,
266
+ factors: values.factors,
267
+ iv: values.iv,
268
+ wrapped_key: values.wrapped_key,
269
+ recipient_did: values.recipient_did
270
+ }).where('kubun_credential_wrappings.key_id', '=', values.key_id)).executeTakeFirst();
271
+ if ((result.numInsertedOrUpdatedRows ?? 0n) === 0n) {
272
+ throw new Error(`Credential wrapping ${params.wrappingID} belongs to another key`);
273
+ }
274
+ }
275
+ });
276
+ },
277
+ async getWrapping (wrappingID) {
278
+ return await db.selectFrom('kubun_credential_wrappings').selectAll().where('wrapping_id', '=', wrappingID).executeTakeFirst();
279
+ },
280
+ async listWrappings (keyID, keyVersion) {
281
+ let query = db.selectFrom('kubun_credential_wrappings').selectAll().where('key_id', '=', keyID);
282
+ if (keyVersion != null) {
283
+ query = query.where('key_version', '=', keyVersion);
284
+ }
285
+ return await query.orderBy('created_at', 'asc').orderBy('wrapping_id', 'asc').execute();
286
+ },
287
+ async listWrappingsWithBranch (keyID, keyVersion) {
288
+ // LEFT JOIN so a wrapping with no op row still lists — its branch is the
289
+ // empty label. The ops PK is `(subject_kind, subject_id)`, so a wrapping
290
+ // matches at most one op and the join never fans out.
291
+ let query = db.selectFrom('kubun_credential_wrappings as w').leftJoin('kubun_credential_ops as o', (join)=>join.onRef('o.subject_id', '=', 'w.wrapping_id').on('o.subject_kind', '=', 'wrapping')).selectAll('w').select('o.branch_id as branch_id').where('w.key_id', '=', keyID);
292
+ if (keyVersion != null) {
293
+ query = query.where('w.key_version', '=', keyVersion);
294
+ }
295
+ const rows = await query.orderBy('w.created_at', 'asc').orderBy('w.wrapping_id', 'asc').execute();
296
+ return rows.map((row)=>({
297
+ ...row,
298
+ branch_id: row.branch_id ?? ''
299
+ }));
300
+ },
301
+ async deleteWrapping (wrappingID, tombstone) {
302
+ await deleteSubject({
303
+ subjectKind: 'wrapping',
304
+ subjectID: wrappingID,
305
+ tombstone,
306
+ remove: async (tx)=>{
307
+ await tx.deleteFrom('kubun_credential_wrappings').where('wrapping_id', '=', wrappingID).execute();
308
+ }
309
+ });
310
+ },
311
+ async deleteWrappingsBelowVersion (keyID, keyVersion, tombstones) {
312
+ const remove = async (tx)=>{
313
+ await tx.deleteFrom('kubun_credential_wrappings').where('key_id', '=', keyID).where('key_version', '<', keyVersion).execute();
314
+ };
315
+ if (tombstones == null) {
316
+ await remove(db);
317
+ return;
318
+ }
319
+ await writeAtomically(db, async (tx)=>{
320
+ for (const tombstone of tombstones){
321
+ if (!await writeOp({
322
+ db: tx,
323
+ subjectKind: 'wrapping',
324
+ subjectID: tombstone.subjectID,
325
+ op: tombstone,
326
+ deleted: 1
327
+ })) {
328
+ continue;
329
+ }
330
+ await tx.deleteFrom('kubun_credential_wrappings').where('wrapping_id', '=', tombstone.subjectID).where('key_id', '=', keyID).where('key_version', '<', keyVersion).execute();
331
+ }
332
+ });
333
+ },
334
+ async putEntry (params, op) {
335
+ await writeEntry(params, op, false);
336
+ },
337
+ async moveEntry (params, op) {
338
+ await writeEntry(params, op, true);
339
+ },
340
+ async getEntry (entryID) {
341
+ return await db.selectFrom('kubun_credential_entries').selectAll().where('entry_id', '=', entryID).executeTakeFirst();
342
+ },
343
+ async getEntryWithProvenance (entryID) {
344
+ return await db.selectFrom('kubun_credential_entries as e').leftJoin('kubun_credential_ops as o', (join)=>join.onRef('o.subject_id', '=', 'e.entry_id').on('o.subject_kind', '=', 'entry')).selectAll('e').select('o.author_did as author_did').where('e.entry_id', '=', entryID).executeTakeFirst();
345
+ },
346
+ async listEntries (keyID) {
347
+ return await db.selectFrom('kubun_credential_entries').selectAll().where('key_id', '=', keyID).orderBy('created_at', 'asc').orderBy('entry_id', 'asc').execute();
348
+ },
349
+ async deleteEntry (entryID, tombstone) {
350
+ await deleteSubject({
351
+ subjectKind: 'entry',
352
+ subjectID: entryID,
353
+ tombstone,
354
+ remove: async (tx)=>{
355
+ await tx.deleteFrom('kubun_credential_entries').where('entry_id', '=', entryID).execute();
356
+ }
357
+ });
358
+ },
359
+ async getMaxEntryHLC () {
360
+ const row = await db.selectFrom('kubun_credential_entries').select((eb)=>eb.fn.max('hlc').as('max_hlc')).executeTakeFirst();
361
+ return row?.max_hlc ?? null;
362
+ },
363
+ async getOp (subjectKind, subjectID) {
364
+ return await db.selectFrom('kubun_credential_ops').selectAll().where('subject_kind', '=', subjectKind).where('subject_id', '=', subjectID).executeTakeFirst();
365
+ },
366
+ async listOps (keyID) {
367
+ return await scanOps(db, keyID);
368
+ },
369
+ async listOpsForRequester (rawRequesterDID, keyID) {
370
+ const requesterDID = normalizeDID(rawRequesterDID);
371
+ // Recipient membership comes from the wrappings table, not the ops table: a
372
+ // rotation deletes a superseded wrapping's row but keeps its tombstone, and
373
+ // a former recipient of an opens-nothing wrapping is no longer a recipient.
374
+ // recipient_did is stored un-normalized, so fold at the compare.
375
+ const wrappings = await db.selectFrom('kubun_credential_wrappings').select([
376
+ 'wrapping_id',
377
+ 'key_id',
378
+ 'recipient_did'
379
+ ]).where('recipient_did', 'is not', null).execute();
380
+ const recipientKeyIDs = new Set();
381
+ const requesterWrappingIDs = new Set();
382
+ for (const wrapping of wrappings){
383
+ if (wrapping.recipient_did != null && normalizeDID(wrapping.recipient_did) === requesterDID) {
384
+ recipientKeyIDs.add(wrapping.key_id);
385
+ requesterWrappingIDs.add(wrapping.wrapping_id);
386
+ }
387
+ }
388
+ const ops = await scanOps(db, keyID);
389
+ // A wrapping's ciphertext is usable only by its addressee, so its op travels
390
+ // only to the DID it names — a co-recipient's wrapping stays withheld. A
391
+ // key's row and its entries travel to any recipient of the key. A wrapping
392
+ // tombstone names no addressee once its row is gone, so it cannot route
393
+ // here; revocation propagation is a separate concern.
394
+ return ops.filter((op)=>{
395
+ if (op.subject_kind === 'wrapping') {
396
+ return requesterWrappingIDs.has(op.subject_id);
397
+ }
398
+ return recipientKeyIDs.has(op.key_id);
399
+ });
400
+ },
401
+ async listHeldSubjects () {
402
+ const rows = await db.selectFrom('kubun_credential_ops').select([
403
+ 'subject_kind',
404
+ 'subject_id',
405
+ 'op_hash'
406
+ ]).where('deleted', '=', 0).execute();
407
+ return rows.map((row)=>({
408
+ subjectKind: row.subject_kind,
409
+ subjectID: row.subject_id,
410
+ opHash: row.op_hash
411
+ }));
412
+ }
413
+ };
414
+ }
415
+ /**
416
+ * The ops leaf scan in stamp order — every one, or one key's. Two devices must
417
+ * agree on this order, so it cannot be the insertion order, which is local.
418
+ */ async function scanOps(db, keyID) {
419
+ let query = db.selectFrom('kubun_credential_ops').selectAll();
420
+ if (keyID != null) {
421
+ query = query.where('key_id', '=', keyID);
422
+ }
423
+ return await query.orderBy('hlc', 'asc').orderBy('subject_id', 'asc').execute();
424
+ }
@@ -0,0 +1,4 @@
1
+ import type { StoreDefinition } from '@kubun/db';
2
+ import type { CredentialStoreAPI } from './api.js';
3
+ import type { CredentialTables } from './tables.js';
4
+ export declare const credentialStoreDefinition: StoreDefinition<CredentialTables, CredentialStoreAPI>;
@@ -0,0 +1,7 @@
1
+ import { createCredentialStore } from './api.js';
2
+ import { getCredentialMigrations } from './migrations.js';
3
+ export const credentialStoreDefinition = {
4
+ name: 'credential',
5
+ migrations: getCredentialMigrations,
6
+ createAPI: createCredentialStore
7
+ };
package/lib/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { CredentialStoreAPI } from './api.js';
3
+ export type { CredentialOpInput, CredentialStoreAPI, CredentialTombstoneInput, HeldCredentialSubject, PutCredentialEntryParams, PutCredentialKeyParams, PutCredentialWrappingParams, } from './api.js';
4
+ export { createCredentialStore } from './api.js';
5
+ export { credentialStoreDefinition } from './definition.js';
6
+ export declare const CREDENTIAL_STORE: "credential";
7
+ export declare function getCredentialStore(provider: StoreProvider): Promise<CredentialStoreAPI>;
8
+ export type { CredentialEntry, CredentialEntryTable, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialKey, CredentialKeyBranch, CredentialKeyBranchTable, CredentialKeyState, CredentialKeyTable, CredentialOp, CredentialOpSubjectKind, CredentialOpTable, CredentialTables, CredentialWrapping, CredentialWrappingTable, CredentialWrappingWithBranch, InsertCredentialEntry, InsertCredentialKey, InsertCredentialOp, InsertCredentialWrapping, } from './tables.js';
package/lib/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createCredentialStore } from './api.js';
2
+ export { credentialStoreDefinition } from './definition.js';
3
+ export const CREDENTIAL_STORE = 'credential';
4
+ export function getCredentialStore(provider) {
5
+ return provider.getStore(CREDENTIAL_STORE);
6
+ }
@@ -0,0 +1,3 @@
1
+ import type { MigrationContext } from '@kubun/db';
2
+ import type { Migration } from 'kysely/migration';
3
+ export declare function getCredentialMigrations(ctx: MigrationContext): Record<string, Migration>;
@@ -0,0 +1,57 @@
1
+ export function getCredentialMigrations(ctx) {
2
+ const t = ctx.types;
3
+ const now = ctx.functions.now;
4
+ const init = {
5
+ async up (db) {
6
+ // Credential keys: public record only, no key material.
7
+ await db.schema.createTable('kubun_credential_keys').ifNotExists().addColumn('key_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('suite', 'integer', (col)=>col.notNull()).addColumn('version', 'integer', (col)=>col.notNull()).addColumn('state', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
8
+ // Key-op history is retained so a branch label can be re-authenticated.
9
+ await db.schema.createTable('kubun_credential_key_branches').ifNotExists().addColumn('key_id', t.text, (col)=>col.notNull()).addColumn('key_version', 'integer', (col)=>col.notNull()).addColumn('op_hash', t.text, (col)=>col.notNull()).addColumn('author_did', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('op_jwt', t.text, (col)=>col.notNull()).addColumn('branch_id', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_kubun_credential_key_branches', [
10
+ 'key_id',
11
+ 'key_version',
12
+ 'op_hash'
13
+ ]).execute();
14
+ // Wrappings: one row per way of opening one version of one key. Keyed on
15
+ // its own id rather than (key, recipient) — a key can carry several
16
+ // wrappings for the same recipient, differing in their factor lists.
17
+ await db.schema.createTable('kubun_credential_wrappings').ifNotExists().addColumn('wrapping_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('key_id', t.text, (col)=>col.notNull()).addColumn('key_version', 'integer', (col)=>col.notNull()).addColumn('factors', t.json, (col)=>col.notNull()).addColumn('iv', t.binary, (col)=>col.notNull()).addColumn('wrapped_key', t.binary, (col)=>col.notNull()).addColumn('recipient_did', t.text).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
18
+ await db.schema.createIndex('idx_kubun_credential_wrappings_key').ifNotExists().on('kubun_credential_wrappings').columns([
19
+ 'key_id',
20
+ 'key_version'
21
+ ]).execute();
22
+ // Entries: opaque ciphertext under a key version.
23
+ await db.schema.createTable('kubun_credential_entries').ifNotExists().addColumn('entry_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('key_id', t.text, (col)=>col.notNull()).addColumn('key_version', 'integer', (col)=>col.notNull()).addColumn('iv', t.binary, (col)=>col.notNull()).addColumn('ciphertext', t.binary, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
24
+ await db.schema.createIndex('idx_kubun_credential_entries_key').ifNotExists().on('kubun_credential_entries').columns([
25
+ 'key_id'
26
+ ]).execute();
27
+ // Ops: the latest signed op per subject, and the only place a deletion is
28
+ // recorded. Keyed on (kind, id) rather than on the subject id alone —
29
+ // nothing guarantees a key id cannot equal an entry id, and a collision
30
+ // would let one subject's op tombstone another's.
31
+ await db.schema.createTable('kubun_credential_ops').ifNotExists().addColumn('subject_kind', t.text, (col)=>col.notNull()).addColumn('subject_id', t.text, (col)=>col.notNull()).addColumn('key_id', t.text, (col)=>col.notNull()).addColumn('author_did', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('op_jwt', t.text, (col)=>col.notNull()).addColumn('op_hash', t.text, (col)=>col.notNull())// The content-key branch this op joins. No index yet: partitioning by
32
+ // branch is a later slice, and the merkle scan still orders by hlc.
33
+ .addColumn('branch_id', t.text, (col)=>col.notNull().defaultTo('')).addColumn('deleted', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_kubun_credential_ops', [
34
+ 'subject_kind',
35
+ 'subject_id'
36
+ ]).execute();
37
+ await db.schema.createIndex('idx_kubun_credential_ops_key').ifNotExists().on('kubun_credential_ops').columns([
38
+ 'key_id'
39
+ ]).execute();
40
+ // The merkle lane buckets leaves by time and reads them in stamp order, so
41
+ // the scan that builds a tree is this index rather than a sort of the table.
42
+ await db.schema.createIndex('idx_kubun_credential_ops_hlc').ifNotExists().on('kubun_credential_ops').columns([
43
+ 'hlc'
44
+ ]).execute();
45
+ },
46
+ async down (db) {
47
+ await db.schema.dropTable('kubun_credential_ops').ifExists().execute();
48
+ await db.schema.dropTable('kubun_credential_entries').ifExists().execute();
49
+ await db.schema.dropTable('kubun_credential_wrappings').ifExists().execute();
50
+ await db.schema.dropTable('kubun_credential_key_branches').ifExists().execute();
51
+ await db.schema.dropTable('kubun_credential_keys').ifExists().execute();
52
+ }
53
+ };
54
+ return {
55
+ '0-init': init
56
+ };
57
+ }
@@ -0,0 +1,158 @@
1
+ import type { CreatedAtColumn } from '@kubun/db-adapter';
2
+ import type { ColumnType, Insertable, Selectable } from 'kysely';
3
+ type JSONArrayColumn<T> = ColumnType<Array<T>, Array<T> | undefined, Array<T>>;
4
+ type DefaultedNumberColumn = ColumnType<number, number | undefined, number>;
5
+ /**
6
+ * Whether a key is the one new entries encrypt under.
7
+ *
8
+ * `retired` is not `deleted`: a rotation bumps the version and leaves the old
9
+ * one readable, because entries written under it still exist. Dropping the row
10
+ * would make them undecryptable rather than stale.
11
+ */
12
+ export type CredentialKeyState = 'active' | 'retired';
13
+ /**
14
+ * One factor in a wrapping's ordered AND-list. `params` is per-kind public
15
+ * material — an ephemeral public key for `did`, an Argon2 salt and cost
16
+ * parameters for `passphrase` and `pin`. Never a secret.
17
+ */
18
+ export type CredentialFactorDescriptor = {
19
+ kind: string;
20
+ params: Record<string, unknown>;
21
+ };
22
+ /**
23
+ * A content key's public record. Carries no key material of any kind: the key
24
+ * itself exists only inside wrappings, and only as ciphertext.
25
+ */
26
+ export type CredentialKeyTable = {
27
+ key_id: string;
28
+ owner_did: string;
29
+ suite: number;
30
+ version: number;
31
+ state: string;
32
+ created_at: CreatedAtColumn;
33
+ };
34
+ export type CredentialKey = Selectable<CredentialKeyTable>;
35
+ export type InsertCredentialKey = Insertable<CredentialKeyTable>;
36
+ /** An authenticated key-op retained as append-only branch provenance. */
37
+ export type CredentialKeyBranchTable = {
38
+ key_id: string;
39
+ key_version: number;
40
+ op_hash: string;
41
+ author_did: string;
42
+ hlc: string;
43
+ op_jwt: string;
44
+ branch_id: string;
45
+ created_at: CreatedAtColumn;
46
+ };
47
+ export type CredentialKeyBranch = Selectable<CredentialKeyBranchTable>;
48
+ /**
49
+ * One way to open one version of one key.
50
+ *
51
+ * `factors` is ORDERED and the order is significant — the KEK derivation
52
+ * concatenates the factor secrets, so reordering produces a different key. It is
53
+ * also covered by the wrapping's AAD, which is what makes a tampered descriptor
54
+ * distinguishable from a wrong secret.
55
+ *
56
+ * `recipient_did` holds a peer:4 DID. It is an input to key resolution, not an
57
+ * identity for comparison: callers accept either long or short form and resolve
58
+ * the current long-form document before opening. Anything comparing this to a
59
+ * roster DID must normalize at the comparison.
60
+ *
61
+ * `iv` is its own column rather than a prefix on `wrapped_key`, matching entries.
62
+ * A wrapping is not a single self-contained blob under either framing — `factors`
63
+ * is bound into its AAD, so a recipient without the descriptors cannot open it
64
+ * regardless.
65
+ */
66
+ export type CredentialWrappingTable = {
67
+ wrapping_id: string;
68
+ key_id: string;
69
+ key_version: number;
70
+ factors: JSONArrayColumn<CredentialFactorDescriptor>;
71
+ iv: Uint8Array;
72
+ wrapped_key: Uint8Array;
73
+ recipient_did: string | null;
74
+ created_at: CreatedAtColumn;
75
+ };
76
+ export type CredentialWrapping = Selectable<CredentialWrappingTable>;
77
+ export type InsertCredentialWrapping = Insertable<CredentialWrappingTable>;
78
+ /**
79
+ * A wrapping paired with the branch label of its op, for the fork-aware read
80
+ * path. Empty label when no op row exists — a branch-agnostic wrapping, not a
81
+ * branch of its own.
82
+ */
83
+ export type CredentialWrappingWithBranch = CredentialWrapping & {
84
+ branch_id: string;
85
+ };
86
+ /**
87
+ * One encrypted value. The plaintext's shape is application-defined and the
88
+ * store never parses it — not as JSON, not as UTF-8, not at all.
89
+ *
90
+ * `key_version` is stored per entry rather than read from the key: a rotation
91
+ * moves the key forward while entries written under the old version stay
92
+ * readable, and the version is what selects the wrapping that opens them.
93
+ */
94
+ export type CredentialEntryTable = {
95
+ entry_id: string;
96
+ key_id: string;
97
+ key_version: number;
98
+ iv: Uint8Array;
99
+ ciphertext: Uint8Array;
100
+ hlc: string;
101
+ created_at: CreatedAtColumn;
102
+ };
103
+ export type CredentialEntry = Selectable<CredentialEntryTable>;
104
+ export type InsertCredentialEntry = Insertable<CredentialEntryTable>;
105
+ export type CredentialEntryWithProvenance = CredentialEntry & {
106
+ author_did: string | null;
107
+ };
108
+ /** Which of the three tables a signed op speaks for. */
109
+ export type CredentialOpSubjectKind = 'key' | 'wrapping' | 'entry';
110
+ /**
111
+ * The latest signed op for one key, wrapping or entry — replaced on update, not
112
+ * appended. There is no op history: a credential op is a whole-row replacement,
113
+ * and a log would retain the wrapped key and the ciphertext that a rotation
114
+ * exists to destroy.
115
+ *
116
+ * One table for all three subjects because a merkle tree wants its leaves from
117
+ * one scan rather than a union, and because a deletion has to travel: a lane
118
+ * that only adds cannot express absence, so a delete leaves a row here with
119
+ * `deleted` set and removes nothing else.
120
+ *
121
+ * `deleted` is an integer 0/1 rather than a boolean so it reads back identically
122
+ * on SQLite and Postgres, matching the other flags in this stack.
123
+ *
124
+ * `hlc` is the op author's stamp, not the local write time. For an entry it is
125
+ * the same string the entry row carries — the two must agree, and a test pins
126
+ * that.
127
+ */
128
+ export type CredentialOpTable = {
129
+ subject_kind: string;
130
+ subject_id: string;
131
+ /** So a whole vault can be served, swept or dropped together. */
132
+ key_id: string;
133
+ /** The op's verified issuer, normalized — this one IS compared. */
134
+ author_did: string;
135
+ hlc: string;
136
+ op_jwt: string;
137
+ /** Digest of `op_jwt`, as the graph mutation log addresses its mutations. */
138
+ op_hash: string;
139
+ /**
140
+ * Which content-key branch this op belongs to, `${hlc}:${opHash}` of the
141
+ * key-op that introduced the branch. A keyless party partitions and ranks
142
+ * branches by this without holding a wrapping. Empty for an op minted outside
143
+ * a rotation/create, which does not yet resolve the branch it joins.
144
+ */
145
+ branch_id: string;
146
+ deleted: DefaultedNumberColumn;
147
+ created_at: CreatedAtColumn;
148
+ };
149
+ export type CredentialOp = Selectable<CredentialOpTable>;
150
+ export type InsertCredentialOp = Insertable<CredentialOpTable>;
151
+ export type CredentialTables = {
152
+ kubun_credential_keys: CredentialKeyTable;
153
+ kubun_credential_key_branches: CredentialKeyBranchTable;
154
+ kubun_credential_wrappings: CredentialWrappingTable;
155
+ kubun_credential_entries: CredentialEntryTable;
156
+ kubun_credential_ops: CredentialOpTable;
157
+ };
158
+ export {};
package/lib/tables.js ADDED
@@ -0,0 +1,2 @@
1
+ // --- Aggregate CredentialTables type ---
2
+ export { };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@kubun/store-credential",
3
+ "version": "0.13.0",
4
+ "keywords": [],
5
+ "license": "see LICENSE.md",
6
+ "sideEffects": false,
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./lib/index.js"
10
+ },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
13
+ "files": [
14
+ "lib/*",
15
+ "LICENSE.md"
16
+ ],
17
+ "dependencies": {
18
+ "@kokuin/token": "^0.5.0",
19
+ "kysely": "^0.29.5",
20
+ "@kubun/db": "^0.13.0",
21
+ "@kubun/db-adapter": "^0.13.0"
22
+ },
23
+ "devDependencies": {
24
+ "@testcontainers/postgresql": "^12.1.0",
25
+ "@kubun/db-postgres": "^0.13.0",
26
+ "@kubun/db-better-sqlite": "^0.13.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
33
+ "build:clean": "del lib",
34
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
35
+ "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
36
+ "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
37
+ "test": "pnpm run test:types && pnpm run test:unit",
38
+ "test:types": "tsc --noEmit -p tsconfig.test.json",
39
+ "test:unit": "vitest run"
40
+ }
41
+ }