@zanii/mandate-verify-core 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +159 -0
- package/README.md +262 -2
- package/package.json +23 -4
- package/src/conditions.js +134 -0
- package/src/delegation.js +467 -0
- package/src/index.js +899 -0
- package/src/offline.js +347 -0
- package/src/passport.js +102 -0
- package/src/policy.js +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or Derivative
|
|
95
|
+
Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work; and
|
|
103
|
+
|
|
104
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
105
|
+
distribution, then any Derivative Works that You distribute must
|
|
106
|
+
include a readable copy of the attribution notices contained
|
|
107
|
+
within such NOTICE file.
|
|
108
|
+
|
|
109
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
110
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
111
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
112
|
+
this License, without any additional terms or conditions.
|
|
113
|
+
|
|
114
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
115
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
116
|
+
except as required for reasonable and customary use in describing the
|
|
117
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
118
|
+
|
|
119
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed
|
|
120
|
+
to in writing, Licensor provides the Work (and each Contributor
|
|
121
|
+
provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES
|
|
122
|
+
OR CONDITIONS OF ANY KIND, either express or implied, including,
|
|
123
|
+
without limitation, any warranties or conditions of TITLE,
|
|
124
|
+
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
|
125
|
+
|
|
126
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
127
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
128
|
+
unless required by applicable law (such as deliberate and grossly
|
|
129
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
130
|
+
liable to You for damages, including any direct, indirect, special,
|
|
131
|
+
incidental, or consequential damages of any character arising as a
|
|
132
|
+
result of this License or out of the use or inability to use the
|
|
133
|
+
Work.
|
|
134
|
+
|
|
135
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
136
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
137
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
138
|
+
or other liability obligations and/or rights consistent with this
|
|
139
|
+
License. However, in accepting such obligations, You may act only
|
|
140
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
141
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
142
|
+
defend, and hold each Contributor harmless for any liability
|
|
143
|
+
incurred by, or claims asserted against, such Contributor.
|
|
144
|
+
|
|
145
|
+
END OF TERMS AND CONDITIONS
|
|
146
|
+
|
|
147
|
+
Copyright 2026 Zanii
|
|
148
|
+
|
|
149
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
150
|
+
you may not use this file except in compliance with the License.
|
|
151
|
+
You may obtain a copy of the License at
|
|
152
|
+
|
|
153
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
154
|
+
|
|
155
|
+
Unless required by applicable law or agreed to in writing, software
|
|
156
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
157
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
158
|
+
See the License for the specific language governing permissions and
|
|
159
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,263 @@
|
|
|
1
|
-
#
|
|
1
|
+
# verify-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pure verification engine. It receives a signed mandate, an action request, a
|
|
4
|
+
status list, and **server-injected** policy/context; it returns a decision. It
|
|
5
|
+
does no database, HTTP or filesystem access. Supply the server clock explicitly through `now` for deterministic evaluation.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { verifyMandate } from '#verify-core';
|
|
11
|
+
|
|
12
|
+
const result = verifyMandate({
|
|
13
|
+
mandate,
|
|
14
|
+
request: {
|
|
15
|
+
...action,
|
|
16
|
+
periodCount: usage.count,
|
|
17
|
+
periodAmount: { value: '125.00', currency: 'AED' },
|
|
18
|
+
},
|
|
19
|
+
statusList,
|
|
20
|
+
issuerPublicKey,
|
|
21
|
+
pins: {
|
|
22
|
+
trustedIssuers: configuredTrustedIssuers,
|
|
23
|
+
maxStatusAgeSeconds: 300,
|
|
24
|
+
allowPrincipalMismatch: false,
|
|
25
|
+
},
|
|
26
|
+
delegationChain: [],
|
|
27
|
+
approvals: [],
|
|
28
|
+
now: new Date(),
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The production API does not accept `pins`, `now`, usage, a delegation chain, a
|
|
33
|
+
status list, certification status, receipt IDs, or a raw mandate from the caller.
|
|
34
|
+
`Store` loads server state and calls `verifyMandate`; caller attempts to send
|
|
35
|
+
those fields are rejected with `400 UNSUPPORTED_FIELD`. V1 has no certification
|
|
36
|
+
or receipt adapter, so a mandate that requires either fails closed.
|
|
37
|
+
|
|
38
|
+
## Result
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
{
|
|
42
|
+
verdict: 'allow' | 'deny' | 'review' | 'error',
|
|
43
|
+
reasons: [{ code, severity, message, details? }],
|
|
44
|
+
checks: [{ code, ok, severity }],
|
|
45
|
+
status: { status, listId, version, index },
|
|
46
|
+
evaluatedAt: '...'
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `deny`: at least one hard failure.
|
|
51
|
+
- `review`: no hard failure, at least one soft failure.
|
|
52
|
+
- `error`: malformed input or an evaluation exception.
|
|
53
|
+
|
|
54
|
+
`allow` is returned only when every check passes. Soft failures do not authorize. Configure trusted issuer identities independently of the candidate mandate: an empty or missing trusted-root configuration denies authorization. A valid self-contained `did:key` signature establishes key possession, not permission. Use the signature-only primitives when authorization policy is not being evaluated.
|
|
55
|
+
|
|
56
|
+
## Main checks
|
|
57
|
+
|
|
58
|
+
Schema and version; issuer key resolution; signature; issuer trust; agent and
|
|
59
|
+
principal binding; validity window; status list presence, issuer, reference,
|
|
60
|
+
signature, freshness, version, index, and bit; actions; resources; amount and
|
|
61
|
+
count limits; data class; jurisdiction; time window; approvals; delegation;
|
|
62
|
+
certification; evidence/receipts.
|
|
63
|
+
|
|
64
|
+
## Any action, any business
|
|
65
|
+
|
|
66
|
+
These optional constraint fields are evaluated when present; mandates without
|
|
67
|
+
them decide exactly as before.
|
|
68
|
+
|
|
69
|
+
| Field | Effect | Reason codes |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `deniedActions`, `deniedResources` (patterns), `deniedDataClasses` | A match is always a hard deny, even when `actions`/`resources` allow it. An unknown request data class gives review. | `ACTION_DENIED`, `RESOURCE_DENIED`, `DATA_CLASS_DENIED`, `DATA_CLASS_UNKNOWN` |
|
|
72
|
+
| `maxCount.period: minute \| hour` | UTC calendar windows counted by the server, like `day`/`month`. | `COUNT_LIMIT_EXCEEDED`, `PERIOD_COUNT_UNKNOWN` |
|
|
73
|
+
| `approvalAbove: { amount?, count?, riskTier?, approvals?, approver?, minAcr? }` | Above any limit (an unknown amount, count or vocabulary tier counts as above) the request needs approval through the same flow as `requiredApprovals`: review, or deny when `pins.requireApproval` is true. | `APPROVAL_*`, `SEPARATION_OF_DUTIES` |
|
|
74
|
+
| `revocation.maxStatusAgeSeconds` | The mandate's own status freshness budget; status is then always required. Validity above 7 days is accepted only with it and only up to `pins.maxValidityDays` (1 to 365). | `STATUS_STALE`, `STATUS_MISSING`, `VALIDITY_EXCEEDS_POLICY` |
|
|
75
|
+
| `conditions: [{ type, params? }]` | Checked by condition providers. | `CONDITION_PROVIDER_MISSING`, `CONDITION_NOT_MET`, `CONDITION_ERROR` |
|
|
76
|
+
|
|
77
|
+
Conditions use async providers, `{ [type]: async ({ mandate, request, condition, now }) => ({ ok, reason?, evidence? }) }`:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { verifyMandateAsync } from '#verify-core';
|
|
81
|
+
|
|
82
|
+
const result = await verifyMandateAsync({ ...options, conditionProviders, conditionTimeoutMs: 2000 });
|
|
83
|
+
// result.conditions: [{ index, type, ok, code?, reason?, evidence? }]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Providers run only after schema and signature checks pass. A missing provider,
|
|
87
|
+
`ok: false`, a throw, a malformed result or a timeout (default 2000 ms) denies.
|
|
88
|
+
The synchronous `verifyMandate` accepts precomputed `conditionResults` from
|
|
89
|
+
`evaluateConditions`; each result is bound to the exact signed condition (index,
|
|
90
|
+
type and canonical params). Without results a conditioned mandate is denied.
|
|
91
|
+
The request fields `dataSubject`, `counterpartyDid`, `dataRegion`, `destination`
|
|
92
|
+
and `transferSafeguard` (strings, at most 512 characters) pass through to providers.
|
|
93
|
+
|
|
94
|
+
`approvalAbove.riskTier` triggers for actions whose vocabulary tier ranks above
|
|
95
|
+
the given tier, and for free action names with no tier. The default server
|
|
96
|
+
configuration (`packages/config`) sets `requireApproval: true`, so through
|
|
97
|
+
`POST /v1/verify` a missing approval is a deny; the operation flow holds the
|
|
98
|
+
action for review instead.
|
|
99
|
+
|
|
100
|
+
Delegation keeps every restriction: a child must cover each parent deny, keep
|
|
101
|
+
each parent condition, keep an `approvalAbove` that is at least as strict, keep
|
|
102
|
+
the same count period, and keep a freshness budget no weaker than the parent's.
|
|
103
|
+
`inheritParentRestrictions` copies parent denies, conditions and the freshness
|
|
104
|
+
budget into child issuance input.
|
|
105
|
+
|
|
106
|
+
## Delegation
|
|
107
|
+
|
|
108
|
+
`validateDelegationChain` lives in `src/delegation.js`. It checks that the child
|
|
109
|
+
scope is a subset of the parent: actions, resources, amount/period, count/period,
|
|
110
|
+
jurisdictions, data classes, time window, required approvals, certification
|
|
111
|
+
level/scope, evidence confidence, expiry, depth, and the parent signature. It is
|
|
112
|
+
exported separately for tests and for callers that want to validate a chain
|
|
113
|
+
without a full request.
|
|
114
|
+
|
|
115
|
+
Supply root-to-nearest-parent entries as `{ mandate, statusList }` with each parent's signed status envelope and independently configured root trust. Every link binds the parent ID, authorized child issuer, validity, depth and exact logical status-list reference. Numeric status claims, per-item key overrides and chains embedded in the action request do not establish authority. Supported references are the logical list ID or its `urn:zanii:status:` encoding; arbitrary URI suffixes do not bind a list. Stable-DID parent/status keys can be resolved through server-owned `resolveMandatePublicKey` and `resolveStatusPublicKey` pin callbacks; request- or artifact-supplied callbacks are not trusted context.
|
|
116
|
+
|
|
117
|
+
## Root passports
|
|
118
|
+
|
|
119
|
+
`verifyPassport` checks an agent's mandate all the way up to a root authority (for example a
|
|
120
|
+
free zone), offline. The root signs one passport mandate to the firm's issuer DID. The firm then
|
|
121
|
+
issues its own tree (company, role, release, instance) with ordinary delegation, so it never
|
|
122
|
+
needs the root to sign anything else.
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
import { verifyPassport } from '#verify-core';
|
|
126
|
+
|
|
127
|
+
const result = verifyPassport({
|
|
128
|
+
passport, // signed by rootDid, agent = the firm's issuer DID, delegation allowed
|
|
129
|
+
passportStatusList, // the root's signed status list
|
|
130
|
+
rootDid,
|
|
131
|
+
mandate, // the agent instance's mandate
|
|
132
|
+
statusList,
|
|
133
|
+
delegationChain, // the firm's chain, top first: [{ mandate, statusList }, ...]
|
|
134
|
+
request: { agentDid, action, resource },
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
It denies when the passport is not from `rootDid` (`PASSPORT_ROOT_UNTRUSTED`), is not a root
|
|
139
|
+
(`PASSPORT_NOT_ROOT`), names a different issuer (`PASSPORT_ISSUER_MISMATCH`), does not allow
|
|
140
|
+
issuing (`PASSPORT_NOT_DELEGABLE`), or when the firm's top mandate exceeds it
|
|
141
|
+
(`PASSPORT_SCOPE_EXCEEDED`). Then it runs `verifyMandate` twice: the passport against the
|
|
142
|
+
request with the root as the only trusted issuer (reasons tagged `scope: 'passport'`), and the
|
|
143
|
+
firm's chain with the firm's issuer as the only trusted issuer. Revoking any level, from the
|
|
144
|
+
passport down to the instance, denies everything below it. Budgets on the passport cannot be
|
|
145
|
+
counted offline, so they give `review`, not `allow`.
|
|
146
|
+
|
|
147
|
+
## Offline trust bundles
|
|
148
|
+
|
|
149
|
+
`createOfflineTrustBundle` in `#db` reads an initialized control store and exports
|
|
150
|
+
one root-to-leaf chain. It does not sign a new snapshot or mint an authorization.
|
|
151
|
+
The latest signed status for every artifact must already satisfy the producer's
|
|
152
|
+
freshness bound. `verifyOfflineTrustBundle` is pure: it performs no database,
|
|
153
|
+
filesystem, HTTP, clock mutation, usage, reservation, or approval operation.
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { createOfflineTrustBundle } from '#db';
|
|
157
|
+
import { verifyOfflineTrustBundle } from '#verify-core';
|
|
158
|
+
|
|
159
|
+
const bundle = await createOfflineTrustBundle({
|
|
160
|
+
store: controlStore,
|
|
161
|
+
mandateId,
|
|
162
|
+
maxStatusAgeSeconds: 25,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const result = verifyOfflineTrustBundle({
|
|
166
|
+
bundle,
|
|
167
|
+
request: { agentDid, action: 'supplier.read', resource: 'supplier:one' },
|
|
168
|
+
trustAnchors: configuredAnchors,
|
|
169
|
+
statusCheckpoints: durableCheckpoints,
|
|
170
|
+
maxStatusAgeSeconds: 25,
|
|
171
|
+
now: new Date(),
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Version 1 format
|
|
176
|
+
|
|
177
|
+
The JSON object has `format: "zanii-offline-trust-bundle"`, a manifest, public
|
|
178
|
+
`issuerKeys`, and `mandates`. The manifest binds the root issuer DID,
|
|
179
|
+
`bundleVersion: 1`, `createdAt`, and `contentHash`. The hash is SHA-256 over the
|
|
180
|
+
canonical bundle with `manifest.contentHash` removed. Each `mandates` entry has:
|
|
181
|
+
|
|
182
|
+
- `artifact`: the exact signed mandate JSON string from the control store;
|
|
183
|
+
- `statusEnvelope`: the exact signed status JSON string, including its proof and
|
|
184
|
+
`validFrom`/`validUntil` window.
|
|
185
|
+
|
|
186
|
+
Entries are root-to-leaf and contain the leaf as well as every parent and parent
|
|
187
|
+
status. Public issuer history preserves key ID, DID, purpose, verification
|
|
188
|
+
method, activation, retirement, compromise, and creation timestamps. Private
|
|
189
|
+
JWK parameters and signing keys are never exported. The bundle contains no trust
|
|
190
|
+
anchors, trusted-issuer list, verifier pins, usage, approval, or dispatch policy.
|
|
191
|
+
Its content hash detects modification; it is not a signature and does not make
|
|
192
|
+
bundle metadata or key history trustworthy.
|
|
193
|
+
|
|
194
|
+
### Caller-owned trust and status checkpoints
|
|
195
|
+
|
|
196
|
+
`trustAnchors` is mandatory caller configuration, never bundle input:
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
const configuredAnchors = [{
|
|
200
|
+
issuerDid: 'did:example:issuer',
|
|
201
|
+
keys: [{
|
|
202
|
+
purpose: 'mandate', // or "status"
|
|
203
|
+
verificationMethod: 'did:example:issuer#keyhist_...',
|
|
204
|
+
publicKey: configuredPublicJwk,
|
|
205
|
+
activatedAt: '...',
|
|
206
|
+
retiredAt: null,
|
|
207
|
+
compromisedAt: null,
|
|
208
|
+
}],
|
|
209
|
+
}];
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Every mandate and status proof must match a configured key for its exact issuer,
|
|
213
|
+
purpose, verification method, and artifact-time key-history window. The root
|
|
214
|
+
issuer must be explicitly anchored. A valid signature from an unknown issuer,
|
|
215
|
+
or a key copied from `bundle.issuerKeys`, does not establish trust. Stable-DID
|
|
216
|
+
chains need configured key resolution for each signing issuer; delegation still
|
|
217
|
+
has to prove parent-agent authority from the trusted root.
|
|
218
|
+
|
|
219
|
+
Optional `statusCheckpoints` are caller-durable high-water records with `issuer`,
|
|
220
|
+
`listId`, `version`, and `hash`. Older snapshots deny with `STATUS_ROLLBACK`;
|
|
221
|
+
different bytes at an accepted version deny with `STATUS_EQUIVOCATION`. Results
|
|
222
|
+
return one checkpoint per issuer/list only for fresh, authenticated snapshots,
|
|
223
|
+
including a fresh revoked snapshot that correctly denies. Persist those records
|
|
224
|
+
atomically outside the verifier. Stale or unauthenticated results return no new
|
|
225
|
+
checkpoint. Deduplication across a chain does not remove any parent's separate
|
|
226
|
+
signature, status, freshness, or delegation checks.
|
|
227
|
+
|
|
228
|
+
### What an offline verdict proves, and does not prove
|
|
229
|
+
|
|
230
|
+
The verifier checks the manifest hash, every mandate/status signature against
|
|
231
|
+
caller anchors, schema and expiry, full chain IDs, parent-agent authority,
|
|
232
|
+
cycles, maximum depth, scope attenuation, each parent's fresh signed status,
|
|
233
|
+
leaf status, action/resource scope, and static request constraints. Verdicts are
|
|
234
|
+
`allow`, `deny`, or `review` with machine-readable reasons. Malformed input and
|
|
235
|
+
core evaluation errors fail closed as `deny`.
|
|
236
|
+
|
|
237
|
+
Every result has `offline: true` and `dispatchPermit: false`. `notEstablished`
|
|
238
|
+
always reports that later revocation and operation-bound dispatch authorization
|
|
239
|
+
were not established. An offline `allow` is a static evaluation under fresh
|
|
240
|
+
signed status at `evaluatedAt`; it is not a reusable permit, a reservation, an
|
|
241
|
+
approval release, a spend, or evidence of an external outcome.
|
|
242
|
+
|
|
243
|
+
Count quotas, day/month amount quotas, and applicable required approvals add
|
|
244
|
+
`DYNAMIC_STATE_UNAVAILABLE` and prevent `allow` (normally producing `review`
|
|
245
|
+
when all static checks pass). Certification and required-receipt state are also
|
|
246
|
+
unavailable offline. Caller-supplied period usage, approval IDs, and receipt IDs
|
|
247
|
+
are ignored rather than treated as authoritative. `dynamicState` identifies the
|
|
248
|
+
missing requirements. Transaction-scoped static amount checks and mandates with
|
|
249
|
+
no dynamic requirements can still produce offline `allow`. Current quotas,
|
|
250
|
+
approvals, reservations, and dispatch require the authoritative service inside
|
|
251
|
+
the connected deployment boundary.
|
|
252
|
+
|
|
253
|
+
## Test
|
|
254
|
+
|
|
255
|
+
```powershell
|
|
256
|
+
node --test "tests/verify.test.js" "tests/delegation.test.js" "tests/passport.test.js"
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Releases
|
|
260
|
+
|
|
261
|
+
- **0.3.0**: `verifyPassport` checks an agent's mandate offline through the firm's own tree up to a
|
|
262
|
+
root authority (see "Root passports"). Additive; every 0.2.0 export is unchanged.
|
|
263
|
+
- **0.2.0**: first public release.
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zanii/mandate-verify-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": "./src/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"LICENSE",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zanii/mandate-core": "0.2.0",
|
|
13
|
+
"@zanii/mandate-policy-engine": "0.2.0",
|
|
14
|
+
"@zanii/mandate-status-list": "0.2.0"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24"
|
|
18
|
+
},
|
|
19
|
+
"license": "Apache-2.0",
|
|
20
|
+
"author": "Zanii AI <info@zanii.agency>",
|
|
21
|
+
"homepage": "https://zanii.agency",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { isPlainObject } from '@zanii/mandate-core';
|
|
2
|
+
import { verifyMandate } from './index.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CONDITION_TIMEOUT_MS = 2000;
|
|
5
|
+
const MAX_EVIDENCE_BYTES = 4096;
|
|
6
|
+
const MAX_REASON_LENGTH = 500;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Evaluate `constraints.conditions` through registered providers.
|
|
10
|
+
*
|
|
11
|
+
* Providers are `{ [type]: async ({ mandate, request, condition, now }) =>
|
|
12
|
+
* ({ ok, reason?, evidence? }) }`. A missing provider, a provider that says
|
|
13
|
+
* no, throws, times out or returns a malformed result never yields `ok`.
|
|
14
|
+
* Results are passed to `verifyMandate` as `conditionResults`.
|
|
15
|
+
*/
|
|
16
|
+
export async function evaluateConditions({
|
|
17
|
+
mandate,
|
|
18
|
+
request,
|
|
19
|
+
providers = {},
|
|
20
|
+
now = new Date(),
|
|
21
|
+
timeoutMs = DEFAULT_CONDITION_TIMEOUT_MS,
|
|
22
|
+
} = {}) {
|
|
23
|
+
const conditions = mandate?.credentialSubject?.constraints?.conditions;
|
|
24
|
+
if (!Array.isArray(conditions) || conditions.length === 0) return [];
|
|
25
|
+
const budget =
|
|
26
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CONDITION_TIMEOUT_MS;
|
|
27
|
+
return Promise.all(
|
|
28
|
+
conditions.map((condition, index) =>
|
|
29
|
+
evaluateOne({ mandate, request, providers, now, timeoutMs: budget }, condition, index),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function evaluateOne(context, condition, index) {
|
|
35
|
+
const base = { index, type: condition?.type, condition: structuredClone(condition) };
|
|
36
|
+
const provider =
|
|
37
|
+
isPlainObject(context.providers) && Object.hasOwn(context.providers, condition?.type)
|
|
38
|
+
? context.providers[condition.type]
|
|
39
|
+
: undefined;
|
|
40
|
+
if (typeof provider !== 'function') {
|
|
41
|
+
return {
|
|
42
|
+
...base,
|
|
43
|
+
ok: false,
|
|
44
|
+
code: 'CONDITION_PROVIDER_MISSING',
|
|
45
|
+
reason: `No condition provider is registered for ${condition?.type}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let timer;
|
|
50
|
+
try {
|
|
51
|
+
const outcome = await Promise.race([
|
|
52
|
+
Promise.resolve().then(() =>
|
|
53
|
+
provider({
|
|
54
|
+
mandate: structuredClone(context.mandate),
|
|
55
|
+
request: structuredClone(context.request),
|
|
56
|
+
condition: structuredClone(condition),
|
|
57
|
+
now: new Date(context.now),
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
new Promise((_, reject) => {
|
|
61
|
+
timer = setTimeout(() => reject(new ConditionTimeout()), context.timeoutMs);
|
|
62
|
+
}),
|
|
63
|
+
]);
|
|
64
|
+
if (!isPlainObject(outcome) || typeof outcome.ok !== 'boolean') {
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
ok: false,
|
|
68
|
+
code: 'CONDITION_ERROR',
|
|
69
|
+
reason: 'Condition provider returned a malformed result',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const evidence = safeEvidence(outcome.evidence);
|
|
73
|
+
if (outcome.ok) return { ...base, ok: true, ...(evidence ? { evidence } : {}) };
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
ok: false,
|
|
77
|
+
code: 'CONDITION_NOT_MET',
|
|
78
|
+
reason:
|
|
79
|
+
typeof outcome.reason === 'string' && outcome.reason
|
|
80
|
+
? outcome.reason.slice(0, MAX_REASON_LENGTH)
|
|
81
|
+
: 'Condition is not met',
|
|
82
|
+
...(evidence ? { evidence } : {}),
|
|
83
|
+
};
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return {
|
|
86
|
+
...base,
|
|
87
|
+
ok: false,
|
|
88
|
+
code: 'CONDITION_ERROR',
|
|
89
|
+
reason:
|
|
90
|
+
error instanceof ConditionTimeout
|
|
91
|
+
? `Condition provider timed out after ${context.timeoutMs} ms`
|
|
92
|
+
: 'Condition provider failed',
|
|
93
|
+
};
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class ConditionTimeout extends Error {}
|
|
100
|
+
|
|
101
|
+
// Evidence is recorded in decisions and audit events: JSON data only, bounded.
|
|
102
|
+
function safeEvidence(evidence) {
|
|
103
|
+
if (evidence === undefined || evidence === null) return null;
|
|
104
|
+
if (!isPlainObject(evidence)) return { invalid: true };
|
|
105
|
+
try {
|
|
106
|
+
const text = JSON.stringify(evidence);
|
|
107
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_EVIDENCE_BYTES) return { truncated: true };
|
|
108
|
+
return JSON.parse(text);
|
|
109
|
+
} catch {
|
|
110
|
+
return { invalid: true };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `verifyMandate` plus condition providers (option `conditionProviders`,
|
|
116
|
+
* timeout `conditionTimeoutMs`). Providers run only after the signature and
|
|
117
|
+
* schema checks pass, so an unauthenticated artifact never triggers a lookup.
|
|
118
|
+
*/
|
|
119
|
+
export async function verifyMandateAsync(options = {}) {
|
|
120
|
+
const conditions = options.mandate?.credentialSubject?.constraints?.conditions;
|
|
121
|
+
if (!Array.isArray(conditions) || conditions.length === 0) return verifyMandate(options);
|
|
122
|
+
const now = options.now ?? new Date();
|
|
123
|
+
const preliminary = verifyMandate({ ...options, now });
|
|
124
|
+
const signed = preliminary.checks.some((check) => check.code === 'SIGNATURE_VALID' && check.ok);
|
|
125
|
+
if (!signed || preliminary.verdict === 'error') return preliminary;
|
|
126
|
+
const conditionResults = await evaluateConditions({
|
|
127
|
+
mandate: options.mandate,
|
|
128
|
+
request: options.request,
|
|
129
|
+
providers: options.conditionProviders,
|
|
130
|
+
now,
|
|
131
|
+
timeoutMs: options.conditionTimeoutMs,
|
|
132
|
+
});
|
|
133
|
+
return verifyMandate({ ...options, now, conditionResults });
|
|
134
|
+
}
|