@kubun/plugin-p2p 0.14.0 → 0.15.1

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 (55) hide show
  1. package/lib/context/peer.js +250 -3
  2. package/lib/context/sync.js +134 -25
  3. package/lib/context/types.d.ts +7 -0
  4. package/lib/groups/broadcast-message.d.ts +29 -0
  5. package/lib/groups/broadcast.d.ts +11 -1
  6. package/lib/groups/broadcast.js +44 -2
  7. package/lib/groups/credential-apply.d.ts +64 -2
  8. package/lib/groups/credential-apply.js +215 -30
  9. package/lib/groups/credential-grant.d.ts +22 -0
  10. package/lib/groups/credential-grant.js +76 -2
  11. package/lib/groups/credential-manifest-token.d.ts +31 -0
  12. package/lib/groups/credential-manifest-token.js +49 -0
  13. package/lib/groups/credential-readiness.d.ts +69 -0
  14. package/lib/groups/credential-readiness.js +172 -0
  15. package/lib/groups/credential-wrapping-deps.d.ts +23 -0
  16. package/lib/groups/credential-wrapping-deps.js +25 -0
  17. package/lib/groups/did-cache-seed.d.ts +26 -0
  18. package/lib/groups/did-cache-seed.js +38 -0
  19. package/lib/groups/grantor-authority.d.ts +65 -0
  20. package/lib/groups/grantor-authority.js +107 -0
  21. package/lib/groups/group-handlers.js +7 -0
  22. package/lib/groups/group-peer-manager.d.ts +32 -1
  23. package/lib/groups/group-peer-manager.js +77 -0
  24. package/lib/groups/group-protocols.d.ts +47 -0
  25. package/lib/groups/group-protocols.js +28 -0
  26. package/lib/hub/http-client.js +6 -1
  27. package/lib/hub/hub-like.js +5 -2
  28. package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
  29. package/lib/hub/peer-scoped-hub-view.js +11 -1
  30. package/lib/hub/wiring.d.ts +31 -1
  31. package/lib/hub/wiring.js +20 -1
  32. package/lib/index.d.ts +14 -0
  33. package/lib/index.js +72 -4
  34. package/lib/protocol.d.ts +30 -0
  35. package/lib/protocol.js +36 -0
  36. package/lib/schema.js +78 -1
  37. package/lib/sync/handlers.js +21 -2
  38. package/lib/sync/held-delegations.d.ts +14 -0
  39. package/lib/sync/held-delegations.js +34 -0
  40. package/lib/sync/hub-tunnel-service-listener.d.ts +84 -0
  41. package/lib/sync/hub-tunnel-service-listener.js +294 -0
  42. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  43. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  44. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
  45. package/lib/sync/hub-tunnel-sync-listener.js +6 -1
  46. package/lib/sync/service-tunnel-listeners.d.ts +41 -0
  47. package/lib/sync/service-tunnel-listeners.js +183 -0
  48. package/lib/sync/sync-manager.d.ts +7 -0
  49. package/lib/sync/sync-manager.js +4 -1
  50. package/lib/sync/tunnel-listeners.d.ts +7 -1
  51. package/lib/sync/tunnel-listeners.js +18 -0
  52. package/lib/sync/tunnel-topics.d.ts +19 -1
  53. package/lib/sync/tunnel-topics.js +7 -3
  54. package/lib/types.d.ts +172 -0
  55. package/package.json +49 -48
@@ -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,26 @@
1
+ import { type DIDCache } from '@kokuin/token';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
5
+ export type SeedDIDCacheParams = {
6
+ cache: DIDCache;
7
+ registry: GroupHandleRegistry;
8
+ groupID: string;
9
+ stores?: StoreProvider;
10
+ logger?: Logger;
11
+ };
12
+ /**
13
+ * Seed the shared DID cache with every `did:peer:4` co-member's document, read
14
+ * off the MLS roster's authenticated leaf long forms.
15
+ *
16
+ * A co-member's short-form `iss` on a peer-lane token carries no document, so a
17
+ * freshly-spawned tunnel/sync Server cannot resolve it without this. The roster
18
+ * leaf is the one authenticated source of the long form — the membership row
19
+ * holds only the normalized short form, which carries none.
20
+ *
21
+ * Monotonic and idempotent: a peer:4 doc is content-addressed, so re-seeding an
22
+ * existing entry is a no-op and a departed member's lingering doc is harmless
23
+ * (per-scope authorization gates access downstream, not cache presence). So the
24
+ * membership-change seam only ever adds, never evicts.
25
+ */
26
+ export declare function seedDIDCacheFromRoster(params: SeedDIDCacheParams): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { decodePeer4, isPeer4 } from '@kokuin/token';
2
+ /**
3
+ * Seed the shared DID cache with every `did:peer:4` co-member's document, read
4
+ * off the MLS roster's authenticated leaf long forms.
5
+ *
6
+ * A co-member's short-form `iss` on a peer-lane token carries no document, so a
7
+ * freshly-spawned tunnel/sync Server cannot resolve it without this. The roster
8
+ * leaf is the one authenticated source of the long form — the membership row
9
+ * holds only the normalized short form, which carries none.
10
+ *
11
+ * Monotonic and idempotent: a peer:4 doc is content-addressed, so re-seeding an
12
+ * existing entry is a no-op and a departed member's lingering doc is harmless
13
+ * (per-scope authorization gates access downstream, not cache presence). So the
14
+ * membership-change seam only ever adds, never evicts.
15
+ */ export async function seedDIDCacheFromRoster(params) {
16
+ const { cache, registry, groupID, stores, logger } = params;
17
+ const members = await registry.readHandle(groupID, (handle)=>handle.listMembers(), stores == null ? undefined : {
18
+ stores
19
+ });
20
+ for (const member of members){
21
+ // did:key members resolve natively; only a genuine peer:4 long form (≠ the
22
+ // short-form `id`) carries a document worth caching.
23
+ if (!isPeer4(member.longForm) || member.longForm === member.id) {
24
+ continue;
25
+ }
26
+ try {
27
+ const { shortForm, doc } = decodePeer4(member.longForm);
28
+ await cache.set(shortForm, doc);
29
+ } catch (error) {
30
+ // A single unparseable leaf must not abort seeding the rest of the roster.
31
+ logger?.warn('did-cache roster seed skipped a member', {
32
+ groupID,
33
+ id: member.id,
34
+ error
35
+ });
36
+ }
37
+ }
38
+ }
@@ -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,
@@ -1,9 +1,10 @@
1
1
  import type { ProcedureHandlers } from '@enkaku/server';
2
- import type { OwnIdentity } from '@kokuin/token';
2
+ import type { DIDCache, OwnIdentity } from '@kokuin/token';
3
3
  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,20 @@ 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>;
135
+ /**
136
+ * Shared, plugin-lifetime DID cache forwarded to the sync- and service-tunnel
137
+ * listeners, which seed it from each group's roster and hand it to every
138
+ * spawned session's Server (see {@link TunnelListenersParams.cache}).
139
+ */
140
+ cache?: DIDCache;
117
141
  /** @see TunnelListenersParams.idleTimeoutMs */
118
142
  tunnelIdleTimeoutMs?: number;
119
143
  };
@@ -215,6 +239,13 @@ export type GroupPeerManager = {
215
239
  * nothing here holds.
216
240
  */
217
241
  tunnelHub: (groupID: string) => DeviceHub | undefined;
242
+ /**
243
+ * Build the dial-side provider for one directed SERVICE-lane session to a
244
+ * co-member, relayed by the group's hub. `undefined` when the group has no
245
+ * hub bound — the mirror of {@link tunnelHub}'s gate, on the service lane
246
+ * rather than the sync one.
247
+ */
248
+ serviceTransportTo: (groupID: string, peerDID: string) => Promise<HubTunnelServiceProvider | undefined>;
218
249
  /**
219
250
  * Try every hub this device holds now, rather than when each one's backoff
220
251
  * says so, and answer with whether any of them is connected afterwards.