@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 +57 -0
- package/lib/api.d.ts +159 -0
- package/lib/api.js +424 -0
- package/lib/definition.d.ts +4 -0
- package/lib/definition.js +7 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +6 -0
- package/lib/migrations.d.ts +3 -0
- package/lib/migrations.js +57 -0
- package/lib/tables.d.ts +158 -0
- package/lib/tables.js +2 -0
- package/package.json +41 -0
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
|
+
}
|
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,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
|
+
}
|
package/lib/tables.d.ts
ADDED
|
@@ -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
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
|
+
}
|