@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.
Files changed (48) hide show
  1. package/README.md +126 -2
  2. package/lib/api.d.ts +5 -0
  3. package/lib/api.js +504 -0
  4. package/lib/batch.d.ts +89 -0
  5. package/lib/batch.js +159 -0
  6. package/lib/coordinator-context.d.ts +71 -0
  7. package/lib/coordinator-context.js +410 -0
  8. package/lib/coordinator-entries.d.ts +20 -0
  9. package/lib/coordinator-entries.js +258 -0
  10. package/lib/coordinator-open.d.ts +9 -0
  11. package/lib/coordinator-open.js +141 -0
  12. package/lib/coordinator-records.d.ts +27 -0
  13. package/lib/coordinator-records.js +132 -0
  14. package/lib/coordinator-rotate.d.ts +9 -0
  15. package/lib/coordinator-rotate.js +436 -0
  16. package/lib/coordinator-types.d.ts +139 -0
  17. package/lib/coordinator-types.js +1 -0
  18. package/lib/coordinator.d.ts +2 -0
  19. package/lib/coordinator.js +459 -0
  20. package/lib/definition.d.ts +6 -0
  21. package/lib/definition.js +12 -0
  22. package/lib/index.d.ts +12 -0
  23. package/lib/index.js +7 -0
  24. package/lib/migrations.d.ts +2 -0
  25. package/lib/migrations.js +52 -0
  26. package/lib/record-context.d.ts +24 -0
  27. package/lib/record-context.js +34 -0
  28. package/lib/record-definition.d.ts +7 -0
  29. package/lib/record-definition.js +93 -0
  30. package/lib/record-tables.d.ts +47 -0
  31. package/lib/record-tables.js +1 -0
  32. package/lib/records-adapter.d.ts +3 -0
  33. package/lib/records-adapter.js +197 -0
  34. package/lib/records-ports.d.ts +4 -0
  35. package/lib/records-ports.js +72 -0
  36. package/lib/records-projection.d.ts +3 -0
  37. package/lib/records-projection.js +105 -0
  38. package/lib/records-rows.d.ts +54 -0
  39. package/lib/records-rows.js +155 -0
  40. package/lib/records.d.ts +59 -0
  41. package/lib/records.js +233 -0
  42. package/lib/settle.d.ts +12 -0
  43. package/lib/settle.js +18 -0
  44. package/lib/tables.d.ts +42 -0
  45. package/lib/tables.js +1 -0
  46. package/lib/types.d.ts +74 -0
  47. package/lib/types.js +1 -0
  48. package/package.json +45 -3
@@ -0,0 +1,141 @@
1
+ import { createPinThrottle, getSuite, NoAvailableFactor, satisfiableFactors, VaultPinRejected, VaultSecretRejected, VaultTampered, vaultRevocationRecordID } from '@kokuin/vault';
2
+ import { VaultKeyLocked, VaultNotFound, VaultRepairPending, VaultSupersededBranch } from './coordinator-context.js';
3
+ import { settledValue } from './settle.js';
4
+ export function createOpenFunctions(ctx) {
5
+ const { params, cache, cacheID } = ctx;
6
+ const throttle = createPinThrottle(params.pinThrottle);
7
+ const inFlight = new Map();
8
+ async function revoked(address, store) {
9
+ if (params.mode !== 'replicated') return false;
10
+ const denied = await ctx.recordsFor(store).listRevocations(address);
11
+ const own = vaultRevocationRecordID(address.keyID, params.principal);
12
+ return denied.some((did)=>vaultRevocationRecordID(address.keyID, did) === own);
13
+ }
14
+ async function doOpen(address, store, generation, specific) {
15
+ // Both reads run together. Their results are checked in the old order: key, version, revocation.
16
+ const [keyRead, revokedRead] = await Promise.allSettled([
17
+ ctx.key(address, store),
18
+ revoked(address, store)
19
+ ]);
20
+ const key = settledValue(keyRead);
21
+ const version = address.atVersion ?? key.version;
22
+ if (version > key.version || version < 1) throw new VaultNotFound();
23
+ if (settledValue(revokedRead)) {
24
+ ctx.evict(address);
25
+ throw new VaultNotFound();
26
+ }
27
+ const branch = await ctx.branch(address, version, store, specific);
28
+ const supplied = Object.values(address.inputs ?? {}).some((input)=>input !== undefined);
29
+ if (!supplied) {
30
+ const hit = cache.get(cacheID(address), version);
31
+ if (hit !== null) {
32
+ if (hit.branchID === branch.branchID) return {
33
+ ...hit,
34
+ version,
35
+ suite: branch.suite
36
+ };
37
+ hit.contentKey.fill(0);
38
+ cache.evictVersion(cacheID(address), version);
39
+ }
40
+ }
41
+ const wrappings = await store.listWrappings({
42
+ ...address,
43
+ keyVersion: version
44
+ });
45
+ let rejected;
46
+ let locked = false;
47
+ for (const row of wrappings){
48
+ if (params.mode === 'replicated' && row.provenance !== branch.branchID) continue;
49
+ const needed = satisfiableFactors(row.descriptors, params.identity);
50
+ if (needed === null) continue;
51
+ const evidence = await ctx.wrappingEvidence(row, store);
52
+ const expectedDescriptorDigest = evidence?.expectedDescriptorDigest;
53
+ try {
54
+ const contentKey = await getSuite(branch.suite).unwrap({
55
+ ...row,
56
+ identity: params.identity,
57
+ inputs: address.inputs,
58
+ expectedDescriptorDigest
59
+ });
60
+ if (ctx.generation(address) === generation && specific === undefined) cache.set(cacheID(address), contentKey, version, branch.branchID);
61
+ return {
62
+ contentKey,
63
+ version,
64
+ suite: branch.suite,
65
+ branchID: branch.branchID
66
+ };
67
+ } catch (error) {
68
+ if (error instanceof VaultTampered) throw error;
69
+ if (error instanceof VaultSecretRejected) rejected = error;
70
+ else if (error instanceof NoAvailableFactor && needed.length > 0) locked = true;
71
+ else throw error;
72
+ }
73
+ }
74
+ if (rejected !== undefined) throw rejected;
75
+ if (locked) throw new VaultKeyLocked();
76
+ if (params.mode === 'replicated' && wrappings.some((w)=>{
77
+ return w.provenance !== branch.branchID && satisfiableFactors(w.descriptors, params.identity) !== null;
78
+ })) throw new VaultRepairPending();
79
+ throw new VaultNotFound();
80
+ }
81
+ async function open(address, store = params.store, specific) {
82
+ const generation = ctx.generation(address);
83
+ if (address.inputs?.pin !== undefined) {
84
+ const id = cacheID(address);
85
+ const rejected = (cause)=>{
86
+ return new VaultPinRejected(address.keyID, {
87
+ cause,
88
+ attemptsRemaining: throttle.attemptsRemaining(id),
89
+ retryAfter: throttle.retryAfter(id)
90
+ });
91
+ };
92
+ if (throttle.retryAfter(id) > 0) throw rejected();
93
+ try {
94
+ const opened = await doOpen(address, store, generation, specific);
95
+ throttle.recordSuccess(id);
96
+ return opened;
97
+ } catch (error) {
98
+ if (error instanceof VaultSecretRejected) {
99
+ throttle.recordFailure(id);
100
+ throw rejected(error);
101
+ }
102
+ throw error;
103
+ }
104
+ }
105
+ if (specific !== undefined || Object.values(address.inputs ?? {}).some((input)=>input !== undefined) || store !== params.store) return doOpen(address, store, generation, specific);
106
+ const flightID = JSON.stringify([
107
+ cacheID(address),
108
+ address.atVersion
109
+ ]);
110
+ while(inFlight.has(flightID))await inFlight.get(flightID);
111
+ const run = doOpen(address, store, generation);
112
+ const gate = run.then(()=>{}, ()=>{});
113
+ inFlight.set(flightID, gate);
114
+ try {
115
+ return await run;
116
+ } finally{
117
+ if (inFlight.get(flightID) === gate) inFlight.delete(flightID);
118
+ }
119
+ }
120
+ async function decrypt(row, address, store = params.store, specific = false) {
121
+ if (row === undefined) throw new VaultNotFound();
122
+ const opened = await open({
123
+ ...address,
124
+ atVersion: row.keyVersion
125
+ }, store, specific ? row.provenance : undefined);
126
+ try {
127
+ if (params.mode === 'replicated' && row.provenance !== opened.branchID) throw new VaultSupersededBranch();
128
+ return getSuite(opened.suite).decryptEntry({
129
+ ...row,
130
+ contentKey: opened.contentKey
131
+ });
132
+ } finally{
133
+ opened.contentKey.fill(0);
134
+ }
135
+ }
136
+ return {
137
+ open,
138
+ decrypt,
139
+ revoked
140
+ };
141
+ }
@@ -0,0 +1,27 @@
1
+ import type { SignedRecord } from '@kokuin/records';
2
+ import type { CoordinatorContext } from './coordinator-context.js';
3
+ import type { CoordinatorCall } from './coordinator-types.js';
4
+ import type { EntryRow, VaultStoreAPI, WrappingRow } from './types.js';
5
+ type Branch = {
6
+ branchID: string;
7
+ branchRecordID: string;
8
+ };
9
+ export type EntryInfo = {
10
+ contentHLC: string;
11
+ consumer: unknown;
12
+ };
13
+ export type RecordFunctions = {
14
+ /** Signs at `hlc` when given, or at a newly allocated one. */
15
+ wrapping(row: WrappingRow, branch: Branch, consumer?: unknown, hlc?: string): Promise<SignedRecord>;
16
+ entry(row: EntryRow, branch: Branch, contentHLC: string, consumer?: unknown, deleted?: boolean): Promise<SignedRecord>;
17
+ entryInfo(row: EntryRow, store: VaultStoreAPI): Promise<EntryInfo>;
18
+ /** Batched {@link entryInfo}: one record read, results and the first failure in row order. */
19
+ entryInfos(rows: ReadonlyArray<EntryRow>, store: VaultStoreAPI): Promise<Array<EntryInfo>>;
20
+ reendorse(request: {
21
+ ownerDID: string;
22
+ keyID: string;
23
+ records: ReadonlyArray<SignedRecord>;
24
+ } & CoordinatorCall): Promise<void>;
25
+ };
26
+ export declare function createRecordFunctions(ctx: CoordinatorContext): RecordFunctions;
27
+ export {};
@@ -0,0 +1,132 @@
1
+ import { getRecordDecisions, recordKeyText, recordPayloadDigest, signRecordOp } from '@kokuin/records';
2
+ import { encodeVaultRecordPayload, VaultTampered } from '@kokuin/vault';
3
+ import { VaultNotFound } from './coordinator-context.js';
4
+ import { settleAll, settledValue } from './settle.js';
5
+ export function createRecordFunctions(ctx) {
6
+ async function wrapping(row, branch, consumer = null, hlc) {
7
+ return ctx.signed('vault.wrapping', {
8
+ ownerDID: row.ownerDID,
9
+ id: row.wrappingID
10
+ }, row, {
11
+ keyID: row.keyID,
12
+ keyVersion: row.keyVersion,
13
+ branchID: branch.branchID,
14
+ branchRecordID: branch.branchRecordID,
15
+ recipient: row.recipientDID,
16
+ consumer
17
+ }, hlc);
18
+ }
19
+ async function entry(row, branch, contentHLC, consumer = null, deleted = false) {
20
+ return ctx.signed('vault.entry', {
21
+ ownerDID: row.ownerDID,
22
+ id: row.entryID
23
+ }, deleted ? null : row, {
24
+ keyID: row.keyID,
25
+ keyVersion: row.keyVersion,
26
+ branchID: branch.branchID,
27
+ branchRecordID: branch.branchRecordID,
28
+ contentHLC,
29
+ consumer,
30
+ ...deleted ? {} : {
31
+ kind: row.kind,
32
+ label: row.label,
33
+ attributes: row.attributes
34
+ }
35
+ }, row.hlc);
36
+ }
37
+ function checkEntry(row, op) {
38
+ const meta = op.claim.meta;
39
+ const attributes = meta.attributes;
40
+ if (op.claim.deleted || op.claim.hlc !== row.hlc || meta.keyID !== row.keyID || meta.keyVersion !== row.keyVersion || meta.branchID !== row.provenance || meta.kind !== row.kind || meta.label !== row.label || attributes === null || typeof attributes !== 'object' || Array.isArray(attributes) || Object.keys(attributes).length !== Object.keys(row.attributes).length || Object.entries(attributes).some(([name, value])=>row.attributes[name] !== value) || recordPayloadDigest(encodeVaultRecordPayload('vault.entry', row)) !== op.claim.payloadDigest) throw new VaultTampered('Entry projection disagrees with authenticated content');
41
+ const contentHLC = op.claim.meta.contentHLC;
42
+ if (typeof contentHLC !== 'string') throw new VaultNotFound();
43
+ return {
44
+ contentHLC,
45
+ consumer: op.claim.meta.consumer
46
+ };
47
+ }
48
+ async function entryInfo(row, store) {
49
+ if (ctx.params.mode === 'local') return {
50
+ contentHLC: row.hlc,
51
+ consumer: null
52
+ };
53
+ const { op } = await ctx.stored({
54
+ type: 'vault.entry',
55
+ owner: row.ownerDID,
56
+ id: row.entryID
57
+ }, store);
58
+ return checkEntry(row, op);
59
+ }
60
+ async function entryInfos(rows, store) {
61
+ if (ctx.params.mode === 'local') return rows.map((row)=>({
62
+ contentHLC: row.hlc,
63
+ consumer: null
64
+ }));
65
+ const evidence = await ctx.storedMany(rows.map((row)=>({
66
+ type: 'vault.entry',
67
+ owner: row.ownerDID,
68
+ id: row.entryID
69
+ })), store);
70
+ return rows.map((row, index)=>{
71
+ const item = evidence[index];
72
+ if (item === undefined) throw new VaultNotFound();
73
+ return checkEntry(row, item.op);
74
+ });
75
+ }
76
+ async function reendorse(request) {
77
+ await ctx.authorize('reendorse', 'administer', request);
78
+ if (ctx.params.mode !== 'replicated') throw new TypeError('Re-endorsement requires replicated mode');
79
+ const params = ctx.params;
80
+ await ctx.locked([
81
+ request
82
+ ], async (tx)=>{
83
+ const decisionsRead = getRecordDecisions(ctx.recordsFor(tx).adapter, request.records);
84
+ // A task that fails before reading the decisions must not leave this rejection unhandled.
85
+ decisionsRead.catch(()=>{});
86
+ const keyRead = tx.getKey(request);
87
+ const branchesRead = keyRead.then((key)=>ctx.branches(request, key?.version ?? 0, tx));
88
+ // All reads run together. Results are checked in the old order: key, then each record,
89
+ // then, after signing, the branches.
90
+ const [keyResult, opsResult, branchesResult] = await Promise.allSettled([
91
+ keyRead,
92
+ settleAll(request.records.map(async (record)=>{
93
+ const op = await ctx.verified(record);
94
+ if (op.claim.owner !== request.ownerDID || op.claim.meta.keyID !== request.keyID) throw new TypeError('Re-endorsement is confined to the explicitly named key');
95
+ const decision = (await decisionsRead).get(recordKeyText(record));
96
+ if (!decision?.copies.some((copy)=>copy.opHash === record.opHash)) throw new VaultNotFound();
97
+ return op;
98
+ })),
99
+ branchesRead
100
+ ]);
101
+ const key = settledValue(keyResult);
102
+ const ops = settledValue(opsResult);
103
+ const output = [];
104
+ // Signing stays sequential: signer calls follow the caller's record order, and a failure
105
+ // stops the rest.
106
+ for (const [index, record] of request.records.entries()){
107
+ const op = ops[index];
108
+ if (op === undefined) throw new VaultNotFound();
109
+ // Re-sign the entire original claim. Consumer evidence and content intent stay authenticated.
110
+ output.push({
111
+ ...record,
112
+ ...await signRecordOp(params.signer, op.claim, params.records.opKind)
113
+ });
114
+ }
115
+ const branches = settledValue(branchesResult);
116
+ await ctx.write(tx, output, {
117
+ key: {
118
+ ...request,
119
+ expectedVersion: key?.version ?? null,
120
+ expectedBranches: branches.map((b)=>b.branchID)
121
+ }
122
+ }, request);
123
+ });
124
+ }
125
+ return {
126
+ wrapping,
127
+ entry,
128
+ entryInfo,
129
+ entryInfos,
130
+ reendorse
131
+ };
132
+ }
@@ -0,0 +1,9 @@
1
+ import type { CoordinatorContext } from './coordinator-context.js';
2
+ import type { OpenFunctions } from './coordinator-open.js';
3
+ import type { RecordFunctions } from './coordinator-records.js';
4
+ import type { ForkRepairResult, RotateKeyParams } from './coordinator-types.js';
5
+ export type RotateFunctions = {
6
+ rotate(request: RotateKeyParams): Promise<number>;
7
+ repairFork(request: RotateKeyParams): Promise<ForkRepairResult>;
8
+ };
9
+ export declare function createRotateFunctions(ctx: CoordinatorContext, open: OpenFunctions, records: RecordFunctions): RotateFunctions;