@kokuin/store-vault 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -2
- package/lib/api.d.ts +5 -0
- package/lib/api.js +504 -0
- package/lib/batch.d.ts +89 -0
- package/lib/batch.js +159 -0
- package/lib/coordinator-context.d.ts +71 -0
- package/lib/coordinator-context.js +410 -0
- package/lib/coordinator-entries.d.ts +20 -0
- package/lib/coordinator-entries.js +258 -0
- package/lib/coordinator-open.d.ts +9 -0
- package/lib/coordinator-open.js +141 -0
- package/lib/coordinator-records.d.ts +27 -0
- package/lib/coordinator-records.js +132 -0
- package/lib/coordinator-rotate.d.ts +9 -0
- package/lib/coordinator-rotate.js +436 -0
- package/lib/coordinator-types.d.ts +139 -0
- package/lib/coordinator-types.js +1 -0
- package/lib/coordinator.d.ts +2 -0
- package/lib/coordinator.js +459 -0
- package/lib/definition.d.ts +6 -0
- package/lib/definition.js +12 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.js +7 -0
- package/lib/migrations.d.ts +2 -0
- package/lib/migrations.js +52 -0
- package/lib/record-context.d.ts +24 -0
- package/lib/record-context.js +34 -0
- package/lib/record-definition.d.ts +7 -0
- package/lib/record-definition.js +93 -0
- package/lib/record-tables.d.ts +47 -0
- package/lib/record-tables.js +1 -0
- package/lib/records-adapter.d.ts +3 -0
- package/lib/records-adapter.js +197 -0
- package/lib/records-ports.d.ts +4 -0
- package/lib/records-ports.js +72 -0
- package/lib/records-projection.d.ts +3 -0
- package/lib/records-projection.js +105 -0
- package/lib/records-rows.d.ts +54 -0
- package/lib/records-rows.js +155 -0
- package/lib/records.d.ts +59 -0
- package/lib/records.js +233 -0
- package/lib/settle.d.ts +12 -0
- package/lib/settle.js +18 -0
- package/lib/tables.d.ts +42 -0
- package/lib/tables.js +1 -0
- package/lib/types.d.ts +74 -0
- package/lib/types.js +1 -0
- package/package.json +45 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { chunk, withStoreTransaction } from '@hozon/db';
|
|
2
|
+
import { recordClaimDigest, recordKeyText, recordPayloadMatches, verifySignedRecord } from '@kokuin/records';
|
|
3
|
+
import { decodeVaultRecord, VAULT_RECORD_TYPES } from '@kokuin/vault';
|
|
4
|
+
import { PARAMETER_BUDGET } from './batch.js';
|
|
5
|
+
import { settleAll } from './settle.js';
|
|
6
|
+
export class VaultRecordRowError extends Error {
|
|
7
|
+
#type;
|
|
8
|
+
#id;
|
|
9
|
+
constructor(key){
|
|
10
|
+
super(`Stored vault record ${key.type} ${key.id} is corrupt`, {
|
|
11
|
+
cause: key.cause
|
|
12
|
+
});
|
|
13
|
+
this.name = 'VaultRecordRowError';
|
|
14
|
+
this.#type = key.type;
|
|
15
|
+
this.#id = key.id;
|
|
16
|
+
}
|
|
17
|
+
get type() {
|
|
18
|
+
return this.#type;
|
|
19
|
+
}
|
|
20
|
+
get id() {
|
|
21
|
+
return this.#id;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Keyed OR predicates per getMany statement, within SQLite's default bind limit.
|
|
25
|
+
export const GET_MANY_CHUNK = 160;
|
|
26
|
+
export const boundAdapters = new WeakMap();
|
|
27
|
+
export const acceptedKinds = new WeakMap();
|
|
28
|
+
export function supported(type) {
|
|
29
|
+
return VAULT_RECORD_TYPES.includes(type);
|
|
30
|
+
}
|
|
31
|
+
export function json(value) {
|
|
32
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
33
|
+
}
|
|
34
|
+
export function groupRows(rows) {
|
|
35
|
+
const groups = new Map();
|
|
36
|
+
for (const row of rows){
|
|
37
|
+
const key = recordKeyText(row);
|
|
38
|
+
const group = groups.get(key) ?? [];
|
|
39
|
+
group.push(row);
|
|
40
|
+
groups.set(key, group);
|
|
41
|
+
}
|
|
42
|
+
return groups;
|
|
43
|
+
}
|
|
44
|
+
/** Deletes the named copies of one record, in statements of at most `PARAMETER_BUDGET` binds. */ export async function deleteCopies(db, key, opHashes) {
|
|
45
|
+
await settleAll(// Three binds name the record. The rest name copies.
|
|
46
|
+
chunk(opHashes, PARAMETER_BUDGET - 3).map((part)=>{
|
|
47
|
+
return db.deleteFrom('vault_records').where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', 'in', part).execute();
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
export function whereKey(db, key) {
|
|
51
|
+
return db.selectFrom('vault_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id);
|
|
52
|
+
}
|
|
53
|
+
export async function assertDomain(bound) {
|
|
54
|
+
// The domain row is written once, in its own committed transaction, and never changed. Once it
|
|
55
|
+
// matches, it matches for the bound adapter's lifetime.
|
|
56
|
+
if (bound.domainChecked === true) return;
|
|
57
|
+
const row = await withStoreTransaction(bound.db, (db)=>{
|
|
58
|
+
return db.selectFrom('vault_record_domain').selectAll().executeTakeFirst();
|
|
59
|
+
});
|
|
60
|
+
if (row === undefined) throw new Error('Initialise the vault operation kind outside caller transactions');
|
|
61
|
+
if (row.op_kind !== bound.opKind) throw new TypeError('Vault records store belongs to another operation kind');
|
|
62
|
+
bound.domainChecked = true;
|
|
63
|
+
}
|
|
64
|
+
export async function checked(row, bound) {
|
|
65
|
+
const key = {
|
|
66
|
+
type: row.type,
|
|
67
|
+
owner: row.owner,
|
|
68
|
+
id: row.id
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
if (row.deleted !== 0 && row.deleted !== 1) throw new Error('invalid deleted flag');
|
|
72
|
+
const payload = row.payload === null ? null : Uint8Array.from(row.payload);
|
|
73
|
+
if (row.deleted === 1 !== (payload === null)) throw new Error('invalid payload liveness');
|
|
74
|
+
const meta = json(row.meta);
|
|
75
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) throw new Error('invalid metadata');
|
|
76
|
+
const copy = {
|
|
77
|
+
...key,
|
|
78
|
+
hlc: row.hlc,
|
|
79
|
+
opJWT: row.op_jwt,
|
|
80
|
+
opHash: row.op_hash,
|
|
81
|
+
payload
|
|
82
|
+
};
|
|
83
|
+
const op = await verifySignedRecord(copy, bound.opKind, bound.params.methods);
|
|
84
|
+
if (op === null) return null;
|
|
85
|
+
if (!recordPayloadMatches(copy, op.claim) || row.payload_digest !== op.claim.payloadDigest || row.deleted !== Number(op.claim.deleted) || recordClaimDigest({
|
|
86
|
+
...op.claim,
|
|
87
|
+
meta: meta
|
|
88
|
+
}) !== recordClaimDigest(op.claim)) throw new Error('stored claim mismatch');
|
|
89
|
+
if (!op.claim.deleted) decodeVaultRecord(copy, op.claim.meta);
|
|
90
|
+
return {
|
|
91
|
+
copy,
|
|
92
|
+
op
|
|
93
|
+
};
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
// biome-ignore lint/style/useErrorCause: the constructor forwards cause
|
|
96
|
+
throw new VaultRecordRowError({
|
|
97
|
+
...key,
|
|
98
|
+
cause
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function decision(rows, bound) {
|
|
103
|
+
const copies = [];
|
|
104
|
+
let selected;
|
|
105
|
+
let failure;
|
|
106
|
+
// Checks run together. Results are consumed in row order, as the sequential checks were.
|
|
107
|
+
const results = await Promise.allSettled(rows.map((row)=>checked(row, bound)));
|
|
108
|
+
for (const result of results){
|
|
109
|
+
if (result.status === 'rejected') {
|
|
110
|
+
failure ??= result.reason;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const value = result.value;
|
|
114
|
+
if (value === null) continue;
|
|
115
|
+
const contentDigest = recordClaimDigest(value.op.claim);
|
|
116
|
+
const decisionHLC = typeof value.op.claim.meta.version === 'string' ? value.op.claim.meta.version : value.op.claim.hlc;
|
|
117
|
+
if (selected !== undefined && (selected.contentDigest !== contentDigest || selected.decisionHLC !== decisionHLC)) throw new VaultRecordRowError(value.copy);
|
|
118
|
+
selected = {
|
|
119
|
+
contentDigest,
|
|
120
|
+
decisionHLC
|
|
121
|
+
};
|
|
122
|
+
copies.push(value.copy);
|
|
123
|
+
}
|
|
124
|
+
if (selected === undefined) {
|
|
125
|
+
if (failure !== undefined) throw failure;
|
|
126
|
+
// Invalid signatures cannot make an existing decision appear absent.
|
|
127
|
+
if (rows[0] !== undefined) throw new VaultRecordRowError(rows[0]);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const first = copies[0];
|
|
131
|
+
if (first === undefined) return null;
|
|
132
|
+
return {
|
|
133
|
+
key: {
|
|
134
|
+
type: first.type,
|
|
135
|
+
owner: first.owner,
|
|
136
|
+
id: first.id
|
|
137
|
+
},
|
|
138
|
+
...selected,
|
|
139
|
+
copies: copies.sort((a, b)=>a.opHash.localeCompare(b.opHash))
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function values(record, op, bound) {
|
|
143
|
+
return {
|
|
144
|
+
type: record.type,
|
|
145
|
+
owner: record.owner,
|
|
146
|
+
id: record.id,
|
|
147
|
+
hlc: record.hlc,
|
|
148
|
+
op_jwt: record.opJWT,
|
|
149
|
+
op_hash: record.opHash,
|
|
150
|
+
payload: record.payload === null ? null : bound.adapter.encodeBinary(record.payload),
|
|
151
|
+
payload_digest: op.claim.payloadDigest,
|
|
152
|
+
deleted: Number(op.claim.deleted),
|
|
153
|
+
meta: bound.adapter.encodeJSON(op.claim.meta)
|
|
154
|
+
};
|
|
155
|
+
}
|
package/lib/records.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ApplyRecordChunkParams, ApplyResult, RecordStoreAdapter, SignedRecord, VerifiedRecordOp } from '@kokuin/records';
|
|
2
|
+
import type { VaultRecordTypeParams } from '@kokuin/vault';
|
|
3
|
+
import type { EntryAddress, KeyAddress, VaultStoreAPI, WrappingRow } from './types.js';
|
|
4
|
+
export { vaultRecordPortsFor } from './records-ports.js';
|
|
5
|
+
export { VaultRecordRowError } from './records-rows.js';
|
|
6
|
+
export type VaultWritePreconditions = {
|
|
7
|
+
key?: KeyAddress & {
|
|
8
|
+
expectedVersion: number | null;
|
|
9
|
+
expectedBranches?: ReadonlyArray<string>;
|
|
10
|
+
};
|
|
11
|
+
entry?: EntryAddress & {
|
|
12
|
+
expectedHLC: string | null;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type VaultRecordsParams = Omit<VaultRecordTypeParams, 'portsFor'> & {
|
|
16
|
+
store: VaultStoreAPI;
|
|
17
|
+
nowMS: () => number;
|
|
18
|
+
maxDriftMS: number;
|
|
19
|
+
receiveClock?: ApplyRecordChunkParams['receiveClock'];
|
|
20
|
+
onCorruptRow?: (info: {
|
|
21
|
+
type: string;
|
|
22
|
+
id: string;
|
|
23
|
+
error: unknown;
|
|
24
|
+
}) => void;
|
|
25
|
+
};
|
|
26
|
+
export type VaultKeyBranch = {
|
|
27
|
+
branchID: string;
|
|
28
|
+
/** The `vault.key-branch` record that projected this branch. */
|
|
29
|
+
branchRecordID: string;
|
|
30
|
+
introducingOp: VerifiedRecordOp;
|
|
31
|
+
};
|
|
32
|
+
export type VaultRecords = {
|
|
33
|
+
adapter: RecordStoreAdapter;
|
|
34
|
+
apply(records: ReadonlyArray<SignedRecord>, transportContext?: unknown): Promise<ApplyResult>;
|
|
35
|
+
write(params: {
|
|
36
|
+
records: ReadonlyArray<SignedRecord>;
|
|
37
|
+
preconditions: VaultWritePreconditions;
|
|
38
|
+
transportContext?: unknown;
|
|
39
|
+
}): Promise<ApplyResult>;
|
|
40
|
+
listKeyBranches(address: KeyAddress & {
|
|
41
|
+
keyVersion: number;
|
|
42
|
+
}): Promise<Array<VaultKeyBranch>>;
|
|
43
|
+
/**
|
|
44
|
+
* Rewrites the key's branch projection from its stored, verified `vault.key-branch` records.
|
|
45
|
+
* Writes only when the projection differs. Returns whether it did.
|
|
46
|
+
*/
|
|
47
|
+
rebuildKeyBranches(address: KeyAddress): Promise<boolean>;
|
|
48
|
+
listWrappingsWithBranch(address: KeyAddress & {
|
|
49
|
+
keyVersion?: number;
|
|
50
|
+
}): Promise<Array<WrappingRow>>;
|
|
51
|
+
listEntryCandidates(address: KeyAddress & {
|
|
52
|
+
keyVersion: number;
|
|
53
|
+
}): Promise<Array<SignedRecord>>;
|
|
54
|
+
listPriorEntryTombstones(address: KeyAddress & {
|
|
55
|
+
beforeVersion: number;
|
|
56
|
+
}): Promise<Array<SignedRecord>>;
|
|
57
|
+
listRevocations(address: KeyAddress): Promise<Array<string>>;
|
|
58
|
+
};
|
|
59
|
+
export declare function createVaultRecords(params: VaultRecordsParams): VaultRecords;
|
package/lib/records.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { withStoreTransaction } from '@hozon/db';
|
|
2
|
+
import { applyRecordChunk, getRecordDecisions, recordKeyText, verifyRecordOp, verifySignedRecord } from '@kokuin/records';
|
|
3
|
+
import { decodeVaultRecord, vaultRecordTypes } from '@kokuin/vault';
|
|
4
|
+
import { KEY_BRANCH_COLUMNS, statementChunks, upsertKeyBranches } from './batch.js';
|
|
5
|
+
import { VaultWriteConflict, vaultDatabases } from './record-context.js';
|
|
6
|
+
import { makeAdapter } from './records-adapter.js';
|
|
7
|
+
import { vaultRecordPortsFor } from './records-ports.js';
|
|
8
|
+
import { acceptedKinds, assertDomain, boundAdapters, checked, VaultRecordRowError } from './records-rows.js';
|
|
9
|
+
import { settleAll, settledValue } from './settle.js';
|
|
10
|
+
export { vaultRecordPortsFor } from './records-ports.js';
|
|
11
|
+
export { VaultRecordRowError } from './records-rows.js';
|
|
12
|
+
export function createVaultRecords(params) {
|
|
13
|
+
if (typeof params.opKind !== 'string' || params.opKind.length === 0) throw new TypeError('An explicit operation kind is required');
|
|
14
|
+
const storage = vaultDatabases.get(params.store);
|
|
15
|
+
if (storage === undefined || !storage.context.provider.hasStore('vault-records')) throw new Error('Register the optional vault records store first');
|
|
16
|
+
const existingKind = acceptedKinds.get(params.store);
|
|
17
|
+
if (existingKind !== undefined && existingKind !== params.opKind) throw new TypeError('Vault records store belongs to another operation kind');
|
|
18
|
+
acceptedKinds.set(params.store, params.opKind);
|
|
19
|
+
const bound = {
|
|
20
|
+
...storage,
|
|
21
|
+
params,
|
|
22
|
+
opKind: params.opKind,
|
|
23
|
+
store: params.store
|
|
24
|
+
};
|
|
25
|
+
const adapter = makeAdapter(bound);
|
|
26
|
+
const types = vaultRecordTypes({
|
|
27
|
+
...params,
|
|
28
|
+
portsFor: vaultRecordPortsFor
|
|
29
|
+
});
|
|
30
|
+
const apply = (records, store, transportContext)=>{
|
|
31
|
+
return applyRecordChunk({
|
|
32
|
+
records,
|
|
33
|
+
store,
|
|
34
|
+
types,
|
|
35
|
+
opKind: params.opKind,
|
|
36
|
+
nowMS: params.nowMS(),
|
|
37
|
+
maxDriftMS: params.maxDriftMS,
|
|
38
|
+
receiveClock: params.receiveClock ?? (()=>{}),
|
|
39
|
+
methods: params.methods,
|
|
40
|
+
transportContext,
|
|
41
|
+
resolveController: async ()=>null,
|
|
42
|
+
verifyCapability: async ()=>false
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const candidates = async (address, version, prior)=>{
|
|
46
|
+
await assertDomain(bound);
|
|
47
|
+
return withStoreTransaction(bound.db, async (tx)=>{
|
|
48
|
+
let query = tx.selectFrom('vault_entry_candidates').selectAll().where('owner', '=', address.ownerDID).where('key_id', '=', address.keyID);
|
|
49
|
+
query = prior ? query.where('key_version', '<', version).where('deleted', '=', 1) : query.where('key_version', '=', version);
|
|
50
|
+
const rows = await query.orderBy('id').orderBy('op_hash').execute();
|
|
51
|
+
return settleAll(rows.map(async (row)=>{
|
|
52
|
+
const value = await checked(row, bound);
|
|
53
|
+
if (value === null) throw new VaultRecordRowError(row);
|
|
54
|
+
return value.copy;
|
|
55
|
+
}));
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const api = {
|
|
59
|
+
adapter,
|
|
60
|
+
apply: (records, transportContext)=>apply(records, adapter, transportContext),
|
|
61
|
+
async write (request) {
|
|
62
|
+
if (request.preconditions === undefined) throw new TypeError('Signed writes require explicit preconditions');
|
|
63
|
+
return adapter.transaction(async (tx)=>{
|
|
64
|
+
const inner = boundAdapters.get(tx);
|
|
65
|
+
if (inner === undefined) throw new Error('Missing transaction adapter');
|
|
66
|
+
const expectedKey = request.preconditions.key;
|
|
67
|
+
const expectedEntry = request.preconditions.entry;
|
|
68
|
+
if (expectedKey === undefined && expectedEntry === undefined) throw new TypeError('Signed writes require a key or entry precondition');
|
|
69
|
+
// The address precondition reads nothing, so it runs before any store read, as it did.
|
|
70
|
+
const ops = await settleAll(request.records.map(async (record)=>{
|
|
71
|
+
const op = await verifySignedRecord(record, params.opKind, params.methods);
|
|
72
|
+
if (op !== null && !(expectedKey !== undefined && expectedKey.ownerDID === op.claim.owner && expectedKey.keyID === op.claim.meta.keyID || expectedEntry !== undefined && record.type === 'vault.entry' && expectedEntry.ownerDID === record.owner && expectedEntry.entryID === record.id)) throw new TypeError('Every signed output requires an address precondition');
|
|
73
|
+
return op;
|
|
74
|
+
}));
|
|
75
|
+
const [old, observedEntry] = await settleAll([
|
|
76
|
+
getRecordDecisions(tx, request.records),
|
|
77
|
+
expectedEntry === undefined ? Promise.resolve(undefined) : inner.store.getEntry(expectedEntry)
|
|
78
|
+
]);
|
|
79
|
+
const recordAddresses = await settleAll(request.records.map(async (record, position)=>{
|
|
80
|
+
const op = ops[position] ?? null;
|
|
81
|
+
const found = [];
|
|
82
|
+
if (op !== null && typeof op.claim.meta.keyID === 'string') found.push({
|
|
83
|
+
ownerDID: record.owner,
|
|
84
|
+
keyID: op.claim.meta.keyID
|
|
85
|
+
});
|
|
86
|
+
const copies = old.get(recordKeyText(record))?.copies ?? [];
|
|
87
|
+
const priors = await settleAll(copies.map((copy)=>verifySignedRecord(copy, params.opKind, params.methods)));
|
|
88
|
+
for (const [index, prior] of priors.entries()){
|
|
89
|
+
const copy = copies[index];
|
|
90
|
+
if (copy !== undefined && typeof prior?.claim.meta.keyID === 'string') found.push({
|
|
91
|
+
ownerDID: copy.owner,
|
|
92
|
+
keyID: prior.claim.meta.keyID
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}));
|
|
97
|
+
const addresses = recordAddresses.flat();
|
|
98
|
+
if (expectedKey !== undefined) addresses.push(expectedKey);
|
|
99
|
+
if (observedEntry !== undefined) addresses.push(observedEntry);
|
|
100
|
+
await tx.lock?.(addresses.map((address)=>{
|
|
101
|
+
return {
|
|
102
|
+
type: 'vault.key',
|
|
103
|
+
key: JSON.stringify([
|
|
104
|
+
address.ownerDID,
|
|
105
|
+
address.keyID
|
|
106
|
+
])
|
|
107
|
+
};
|
|
108
|
+
}));
|
|
109
|
+
const heldKeys = new Set(addresses.map((address)=>JSON.stringify([
|
|
110
|
+
address.ownerDID,
|
|
111
|
+
address.keyID
|
|
112
|
+
])));
|
|
113
|
+
// Authoritative reads follow the lock. They are independent of each other.
|
|
114
|
+
const keyCheck = async ()=>{
|
|
115
|
+
if (expectedKey === undefined) return true;
|
|
116
|
+
const row = await inner.store.getKey(expectedKey);
|
|
117
|
+
if ((row?.version ?? null) !== expectedKey.expectedVersion) return false;
|
|
118
|
+
if (expectedKey.expectedBranches === undefined) return true;
|
|
119
|
+
const branches = await createVaultRecords({
|
|
120
|
+
...params,
|
|
121
|
+
store: inner.store
|
|
122
|
+
}).listKeyBranches({
|
|
123
|
+
...expectedKey,
|
|
124
|
+
keyVersion: expectedKey.expectedVersion ?? 0
|
|
125
|
+
});
|
|
126
|
+
const current = branches.map((branch)=>branch.branchID).sort();
|
|
127
|
+
const expected = [
|
|
128
|
+
...expectedKey.expectedBranches
|
|
129
|
+
].sort();
|
|
130
|
+
return JSON.stringify(current) === JSON.stringify(expected);
|
|
131
|
+
};
|
|
132
|
+
// All three run together. Results are checked in the old order: held copies, entry key,
|
|
133
|
+
// key version and branches, entry HLC.
|
|
134
|
+
const [currentRead, entryRead, keyRead] = await Promise.allSettled([
|
|
135
|
+
getRecordDecisions(tx, request.records),
|
|
136
|
+
expectedEntry === undefined ? Promise.resolve(undefined) : inner.store.getEntry(expectedEntry),
|
|
137
|
+
keyCheck()
|
|
138
|
+
]);
|
|
139
|
+
const current = settledValue(currentRead);
|
|
140
|
+
const currentOps = await settleAll(request.records.flatMap((record)=>{
|
|
141
|
+
return (current.get(recordKeyText(record))?.copies ?? []).map((copy)=>{
|
|
142
|
+
return verifySignedRecord(copy, params.opKind, params.methods);
|
|
143
|
+
});
|
|
144
|
+
}));
|
|
145
|
+
for (const op of currentOps)if (op !== null && !heldKeys.has(JSON.stringify([
|
|
146
|
+
op.claim.owner,
|
|
147
|
+
op.claim.meta.keyID
|
|
148
|
+
]))) throw new VaultWriteConflict();
|
|
149
|
+
const currentEntry = settledValue(entryRead);
|
|
150
|
+
if (expectedEntry !== undefined && currentEntry?.keyID !== observedEntry?.keyID) throw new VaultWriteConflict();
|
|
151
|
+
if (!settledValue(keyRead)) throw new VaultWriteConflict();
|
|
152
|
+
if (expectedEntry !== undefined && (currentEntry?.hlc ?? null) !== expectedEntry.expectedHLC) throw new VaultWriteConflict();
|
|
153
|
+
return apply(request.records, tx, request.transportContext);
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
async listKeyBranches (address) {
|
|
157
|
+
await assertDomain(bound);
|
|
158
|
+
return withStoreTransaction(bound.db, async (tx)=>{
|
|
159
|
+
const rows = await tx.selectFrom('vault_key_branches').selectAll().where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).where('key_version', '=', address.keyVersion).orderBy('branch_id').execute();
|
|
160
|
+
const ops = await settleAll(rows.map((row)=>verifyRecordOp(row.op_jwt, params.opKind, params.methods)));
|
|
161
|
+
const result = [];
|
|
162
|
+
for (const [index, row] of rows.entries()){
|
|
163
|
+
const op = ops[index] ?? null;
|
|
164
|
+
if (op === null || op.claim.owner !== address.ownerDID || op.claim.id !== address.keyID || op.claim.meta.version !== address.keyVersion || row.branch_id !== `${op.claim.hlc}:${op.opHash}`) throw new VaultRecordRowError({
|
|
165
|
+
type: 'vault.key-branch',
|
|
166
|
+
owner: row.owner_did,
|
|
167
|
+
id: row.record_id
|
|
168
|
+
});
|
|
169
|
+
result.push({
|
|
170
|
+
branchID: row.branch_id,
|
|
171
|
+
branchRecordID: row.record_id,
|
|
172
|
+
introducingOp: op
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
async listWrappingsWithBranch (address) {
|
|
179
|
+
await assertDomain(bound);
|
|
180
|
+
return params.store.listWrappings(address);
|
|
181
|
+
},
|
|
182
|
+
async rebuildKeyBranches (address) {
|
|
183
|
+
await assertDomain(bound);
|
|
184
|
+
const decisions = await adapter.list([
|
|
185
|
+
'vault.key-branch'
|
|
186
|
+
]);
|
|
187
|
+
const candidates = decisions.flatMap((decision)=>{
|
|
188
|
+
const record = decision.copies[0];
|
|
189
|
+
return record !== undefined && record.owner === address.ownerDID ? [
|
|
190
|
+
record
|
|
191
|
+
] : [];
|
|
192
|
+
});
|
|
193
|
+
const ops = await settleAll(candidates.map((record)=>verifySignedRecord(record, params.opKind, params.methods)));
|
|
194
|
+
const rows = [];
|
|
195
|
+
for (const [index, record] of candidates.entries()){
|
|
196
|
+
const op = ops[index] ?? null;
|
|
197
|
+
if (op === null || op.claim.meta.keyID !== address.keyID) continue;
|
|
198
|
+
const decoded = decodeVaultRecord(record, op.claim.meta);
|
|
199
|
+
if (decoded.type !== 'vault.key-branch') continue;
|
|
200
|
+
rows.push({
|
|
201
|
+
owner_did: decoded.owner,
|
|
202
|
+
key_id: decoded.meta.keyID,
|
|
203
|
+
key_version: decoded.meta.keyVersion,
|
|
204
|
+
record_id: decoded.id,
|
|
205
|
+
branch_id: decoded.meta.branchID,
|
|
206
|
+
op_jwt: decoded.payload.opJWT
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const text = (list)=>{
|
|
210
|
+
return JSON.stringify(list.map((row)=>[
|
|
211
|
+
row.record_id,
|
|
212
|
+
row.key_version,
|
|
213
|
+
row.branch_id,
|
|
214
|
+
row.op_jwt
|
|
215
|
+
]).sort((a, b)=>String(a[0]).localeCompare(String(b[0]))));
|
|
216
|
+
};
|
|
217
|
+
return withStoreTransaction(bound.db, async (tx)=>{
|
|
218
|
+
const held = await tx.selectFrom('vault_key_branches').selectAll().where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).execute();
|
|
219
|
+
if (text(held) === text(rows)) return false;
|
|
220
|
+
await tx.deleteFrom('vault_key_branches').where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).execute();
|
|
221
|
+
// A record ID still projected under another key is taken over, as one upsert per row did.
|
|
222
|
+
await settleAll(statementChunks(rows, KEY_BRANCH_COLUMNS).map((part)=>{
|
|
223
|
+
return upsertKeyBranches(tx, part).execute();
|
|
224
|
+
}));
|
|
225
|
+
return true;
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
listRevocations: (address)=>vaultRecordPortsFor(adapter).listRevocations(address),
|
|
229
|
+
listEntryCandidates: (address)=>candidates(address, address.keyVersion, false),
|
|
230
|
+
listPriorEntryTombstones: (address)=>candidates(address, address.beforeVersion, true)
|
|
231
|
+
};
|
|
232
|
+
return api;
|
|
233
|
+
}
|
package/lib/settle.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type SettleRelease<Values extends ReadonlyArray<unknown>> = {
|
|
2
|
+
[Index in keyof Values]?: (value: Values[Index]) => void;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* Waits for every task, then throws the first rejection in task order. Before throwing, runs
|
|
6
|
+
* `release` on each fulfilled value, so a secret from a fulfilled sibling is still zeroed.
|
|
7
|
+
*/
|
|
8
|
+
export declare function settleAll<Values extends ReadonlyArray<unknown>>(tasks: {
|
|
9
|
+
[Index in keyof Values]: Promise<Values[Index]>;
|
|
10
|
+
}, release?: SettleRelease<Values>): Promise<Values>;
|
|
11
|
+
/** The value of a settled task, or its rejection thrown. Lets the caller pick the throw order. */
|
|
12
|
+
export declare function settledValue<Value>(result: PromiseSettledResult<Value>): Value;
|
package/lib/settle.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits for every task, then throws the first rejection in task order. Before throwing, runs
|
|
3
|
+
* `release` on each fulfilled value, so a secret from a fulfilled sibling is still zeroed.
|
|
4
|
+
*/ export async function settleAll(tasks, release) {
|
|
5
|
+
const settled = await Promise.allSettled(tasks);
|
|
6
|
+
const failure = settled.find((result)=>result.status === 'rejected');
|
|
7
|
+
if (failure === undefined) {
|
|
8
|
+
const values = settled.map((result)=>result.value);
|
|
9
|
+
return values;
|
|
10
|
+
}
|
|
11
|
+
const releases = release;
|
|
12
|
+
for (const [index, result] of settled.entries())if (result.status === 'fulfilled') releases?.[index]?.(result.value);
|
|
13
|
+
throw failure.reason;
|
|
14
|
+
}
|
|
15
|
+
/** The value of a settled task, or its rejection thrown. Lets the caller pick the throw order. */ export function settledValue(result) {
|
|
16
|
+
if (result.status === 'rejected') throw result.reason;
|
|
17
|
+
return result.value;
|
|
18
|
+
}
|
package/lib/tables.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Generated } from '@hozon/db';
|
|
2
|
+
import type { FactorDescriptor } from '@kokuin/vault';
|
|
3
|
+
export type VaultKeyTable = {
|
|
4
|
+
owner_did: string;
|
|
5
|
+
key_id: string;
|
|
6
|
+
suite: number;
|
|
7
|
+
version: number;
|
|
8
|
+
state: 'active' | 'retired';
|
|
9
|
+
mode: Generated<'local' | 'replicated'>;
|
|
10
|
+
};
|
|
11
|
+
export type VaultWrappingTable = {
|
|
12
|
+
owner_did: string;
|
|
13
|
+
wrapping_id: string;
|
|
14
|
+
key_id: string;
|
|
15
|
+
key_version: number;
|
|
16
|
+
descriptors: Array<FactorDescriptor>;
|
|
17
|
+
descriptor_digest: string;
|
|
18
|
+
iv: Uint8Array;
|
|
19
|
+
wrapped_key: Uint8Array;
|
|
20
|
+
recipient_did: string | null;
|
|
21
|
+
provenance: string;
|
|
22
|
+
mode: Generated<'local' | 'replicated'>;
|
|
23
|
+
};
|
|
24
|
+
export type VaultEntryTable = {
|
|
25
|
+
owner_did: string;
|
|
26
|
+
entry_id: string;
|
|
27
|
+
key_id: string;
|
|
28
|
+
key_version: number;
|
|
29
|
+
kind: string;
|
|
30
|
+
label: string;
|
|
31
|
+
attributes: Record<string, string>;
|
|
32
|
+
iv: Uint8Array;
|
|
33
|
+
ciphertext: Uint8Array;
|
|
34
|
+
hlc: string;
|
|
35
|
+
provenance: string;
|
|
36
|
+
mode: Generated<'local' | 'replicated'>;
|
|
37
|
+
};
|
|
38
|
+
export type VaultStoreTables = {
|
|
39
|
+
vault_keys: VaultKeyTable;
|
|
40
|
+
vault_wrappings: VaultWrappingTable;
|
|
41
|
+
vault_entries: VaultEntryTable;
|
|
42
|
+
};
|
package/lib/tables.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { EncryptEntryResult, VaultEntryMetadata, WrapResult } from '@kokuin/vault';
|
|
2
|
+
export type KeyAddress = {
|
|
3
|
+
ownerDID: string;
|
|
4
|
+
keyID: string;
|
|
5
|
+
};
|
|
6
|
+
export type EntryAddress = {
|
|
7
|
+
ownerDID: string;
|
|
8
|
+
entryID: string;
|
|
9
|
+
};
|
|
10
|
+
export type WrappingAddress = {
|
|
11
|
+
ownerDID: string;
|
|
12
|
+
wrappingID: string;
|
|
13
|
+
};
|
|
14
|
+
export type KeyRow = KeyAddress & {
|
|
15
|
+
suite: number;
|
|
16
|
+
version: number;
|
|
17
|
+
state: 'active' | 'retired';
|
|
18
|
+
};
|
|
19
|
+
export type EntryMetadata = VaultEntryMetadata;
|
|
20
|
+
export type WrappingRow = KeyAddress & WrapResult & WrappingAddress & {
|
|
21
|
+
keyVersion: number;
|
|
22
|
+
recipientDID: string | null;
|
|
23
|
+
provenance: string;
|
|
24
|
+
};
|
|
25
|
+
export type EntryRow = KeyAddress & EntryAddress & EntryMetadata & EncryptEntryResult & {
|
|
26
|
+
keyVersion: number;
|
|
27
|
+
hlc: string;
|
|
28
|
+
provenance: string;
|
|
29
|
+
};
|
|
30
|
+
export type EntryFilter = KeyAddress & Partial<EntryMetadata>;
|
|
31
|
+
export type UpdateEntryResult = 'updated' | 'absent' | 'superseded';
|
|
32
|
+
export type VaultStoreAPI = {
|
|
33
|
+
isTransaction: boolean;
|
|
34
|
+
transaction<Result>(fn: (store: VaultStoreAPI) => Promise<Result>): Promise<Result>;
|
|
35
|
+
/** Declare the complete key set before authoritative reads. Requires a transaction. */
|
|
36
|
+
lockKeyRows(addresses: ReadonlyArray<KeyAddress>): Promise<void>;
|
|
37
|
+
lockKeyRow(address: KeyAddress): Promise<void>;
|
|
38
|
+
putKey(row: KeyRow): Promise<void>;
|
|
39
|
+
getKey(address: KeyAddress): Promise<KeyRow | undefined>;
|
|
40
|
+
listKeys(ownerDID: string): Promise<Array<KeyRow>>;
|
|
41
|
+
setKeyState(row: KeyAddress & Pick<KeyRow, 'state'>): Promise<void>;
|
|
42
|
+
setKeyVersion(row: KeyAddress & Pick<KeyRow, 'version'>): Promise<void>;
|
|
43
|
+
deleteKey(address: KeyAddress): Promise<void>;
|
|
44
|
+
putWrapping(row: WrappingRow): Promise<void>;
|
|
45
|
+
/** As `putWrapping` for each row, in batched statements. A later row for one address wins. */
|
|
46
|
+
putWrappings(rows: ReadonlyArray<WrappingRow>): Promise<void>;
|
|
47
|
+
getWrapping(address: WrappingAddress): Promise<WrappingRow | undefined>;
|
|
48
|
+
listWrappings(address: KeyAddress & {
|
|
49
|
+
keyVersion?: number;
|
|
50
|
+
}): Promise<Array<WrappingRow>>;
|
|
51
|
+
deleteWrapping(address: WrappingAddress): Promise<void>;
|
|
52
|
+
/** As `deleteWrapping` for each address, in batched statements. */
|
|
53
|
+
deleteWrappings(addresses: ReadonlyArray<WrappingAddress>): Promise<void>;
|
|
54
|
+
deleteWrappingsBelowVersion(address: KeyAddress & {
|
|
55
|
+
keyVersion: number;
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
putEntry(row: EntryRow): Promise<void>;
|
|
58
|
+
/** As `putEntry` for each row, in batched statements. A later row for one address wins. */
|
|
59
|
+
putEntries(rows: ReadonlyArray<EntryRow>): Promise<void>;
|
|
60
|
+
/** Stores caller-encrypted bytes. Does nothing if the observed source disappears or changes key. */
|
|
61
|
+
moveEntry(row: EntryRow): Promise<void>;
|
|
62
|
+
updateEntryIfPresent(row: EntryRow): Promise<'updated' | 'absent'>;
|
|
63
|
+
updateEntryIfUnchanged(row: EntryRow & {
|
|
64
|
+
expectedHLC: string;
|
|
65
|
+
}): Promise<UpdateEntryResult>;
|
|
66
|
+
getEntry(address: EntryAddress): Promise<EntryRow | undefined>;
|
|
67
|
+
getEntryWithProvenance(address: EntryAddress): Promise<EntryRow | undefined>;
|
|
68
|
+
listEntryOwners(entryID: string): Promise<Array<string>>;
|
|
69
|
+
listEntries(filter: EntryFilter): Promise<Array<EntryRow>>;
|
|
70
|
+
deleteEntry(address: EntryAddress): Promise<void>;
|
|
71
|
+
/** As `deleteEntry` for each address, in batched statements. */
|
|
72
|
+
deleteEntries(addresses: ReadonlyArray<EntryAddress>): Promise<void>;
|
|
73
|
+
getMaxEntryHLC(): Promise<string | null>;
|
|
74
|
+
};
|
package/lib/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kokuin/store-vault",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hozon-backed local vault storage",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vault",
|
|
7
|
+
"store"
|
|
8
|
+
],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/TairuFramework/kokuin",
|
|
12
|
+
"directory": "packages/store-vault"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./lib/index.js"
|
|
19
|
+
},
|
|
20
|
+
"main": "lib/index.js",
|
|
21
|
+
"types": "lib/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"lib/*"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@hozon/adapter": "^0.2.1",
|
|
27
|
+
"@hozon/db": "^0.5.0",
|
|
28
|
+
"@kokuin/records": "^0.2.0",
|
|
29
|
+
"@kokuin/vault": "^0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@hozon/node-sqlite": "^0.2.0",
|
|
33
|
+
"@types/node": "^26.6.5",
|
|
34
|
+
"kysely": "^0.29.7"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
41
|
+
"build:clean": "del-cli lib",
|
|
42
|
+
"build:js": "del-cli 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
43
|
+
"build:types": "del-cli 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
|
|
44
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
45
|
+
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
46
|
+
"test:unit": "vitest run"
|
|
47
|
+
}
|
|
6
48
|
}
|