@kubun/plugin-p2p 0.13.1 → 0.15.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/lib/context/peer.js +250 -3
- package/lib/context/sync.js +134 -25
- package/lib/context/types.d.ts +7 -0
- package/lib/groups/broadcast-message.d.ts +29 -0
- package/lib/groups/broadcast.d.ts +11 -1
- package/lib/groups/broadcast.js +44 -2
- package/lib/groups/credential-apply.d.ts +64 -2
- package/lib/groups/credential-apply.js +215 -30
- package/lib/groups/credential-grant.d.ts +22 -0
- package/lib/groups/credential-grant.js +76 -2
- package/lib/groups/credential-manifest-token.d.ts +31 -0
- package/lib/groups/credential-manifest-token.js +49 -0
- package/lib/groups/credential-readiness.d.ts +69 -0
- package/lib/groups/credential-readiness.js +172 -0
- package/lib/groups/credential-wrapping-deps.d.ts +23 -0
- package/lib/groups/credential-wrapping-deps.js +25 -0
- package/lib/groups/grantor-authority.d.ts +65 -0
- package/lib/groups/grantor-authority.js +107 -0
- package/lib/groups/group-handlers.js +7 -0
- package/lib/groups/group-peer-manager.d.ts +25 -0
- package/lib/groups/group-peer-manager.js +71 -0
- package/lib/groups/group-protocols.d.ts +47 -0
- package/lib/groups/group-protocols.js +28 -0
- package/lib/hub/wiring.d.ts +24 -0
- package/lib/hub/wiring.js +17 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +157 -6
- package/lib/peer/blob-fetch.d.ts +2 -18
- package/lib/protocol.d.ts +30 -0
- package/lib/protocol.js +36 -0
- package/lib/schema.d.ts +16 -1
- package/lib/schema.js +113 -4
- package/lib/sync/group-sync-workflow.d.ts +77 -0
- package/lib/sync/group-sync-workflow.js +96 -0
- package/lib/sync/handlers.js +21 -2
- package/lib/sync/held-delegations.d.ts +14 -0
- package/lib/sync/held-delegations.js +34 -0
- package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
- package/lib/sync/hub-tunnel-service-listener.js +289 -0
- package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
- package/lib/sync/hub-tunnel-service-provider.js +100 -0
- package/lib/sync/service-tunnel-listeners.d.ts +35 -0
- package/lib/sync/service-tunnel-listeners.js +165 -0
- package/lib/sync/sync-manager.d.ts +7 -0
- package/lib/sync/sync-manager.js +4 -1
- package/lib/sync/tunnel-topics.d.ts +19 -1
- package/lib/sync/tunnel-topics.js +7 -3
- package/lib/types.d.ts +182 -7
- package/lib/util/handler-error.d.ts +8 -5
- package/lib/util/handler-error.js +10 -23
- package/package.json +51 -46
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
|
|
2
|
+
/**
|
|
3
|
+
* Check that a value is a finite, safe, positive integer (>= 1).
|
|
4
|
+
* Rejects NaN, Infinity, floats, negatives, and zero.
|
|
5
|
+
*/ const isPosInt = (n)=>typeof n === 'number' && Number.isSafeInteger(n) && n >= 1;
|
|
6
|
+
export async function signCredentialManifest(identity, claim) {
|
|
7
|
+
const fullClaim = {
|
|
8
|
+
kind: 'credential/key-manifest',
|
|
9
|
+
grantorDID: identity.id,
|
|
10
|
+
...claim
|
|
11
|
+
};
|
|
12
|
+
const signed = await identity.signToken(fullClaim, {
|
|
13
|
+
embedLongForm: true
|
|
14
|
+
});
|
|
15
|
+
return stringifyToken(signed);
|
|
16
|
+
}
|
|
17
|
+
function isValidKeysArray(value) {
|
|
18
|
+
return Array.isArray(value) && value.every((entry)=>entry != null && typeof entry === 'object' && typeof entry.keyID === 'string' && isPosInt(entry.version));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Verify a credential-manifest envelope and extract its claim. Returns `null`
|
|
22
|
+
* (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
|
|
23
|
+
* structurally not a credential-manifest claim -- `grantorDID` is trusted
|
|
24
|
+
* only from the verified `iss`, never from a same-named field in the payload.
|
|
25
|
+
*/ export async function verifyCredentialManifest(envelope) {
|
|
26
|
+
let verified;
|
|
27
|
+
try {
|
|
28
|
+
verified = await verifyToken(envelope);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!isVerifiedToken(verified)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const { iss, kind, ownerDID, recipientDID, epoch, sequence, keys, issuedAt } = verified.payload;
|
|
36
|
+
if (kind !== 'credential/key-manifest' || typeof ownerDID !== 'string' || typeof recipientDID !== 'string' || !isPosInt(epoch) || !isPosInt(sequence) || typeof issuedAt !== 'string' || !isValidKeysArray(keys)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
kind: 'credential/key-manifest',
|
|
41
|
+
ownerDID,
|
|
42
|
+
recipientDID,
|
|
43
|
+
grantorDID: normalizeDID(iss),
|
|
44
|
+
epoch,
|
|
45
|
+
sequence,
|
|
46
|
+
keys,
|
|
47
|
+
issuedAt
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CredentialManager } from '@kubun/credential';
|
|
2
|
+
import type { CredentialStoreAPI } from '@kubun/store-credential';
|
|
3
|
+
import { type DelegatedWrappingCheckDeps } from './grantor-authority.js';
|
|
4
|
+
/**
|
|
5
|
+
* The readiness verdict for one owner's provisioning: `complete` iff every
|
|
6
|
+
* durable expectation is satisfied; `missing` names keys still needing
|
|
7
|
+
* decrypt-materialization; `reason` explains a `false` verdict (absent on
|
|
8
|
+
* `complete`).
|
|
9
|
+
*/
|
|
10
|
+
export type CredentialProvisioningReadiness = {
|
|
11
|
+
complete: boolean;
|
|
12
|
+
missing: Array<string>;
|
|
13
|
+
reason?: 'not-initiated' | 'pending' | 'incomplete';
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Whether one expected key is materialized on this device AT the manifest's
|
|
17
|
+
* named version: key op present, a wrapping addressed to this device exists,
|
|
18
|
+
* and — when the key has entries — at least one DECRYPT-VERIFIES (a wrapping
|
|
19
|
+
* over a garbage key still stores and reports `available`, so signature alone
|
|
20
|
+
* isn't proof). Zero entries: an openable wrapping is the whole proof.
|
|
21
|
+
*
|
|
22
|
+
* `expectedVersion` is the manifest's required version, not whatever is
|
|
23
|
+
* materialized locally — a stale pre-rotation version must not satisfy it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isKeyMaterialized(params: {
|
|
26
|
+
store: CredentialStoreAPI;
|
|
27
|
+
credentials: CredentialManager;
|
|
28
|
+
keyID: string;
|
|
29
|
+
self: string;
|
|
30
|
+
owner: string;
|
|
31
|
+
expectedVersion: number;
|
|
32
|
+
}): Promise<boolean>;
|
|
33
|
+
/**
|
|
34
|
+
* The readiness authority: `complete:true` iff `selfDID` durably holds,
|
|
35
|
+
* decrypt-verified, every credential the CURRENT provisioning attempt(s) for
|
|
36
|
+
* `ownerDID` targeted. Revalidates each held manifest's delegation proof
|
|
37
|
+
* present-time before trusting it — never a false or vacuous complete.
|
|
38
|
+
*
|
|
39
|
+
* Rule order (first match wins):
|
|
40
|
+
* 1. Any outstanding attempt ⇒ `pending`.
|
|
41
|
+
* 2. Zero expectation rows ⇒ `not-initiated`.
|
|
42
|
+
* 3. Otherwise, each expectation `(grantor, floor)` needs a held manifest
|
|
43
|
+
* with `epoch >= floor`, a present-time delegation proof, and every
|
|
44
|
+
* target key decrypt-materialized at its named version.
|
|
45
|
+
*
|
|
46
|
+
* Self-issued exception: grantor === owner skips the admin-chain check
|
|
47
|
+
* (mirrors `grantDeviceCredentials`'s self-issued path, which mints no
|
|
48
|
+
* envelope either) — `checkAdministerChain`'s check has no meaning when
|
|
49
|
+
* issuer == subject.
|
|
50
|
+
*/
|
|
51
|
+
export declare function computeCredentialProvisioningStatus(params: {
|
|
52
|
+
store: CredentialStoreAPI;
|
|
53
|
+
credentials: CredentialManager;
|
|
54
|
+
selfDID: string;
|
|
55
|
+
ownerDID: string;
|
|
56
|
+
deps: DelegatedWrappingCheckDeps;
|
|
57
|
+
}): Promise<CredentialProvisioningReadiness>;
|
|
58
|
+
/**
|
|
59
|
+
* The candidate `ownerDIDs` whose provisioning is not yet satisfied. A thin
|
|
60
|
+
* filter over {@link computeCredentialProvisioningStatus}, used by the
|
|
61
|
+
* credential-only PULL reconcile trigger.
|
|
62
|
+
*/
|
|
63
|
+
export declare function unmetCredentialOwners(params: {
|
|
64
|
+
store: CredentialStoreAPI;
|
|
65
|
+
credentials: CredentialManager;
|
|
66
|
+
selfDID: string;
|
|
67
|
+
ownerDIDs: Array<string>;
|
|
68
|
+
deps: DelegatedWrappingCheckDeps;
|
|
69
|
+
}): Promise<Array<string>>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { checkAdministerChain } from './grantor-authority.js';
|
|
3
|
+
/**
|
|
4
|
+
* Whether one expected key is materialized on this device AT the manifest's
|
|
5
|
+
* named version: key op present, a wrapping addressed to this device exists,
|
|
6
|
+
* and — when the key has entries — at least one DECRYPT-VERIFIES (a wrapping
|
|
7
|
+
* over a garbage key still stores and reports `available`, so signature alone
|
|
8
|
+
* isn't proof). Zero entries: an openable wrapping is the whole proof.
|
|
9
|
+
*
|
|
10
|
+
* `expectedVersion` is the manifest's required version, not whatever is
|
|
11
|
+
* materialized locally — a stale pre-rotation version must not satisfy it.
|
|
12
|
+
*/ export async function isKeyMaterialized(params) {
|
|
13
|
+
const { store, credentials, keyID, self, owner, expectedVersion } = params;
|
|
14
|
+
const key = await store.getKey(keyID);
|
|
15
|
+
if (key == null) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
// Owner mismatch: not materialized (stays in `missing`), never a throw --
|
|
19
|
+
// this is a readiness probe, not an authorization check.
|
|
20
|
+
if (normalizeDID(key.owner_did) !== owner) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const version = expectedVersion;
|
|
24
|
+
const wrappings = await store.listWrappings(keyID, version);
|
|
25
|
+
const addressedToSelf = wrappings.some((wrapping)=>wrapping.recipient_did != null && normalizeDID(wrapping.recipient_did) === self);
|
|
26
|
+
if (!addressedToSelf) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const entries = (await store.listEntries(keyID)).filter((entry)=>entry.key_version === version);
|
|
30
|
+
if (entries.length === 0) {
|
|
31
|
+
// No ciphertext to authenticate, so an openable wrapping is the proof.
|
|
32
|
+
// Probed at the manifest's expected version, not the key's current one.
|
|
33
|
+
const status = await credentials.status(keyID, version);
|
|
34
|
+
return status.state === 'available' || status.state === 'unlocked';
|
|
35
|
+
}
|
|
36
|
+
// The load-bearing gate: a real content key authenticates its entries, a
|
|
37
|
+
// garbage one MAC-fails. One entry that decrypt-verifies is enough.
|
|
38
|
+
for (const entry of entries){
|
|
39
|
+
try {
|
|
40
|
+
await credentials.readEntry(entry.entry_id);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
// This entry did not decrypt-verify — try the next; if none do, the key
|
|
44
|
+
// is pending.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The readiness authority: `complete:true` iff `selfDID` durably holds,
|
|
51
|
+
* decrypt-verified, every credential the CURRENT provisioning attempt(s) for
|
|
52
|
+
* `ownerDID` targeted. Revalidates each held manifest's delegation proof
|
|
53
|
+
* present-time before trusting it — never a false or vacuous complete.
|
|
54
|
+
*
|
|
55
|
+
* Rule order (first match wins):
|
|
56
|
+
* 1. Any outstanding attempt ⇒ `pending`.
|
|
57
|
+
* 2. Zero expectation rows ⇒ `not-initiated`.
|
|
58
|
+
* 3. Otherwise, each expectation `(grantor, floor)` needs a held manifest
|
|
59
|
+
* with `epoch >= floor`, a present-time delegation proof, and every
|
|
60
|
+
* target key decrypt-materialized at its named version.
|
|
61
|
+
*
|
|
62
|
+
* Self-issued exception: grantor === owner skips the admin-chain check
|
|
63
|
+
* (mirrors `grantDeviceCredentials`'s self-issued path, which mints no
|
|
64
|
+
* envelope either) — `checkAdministerChain`'s check has no meaning when
|
|
65
|
+
* issuer == subject.
|
|
66
|
+
*/ export async function computeCredentialProvisioningStatus(params) {
|
|
67
|
+
const { store, credentials, deps } = params;
|
|
68
|
+
const self = normalizeDID(params.selfDID);
|
|
69
|
+
const owner = normalizeDID(params.ownerDID);
|
|
70
|
+
const expectations = await store.listProvisioningExpectations({
|
|
71
|
+
ownerDID: owner
|
|
72
|
+
});
|
|
73
|
+
// Rule 1: an attempt in flight forces false even against a materialized OLD
|
|
74
|
+
// manifest -- its floor is not yet bound.
|
|
75
|
+
if (expectations.some((expectation)=>expectation.outstanding > 0)) {
|
|
76
|
+
return {
|
|
77
|
+
complete: false,
|
|
78
|
+
missing: [],
|
|
79
|
+
reason: 'pending'
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Rule 2: nothing has ever been asked for — never a vacuous complete.
|
|
83
|
+
if (expectations.length === 0) {
|
|
84
|
+
return {
|
|
85
|
+
complete: false,
|
|
86
|
+
missing: [],
|
|
87
|
+
reason: 'not-initiated'
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const manifests = await store.listManifests({
|
|
91
|
+
ownerDID: owner,
|
|
92
|
+
recipientDID: self
|
|
93
|
+
});
|
|
94
|
+
const byGrantor = new Map(manifests.map((manifest)=>[
|
|
95
|
+
normalizeDID(manifest.record.grantorDID),
|
|
96
|
+
manifest
|
|
97
|
+
]));
|
|
98
|
+
const missing = [];
|
|
99
|
+
let allSatisfied = true;
|
|
100
|
+
for (const expectation of expectations){
|
|
101
|
+
const grantor = normalizeDID(expectation.grantorDID);
|
|
102
|
+
const manifest = byGrantor.get(grantor);
|
|
103
|
+
// No held manifest at/above the floor: this expectation names no known
|
|
104
|
+
// target keys to report as `missing` — it is simply unsatisfied.
|
|
105
|
+
if (manifest == null || manifest.record.epoch < expectation.epochFloor) {
|
|
106
|
+
allSatisfied = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const authorized = grantor === owner ? true : await checkAdministerChain({
|
|
110
|
+
grantorDID: grantor,
|
|
111
|
+
ownerDID: owner,
|
|
112
|
+
delegationTokens: manifest.delegationTokens,
|
|
113
|
+
deps
|
|
114
|
+
});
|
|
115
|
+
if (!authorized) {
|
|
116
|
+
// Expired/revoked/unwired proof: fail-closed means unmet, not thrown.
|
|
117
|
+
allSatisfied = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const key of manifest.record.keys){
|
|
121
|
+
let materialized = false;
|
|
122
|
+
try {
|
|
123
|
+
materialized = await isKeyMaterialized({
|
|
124
|
+
store,
|
|
125
|
+
credentials,
|
|
126
|
+
keyID: key.keyID,
|
|
127
|
+
self,
|
|
128
|
+
owner,
|
|
129
|
+
expectedVersion: key.version
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
// Any error (locked factor, unopenable wrapping, store fault) means
|
|
133
|
+
// pending, not a thrown query.
|
|
134
|
+
materialized = false;
|
|
135
|
+
}
|
|
136
|
+
if (!materialized) {
|
|
137
|
+
missing.push(key.keyID);
|
|
138
|
+
allSatisfied = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (allSatisfied && missing.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
complete: true,
|
|
145
|
+
missing: []
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
complete: false,
|
|
150
|
+
missing,
|
|
151
|
+
reason: 'incomplete'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The candidate `ownerDIDs` whose provisioning is not yet satisfied. A thin
|
|
156
|
+
* filter over {@link computeCredentialProvisioningStatus}, used by the
|
|
157
|
+
* credential-only PULL reconcile trigger.
|
|
158
|
+
*/ export async function unmetCredentialOwners(params) {
|
|
159
|
+
const { store, credentials, selfDID, deps } = params;
|
|
160
|
+
// Independent per-owner status probes — compute concurrently, preserve order.
|
|
161
|
+
const statuses = await Promise.all(params.ownerDIDs.map(async (ownerDID)=>({
|
|
162
|
+
ownerDID,
|
|
163
|
+
complete: (await computeCredentialProvisioningStatus({
|
|
164
|
+
store,
|
|
165
|
+
credentials,
|
|
166
|
+
selfDID,
|
|
167
|
+
ownerDID,
|
|
168
|
+
deps
|
|
169
|
+
})).complete
|
|
170
|
+
})));
|
|
171
|
+
return statuses.filter((status)=>!status.complete).map((status)=>normalizeDID(status.ownerDID));
|
|
172
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DIDMethodResolver } from '@kokuin/token';
|
|
2
|
+
import type { StoreProvider } from '@kubun/db';
|
|
3
|
+
import type { DelegatedWrappingCheckDeps } from './grantor-authority.js';
|
|
4
|
+
/**
|
|
5
|
+
* How the plugin obtains the engine's `did:kokuin:` controller resolver for a
|
|
6
|
+
* store provider — the SAME seam the engine builds for document controller
|
|
7
|
+
* resolution. Typed here rather than imported from `@kubun/engine` so
|
|
8
|
+
* plugin-p2p keeps no dependency on the engine.
|
|
9
|
+
*/
|
|
10
|
+
export type ControllerResolverFor = (stores: StoreProvider, opts?: {
|
|
11
|
+
localOnly?: boolean;
|
|
12
|
+
}) => DIDMethodResolver;
|
|
13
|
+
/**
|
|
14
|
+
* Source the delegated-wrapping admission deps: a controller resolver (for a
|
|
15
|
+
* `did:kokuin:` grantor's administer chain) and a revocation checker over
|
|
16
|
+
* this device's delegation store, both built from the same store provider.
|
|
17
|
+
*
|
|
18
|
+
* Fail-closed: no `controllerResolverFor` seam returns an empty deps object,
|
|
19
|
+
* and {@link authorizeDelegatedWrapping} refuses. Neither dep is emitted
|
|
20
|
+
* without the other -- a revocation checker is only meaningful paired with
|
|
21
|
+
* the resolver.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createCredentialWrappingDeps(stores: StoreProvider, controllerResolverFor?: ControllerResolverFor): Promise<DelegatedWrappingCheckDeps>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createDelegationRevocationChecker, getDelegationStore } from '@kubun/store-delegation';
|
|
2
|
+
/**
|
|
3
|
+
* Source the delegated-wrapping admission deps: a controller resolver (for a
|
|
4
|
+
* `did:kokuin:` grantor's administer chain) and a revocation checker over
|
|
5
|
+
* this device's delegation store, both built from the same store provider.
|
|
6
|
+
*
|
|
7
|
+
* Fail-closed: no `controllerResolverFor` seam returns an empty deps object,
|
|
8
|
+
* and {@link authorizeDelegatedWrapping} refuses. Neither dep is emitted
|
|
9
|
+
* without the other -- a revocation checker is only meaningful paired with
|
|
10
|
+
* the resolver.
|
|
11
|
+
*/ export async function createCredentialWrappingDeps(stores, controllerResolverFor) {
|
|
12
|
+
if (controllerResolverFor == null) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
const controllerResolver = controllerResolverFor(stores);
|
|
16
|
+
const delegationStore = await getDelegationStore(stores);
|
|
17
|
+
return {
|
|
18
|
+
controllerResolver,
|
|
19
|
+
// Resolver rides `methods` so a `did:kokuin:` controller-DID issuer can
|
|
20
|
+
// be resolved and honoured (mirrors the engine's own checker).
|
|
21
|
+
revocationChecker: createDelegationRevocationChecker(delegationStore, [
|
|
22
|
+
controllerResolver
|
|
23
|
+
])
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import { type DIDMethodResolver, type SigningIdentity } from '@kokuin/token';
|
|
3
|
+
/**
|
|
4
|
+
* What a grantor signs when handing a credential-wrapping op to a recipient:
|
|
5
|
+
* binds the op to a monotonic refresh sequence. Domain separation from other
|
|
6
|
+
* signed payloads comes from the `kind` marker plus the structural check in
|
|
7
|
+
* `verifyGrantorAuthority` -- `signToken` itself has no domain override.
|
|
8
|
+
*/
|
|
9
|
+
export type GrantorAuthorityClaim = {
|
|
10
|
+
kind: 'credential/grantor-authority';
|
|
11
|
+
wrappingOpHash: string;
|
|
12
|
+
refreshSeq: number;
|
|
13
|
+
grantorDID: string;
|
|
14
|
+
ownerDID: string;
|
|
15
|
+
recipientDID: string;
|
|
16
|
+
delegationTokens: Array<string>;
|
|
17
|
+
};
|
|
18
|
+
/** The envelope carried on the wire and persisted is the signed token string itself. */
|
|
19
|
+
export type GrantorAuthorityEnvelope = string;
|
|
20
|
+
export declare function signGrantorAuthority(identity: SigningIdentity, claim: Omit<GrantorAuthorityClaim, 'kind' | 'grantorDID'>): Promise<GrantorAuthorityEnvelope>;
|
|
21
|
+
/**
|
|
22
|
+
* Verify a grantor-authority envelope and extract its claim. Returns `null`
|
|
23
|
+
* (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
|
|
24
|
+
* structurally not a grantor-authority claim -- `grantorDID` is trusted only
|
|
25
|
+
* from the verified `iss`, never from a same-named field in the payload.
|
|
26
|
+
*/
|
|
27
|
+
export declare function verifyGrantorAuthority(envelope: GrantorAuthorityEnvelope): Promise<GrantorAuthorityClaim | null>;
|
|
28
|
+
export type DelegatedWrappingCheckDeps = {
|
|
29
|
+
/** Injected so a `did:kokuin:` grantor's capability chain resolves without a dependency cycle. */
|
|
30
|
+
controllerResolver?: DIDMethodResolver;
|
|
31
|
+
/** The delegation-revocation hook, consulted for every capability in the chain. */
|
|
32
|
+
revocationChecker?: VerifyTokenHook;
|
|
33
|
+
/** Evaluate capability expiry at this time (epoch seconds) rather than now(). */
|
|
34
|
+
atTime?: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Present-time `credential/administer` chain check shared by
|
|
38
|
+
* {@link authorizeDelegatedWrapping} and the credential-manifest admission
|
|
39
|
+
* gate. Mirrors `@kubun/engine`'s `createControllerAuthority` check; never
|
|
40
|
+
* throws.
|
|
41
|
+
*
|
|
42
|
+
* Fail-CLOSED: no `controllerResolver`/`revocationChecker` wired returns
|
|
43
|
+
* `false`. Tries both the owner's user resource and `*`, since a grant may
|
|
44
|
+
* scope its administer right to either.
|
|
45
|
+
*/
|
|
46
|
+
export declare function checkAdministerChain(params: {
|
|
47
|
+
grantorDID: string;
|
|
48
|
+
ownerDID: string;
|
|
49
|
+
delegationTokens: Array<string>;
|
|
50
|
+
deps: DelegatedWrappingCheckDeps;
|
|
51
|
+
}): Promise<boolean>;
|
|
52
|
+
/**
|
|
53
|
+
* Present-time admission gate for a delegated credential-wrapping op: `true`
|
|
54
|
+
* iff (a) the envelope structurally binds this exact op + issuer + owner
|
|
55
|
+
* (defeats envelope transplant and issuer-swap), and (b) the VERIFIED issuer
|
|
56
|
+
* `G` presently holds a `credential/administer` capability over the owner
|
|
57
|
+
* via the shared {@link checkAdministerChain}. Never throws.
|
|
58
|
+
*/
|
|
59
|
+
export declare function authorizeDelegatedWrapping(params: {
|
|
60
|
+
envelope: GrantorAuthorityEnvelope;
|
|
61
|
+
wrappingOpIssuer: string;
|
|
62
|
+
wrappingOpHash: string;
|
|
63
|
+
ownerDID: string;
|
|
64
|
+
deps: DelegatedWrappingCheckDeps;
|
|
65
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { checkCapability } from '@kokuin/capability';
|
|
2
|
+
import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
|
|
3
|
+
import { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from '@kubun/credential';
|
|
4
|
+
export async function signGrantorAuthority(identity, claim) {
|
|
5
|
+
const fullClaim = {
|
|
6
|
+
kind: 'credential/grantor-authority',
|
|
7
|
+
grantorDID: identity.id,
|
|
8
|
+
...claim
|
|
9
|
+
};
|
|
10
|
+
const signed = await identity.signToken(fullClaim, {
|
|
11
|
+
embedLongForm: true
|
|
12
|
+
});
|
|
13
|
+
return stringifyToken(signed);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Verify a grantor-authority envelope and extract its claim. Returns `null`
|
|
17
|
+
* (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
|
|
18
|
+
* structurally not a grantor-authority claim -- `grantorDID` is trusted only
|
|
19
|
+
* from the verified `iss`, never from a same-named field in the payload.
|
|
20
|
+
*/ export async function verifyGrantorAuthority(envelope) {
|
|
21
|
+
let verified;
|
|
22
|
+
try {
|
|
23
|
+
verified = await verifyToken(envelope);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (!isVerifiedToken(verified)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const { iss, kind, wrappingOpHash, refreshSeq, ownerDID, recipientDID, delegationTokens } = verified.payload;
|
|
31
|
+
if (kind !== 'credential/grantor-authority' || typeof wrappingOpHash !== 'string' || typeof refreshSeq !== 'number' || typeof ownerDID !== 'string' || typeof recipientDID !== 'string' || !Array.isArray(delegationTokens) || !delegationTokens.every((token)=>typeof token === 'string')) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
kind: 'credential/grantor-authority',
|
|
36
|
+
wrappingOpHash,
|
|
37
|
+
refreshSeq,
|
|
38
|
+
grantorDID: normalizeDID(iss),
|
|
39
|
+
ownerDID,
|
|
40
|
+
recipientDID,
|
|
41
|
+
delegationTokens
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Present-time `credential/administer` chain check shared by
|
|
46
|
+
* {@link authorizeDelegatedWrapping} and the credential-manifest admission
|
|
47
|
+
* gate. Mirrors `@kubun/engine`'s `createControllerAuthority` check; never
|
|
48
|
+
* throws.
|
|
49
|
+
*
|
|
50
|
+
* Fail-CLOSED: no `controllerResolver`/`revocationChecker` wired returns
|
|
51
|
+
* `false`. Tries both the owner's user resource and `*`, since a grant may
|
|
52
|
+
* scope its administer right to either.
|
|
53
|
+
*/ export async function checkAdministerChain(params) {
|
|
54
|
+
const { grantorDID, ownerDID, delegationTokens, deps } = params;
|
|
55
|
+
if (deps.controllerResolver == null || deps.revocationChecker == null) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const methods = [
|
|
59
|
+
deps.controllerResolver
|
|
60
|
+
];
|
|
61
|
+
for (const res of [
|
|
62
|
+
credentialUserResource(ownerDID),
|
|
63
|
+
'*'
|
|
64
|
+
]){
|
|
65
|
+
try {
|
|
66
|
+
await checkCapability({
|
|
67
|
+
act: CREDENTIAL_ADMINISTER_ACTION,
|
|
68
|
+
res
|
|
69
|
+
}, {
|
|
70
|
+
iss: grantorDID,
|
|
71
|
+
sub: ownerDID,
|
|
72
|
+
cap: delegationTokens
|
|
73
|
+
}, {
|
|
74
|
+
...deps.atTime != null ? {
|
|
75
|
+
atTime: deps.atTime
|
|
76
|
+
} : {},
|
|
77
|
+
verifyToken: deps.revocationChecker,
|
|
78
|
+
methods
|
|
79
|
+
});
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
// This resource does not match the chain -- try the next one.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Present-time admission gate for a delegated credential-wrapping op: `true`
|
|
89
|
+
* iff (a) the envelope structurally binds this exact op + issuer + owner
|
|
90
|
+
* (defeats envelope transplant and issuer-swap), and (b) the VERIFIED issuer
|
|
91
|
+
* `G` presently holds a `credential/administer` capability over the owner
|
|
92
|
+
* via the shared {@link checkAdministerChain}. Never throws.
|
|
93
|
+
*/ export async function authorizeDelegatedWrapping(params) {
|
|
94
|
+
const claim = await verifyGrantorAuthority(params.envelope);
|
|
95
|
+
if (claim == null) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (claim.grantorDID !== normalizeDID(params.wrappingOpIssuer) || claim.wrappingOpHash !== params.wrappingOpHash || normalizeDID(claim.ownerDID) !== normalizeDID(params.ownerDID)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return await checkAdministerChain({
|
|
102
|
+
grantorDID: claim.grantorDID,
|
|
103
|
+
ownerDID: params.ownerDID,
|
|
104
|
+
delegationTokens: claim.delegationTokens,
|
|
105
|
+
deps: params.deps
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -123,6 +123,13 @@ import { processBroadcast } from './broadcast.js';
|
|
|
123
123
|
entries: data.entries,
|
|
124
124
|
auth: data.auth
|
|
125
125
|
}, groupID)),
|
|
126
|
+
// Carried back verbatim: the manifest token digests its own fields, so
|
|
127
|
+
// reshaping it would break its signature.
|
|
128
|
+
'control/credentialKeyManifest': ({ data })=>safeApply(()=>processBroadcast(params, {
|
|
129
|
+
type: 'credential:key-manifest',
|
|
130
|
+
manifest: data.manifest,
|
|
131
|
+
delegationTokens: data.delegationTokens
|
|
132
|
+
}, groupID)),
|
|
126
133
|
'control/groupLeaveRequest': ({ data })=>safeApply(()=>processBroadcast(params, {
|
|
127
134
|
type: 'group:leaveRequest',
|
|
128
135
|
groupID,
|
|
@@ -4,6 +4,7 @@ import type { StoreProvider } from '@kubun/db';
|
|
|
4
4
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
5
5
|
import type { HLC } from '@kubun/hlc';
|
|
6
6
|
import type { Logger } from '@kubun/logger';
|
|
7
|
+
import type { ServiceConfig } from '@kubun/plugin-service-api';
|
|
7
8
|
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
8
9
|
import { type GroupPeer, type LaneResult, type PendingCommit } from '@kumiai/rpc';
|
|
9
10
|
import type { Runtime } from '@sozai/runtime';
|
|
@@ -11,9 +12,12 @@ import type { CreateHubClient } from '../hub/http-client.js';
|
|
|
11
12
|
import { type DeviceHub, type ReconnectingDeviceHub } from '../hub/hub-like.js';
|
|
12
13
|
import type { SyncProtocol } from '../protocol.js';
|
|
13
14
|
import type { ForwardingConfig } from '../sync/forwarder.js';
|
|
15
|
+
import type { ServeService } from '../sync/hub-tunnel-service-listener.js';
|
|
16
|
+
import { HubTunnelServiceProvider } from '../sync/hub-tunnel-service-provider.js';
|
|
14
17
|
import type { LedgerCatchupOptions, LedgerCatchupSummary, RejoinResult, StoreUnreadableMode } from '../types.js';
|
|
15
18
|
import type { GroupBroadcastMessage } from './broadcast-message.js';
|
|
16
19
|
import { type SettleLostControlRequestDeps } from './commit-adoption.js';
|
|
20
|
+
import { type ControllerResolverFor } from './credential-wrapping-deps.js';
|
|
17
21
|
import type { P2PEventEmitter } from './events.js';
|
|
18
22
|
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
19
23
|
import { type GroupProtocols } from './group-protocols.js';
|
|
@@ -66,6 +70,12 @@ export type GroupPeerManagerParams = {
|
|
|
66
70
|
* engine does, so absence is never an absent bound.
|
|
67
71
|
*/
|
|
68
72
|
maxDriftMS?: number;
|
|
73
|
+
/**
|
|
74
|
+
* The engine's `did:kokuin:` controller-resolver seam, forwarded to the apply
|
|
75
|
+
* path for delegate-signed wrapping authorization. Absent means fail-closed
|
|
76
|
+
* (owner-signed wrappings only).
|
|
77
|
+
*/
|
|
78
|
+
controllerResolverFor?: ControllerResolverFor;
|
|
69
79
|
/** Authenticated DID of this device (normalized). */
|
|
70
80
|
localDID: string;
|
|
71
81
|
/**
|
|
@@ -114,6 +124,14 @@ export type GroupPeerManagerParams = {
|
|
|
114
124
|
* handlers has no sync surface to expose.
|
|
115
125
|
*/
|
|
116
126
|
syncHandlers?: ProcedureHandlers<SyncProtocol>;
|
|
127
|
+
/**
|
|
128
|
+
* Injected `plugin-service-server` `serve()` for answering an inbound
|
|
129
|
+
* service-lane tunnel session (see {@link ServeService}). Paired with
|
|
130
|
+
* `services`: both present is what serves the lane.
|
|
131
|
+
*/
|
|
132
|
+
serviceServe?: ServeService;
|
|
133
|
+
/** Which services to serve on an inbound service-lane session. */
|
|
134
|
+
services?: Record<string, ServiceConfig>;
|
|
117
135
|
/** @see TunnelListenersParams.idleTimeoutMs */
|
|
118
136
|
tunnelIdleTimeoutMs?: number;
|
|
119
137
|
};
|
|
@@ -215,6 +233,13 @@ export type GroupPeerManager = {
|
|
|
215
233
|
* nothing here holds.
|
|
216
234
|
*/
|
|
217
235
|
tunnelHub: (groupID: string) => DeviceHub | undefined;
|
|
236
|
+
/**
|
|
237
|
+
* Build the dial-side provider for one directed SERVICE-lane session to a
|
|
238
|
+
* co-member, relayed by the group's hub. `undefined` when the group has no
|
|
239
|
+
* hub bound — the mirror of {@link tunnelHub}'s gate, on the service lane
|
|
240
|
+
* rather than the sync one.
|
|
241
|
+
*/
|
|
242
|
+
serviceTransportTo: (groupID: string, peerDID: string) => Promise<HubTunnelServiceProvider | undefined>;
|
|
218
243
|
/**
|
|
219
244
|
* Try every hub this device holds now, rather than when each one's backoff
|
|
220
245
|
* says so, and answer with whether any of them is connected afterwards.
|