@intyga/verify 0.0.0-bootstrap.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,299 @@
1
1
  # @intyga/verify
2
2
 
3
- This is a placeholder. It contains no code and should not be installed.
3
+ **Independently confirm that a human cryptographically approved exactly the action you're about to run — with no INTYGA secret.**
4
4
 
5
- Real versions of @intyga/verify are published by INTYGA's release workflow. See https://www.intyga.com.
5
+ When INTYGA returns an approval, it hands you a **receipt**: the exact canonical payload the human's key signed, plus the signature and public key. This library lets your own code re-derive that payload from *your* parameters, check it byte-for-byte against what was signed, and verify the signature — entirely offline. You don't have to trust INTYGA's word that the approval is real; you check the math yourself.
6
+
7
+ - **Zero npm runtime dependencies.** Receipt verification uses Node cryptography. Optional RFC 3161 timestamp verification additionally requires an installed OpenSSL 3 executable.
8
+ - **No INTYGA secret required.** Verification uses approver keys **you** resolve — never a key read out of the receipt (see [Whose key?](#whose-key-the-trust-anchor)).
9
+ - Verifies both **WebAuthn** approvals (passkey / hardware security key — the normal path) and **raw P-256** signatures (legacy/headless signer keys), plus policy `AUTO_APPROVED` receipts.
10
+
11
+ ```ts
12
+ import { verifyApprovalReceipt } from "@intyga/verify";
13
+
14
+ // `receipt` came back from INTYGA when the human approved.
15
+ const check = verifyApprovalReceipt(receipt, {
16
+ target: "prod-payments-eu", // YOUR service identifier — see below
17
+ actionType: "wipe_production",
18
+ params: { target: "prod-db-1", region: "eu-north-1" }, // what you're ACTUALLY about to do
19
+ nonce, // the challenge YOU issued — see "Replay" below
20
+ approvers: { // WHOSE signature you accept — see "Whose key?" below
21
+ dids: ["did:intyga:cfo-alice", "did:intyga:cto-bob"],
22
+ resolveKey: (did) => APPROVER_KEYS[did] ?? null, // from YOUR config/directory
23
+ },
24
+ // YOUR approval rule for this action — see "Whose quorum?" below. Without it you verify only the
25
+ // quorum the signers themselves wrote into the receipt.
26
+ requirement: { requiredApprovals: 2, requesterCannotApprove: true },
27
+ }, {
28
+ // REQUIRED for passkey receipts (the normal flow): your approval console's exact origin and RP ID,
29
+ // from the trust-anchor file exported in the console (its `webauthn` block). Without them a passkey
30
+ // receipt is refused — see "WebAuthn receipts need an origin and an RP ID" below.
31
+ expectedOrigin: process.env.INTYGA_WEBAUTHN_ORIGIN!,
32
+ expectedRpId: process.env.INTYGA_WEBAUTHN_RP_ID!,
33
+ });
34
+
35
+ if (!check.ok) throw new Error(`Refusing to proceed: ${check.reason}`);
36
+ // ✅ A human signed off on THIS exact instruction. Safe to execute.
37
+ ```
38
+
39
+ Why re-pass the params? So the approval can't be swapped: if what you're about to execute differs by a
40
+ single byte from what the human saw and signed, `verifyApprovalReceipt` returns `{ ok: false }`. This is
41
+ your defense-in-depth even against a compromised INTYGA gateway.
42
+
43
+ `target` is **required and must come from your own configuration, never from the receipt**. It is what
44
+ rejects an approval that was minted for a *different* service (DIV Target Isolation): if the verifier
45
+ read the target out of the receipt, the receipt would be defining the scope it is checked against, and
46
+ a proof harvested from another relying party would verify. Omitting it is refused rather than defaulted.
47
+
48
+ ## Whose key? (the trust anchor)
49
+
50
+ `approvers` is **required**, and it is the single most important input. Everything else this library
51
+ does is arithmetic; this is the part that decides *whose* approval counts.
52
+
53
+ Verification never uses `receipt.signerPublicKey`. If it did, the receipt would be vouching for its own
54
+ signer: anyone able to hand you a receipt — and under the DIV threat model that includes the untrusted
55
+ agent — could generate a keypair, sign a payload over the nonce you issued and the params you are about
56
+ to run, put any string in `signerDid`, and be told a human approved. Everything would check out, because
57
+ the signature really would verify against the key in the object.
58
+
59
+ So you supply the keys. Three forms:
60
+
61
+ ```ts
62
+ // A pinned allowlist. Simplest, and the identity IS the key — the receipt's signerDid is not trusted.
63
+ approvers: { publicKeys: [ALICE_SPKI_B64, BOB_SPKI_B64] }
64
+
65
+ // Or a DID allowlist plus your own resolver (directory lookup, enrollment record, config map).
66
+ approvers: { dids: [...], resolveKey: (did) => myDirectory.get(did) ?? null }
67
+
68
+ // Or self-certifying DIDs alone — no resolver, no key distribution at all.
69
+ approvers: { dids: ["did:intyga:key:tSNSCM0v6Qs…"] }
70
+ ```
71
+
72
+ **Self-certifying DIDs.** A pinned DID of the form `did:intyga:key:<base64url(sha256(key bytes))>`
73
+ is itself a commitment to the enrolled public key: the receipt carries the key, and verification
74
+ accepts it exactly when it hashes to the pinned DID (`selfCertifyingDid()` exports the derivation).
75
+ **Precedence: an explicit mapping always wins.** If `resolveKey` returns keys for the DID, those
76
+ keys are the anchor and the commitment is not consulted — that is what lets you extend the identity
77
+ to credentials enrolled after the DID was minted, and (the direction that matters for security)
78
+ *narrow* it away from a compromised credential by re-exporting the anchor without it. The bare
79
+ commitment applies only when the anchor names no keys, which is what makes a plain DID list a
80
+ complete anchor with zero key distribution. Pinned DIDs that are NOT self-certifying still require
81
+ `resolveKey`; verification fails closed with an explicit reason otherwise. Self-certifying
82
+ validation and explicit-mapping precedence are also implemented in the Go, Rust, Java and Python
83
+ ports and exercised by shared verifier fixtures.
84
+
85
+ **Where the key must come from.** Somewhere you control and that an attacker who can forge a receipt
86
+ cannot also change: your deployment config, your secrets manager, your own IdP/directory, or keys you
87
+ pinned at enrollment.
88
+
89
+ **Where it must NOT come from.** Fetching approver keys from the INTYGA gateway at verification time
90
+ defeats the entire property — a compromised gateway would then supply both the receipt and the key that
91
+ validates it, and this library would happily agree. If you are going to trust the gateway for keys, you
92
+ do not need this library; you can just trust its answer.
93
+
94
+ ## Whose quorum? (the requirement floor)
95
+
96
+ The signed payload carries a `requirement` — `requiredApprovals`, `requesterCannotApprove`,
97
+ `requireHardwareKey` — and verification counts **distinct** approvers whose signature verifies under a
98
+ key you resolved against it. But that requirement is **the signers' own statement**. Its signature stops
99
+ a third party from altering it; it does not stop the people it constrains from writing a weaker one.
100
+ Whoever composes the bytes chooses the requirement, so one approver — including one who is also the
101
+ requester — can compose `{ requiredApprovals: 1, requesterCannotApprove: false }` for an action your
102
+ policy gates at 3-of-3 with four-eyes, sign it alone, and hand you a receipt that verifies. A
103
+ compromised gateway can do the same at issuance.
104
+
105
+ **Without a floor, "quorum met" means only "the quorum the signers stated was met".** If you know the
106
+ rule — from a trust bundle, your own configuration, or anywhere you control — pass it as
107
+ `expected.requirement`:
108
+
109
+ ```ts
110
+ verifyApprovalReceipt(receipt, { ...expected, requirement: { requiredApprovals: 3, requesterCannotApprove: true } })
111
+ // → { ok: false, reason: "signed requirement is weaker than the relying party's policy: it requires 1 approval(s), the policy 3 (DIV §5 step 3d)" }
112
+ ```
113
+
114
+ A signed requirement weaker on any field — fewer approvals, no four-eyes where you require it, no
115
+ hardware key where you require it — is refused before any signature is counted (DIV §5 step 3d). An
116
+ equal or stricter one passes, and the signed value is then what is enforced. A malformed floor (a quorum
117
+ below 1, say) is refused rather than treated as absent. `allowedAaguids` is not floored; express a model
118
+ restriction as `requireHardwareKey`. The same `requirement` field exists on `verifyDelegation` (pass the
119
+ ordinary rule the delegation was sealed against) and `verifyAgentAuthority` (your sealing policy); under
120
+ an offline delegation, pass the ordinary rule to `verifyApprovalReceipt` too. Omitting it keeps the
121
+ previous behaviour, for compatibility — which is exactly the weaker guarantee described above.
122
+
123
+ Key-only trust is refused when the signed `requiredApprovals` exceeds 1 or `requesterCannotApprove` is
124
+ true. Use the DID form for those policies: multiple credentials for one DID count as one person
125
+ (DIV §5 step 3b).
126
+
127
+ ## Expiry and replay: what this does and does not prove
128
+
129
+ `ok: true` proves a human key signed **exactly this action, with exactly these params, for exactly the
130
+ target and nonce you passed**, and that the proof **has not expired**. The signed `expiresAt` is enforced
131
+ fail-closed by default (±30s clock-skew tolerance); pass `{ allowExpired: true }` only for post-hoc
132
+ audit/forensic re-verification, where confirming a signature that was valid *at the time* is the point.
133
+
134
+ Expiry bounds how long a proof is valid, but it does **not** prove the approval hasn't already been used
135
+ *within* that window. Single-use enforcement is separate: it lives in the gateway's `/authorize/verify`,
136
+ which atomically marks the challenge `CONSUMED`. This library is a companion to that call, not a
137
+ replacement for it. If you verify offline and skip the consume step, **you** must record redeemed nonces
138
+ yourself — which is why `nonce` is a required part of the expectation rather than something read out of
139
+ the receipt.
140
+
141
+ ## WebAuthn receipts need an origin and an RP ID
142
+
143
+ A WebAuthn assertion says "this credential signed these bytes" — it does not, by itself, say *which
144
+ relying party asked*. So for `sigAlg: "WEBAUTHN"` you must pin both, or verification is refused:
145
+
146
+ ```ts
147
+ verifyApprovalReceipt(receipt, expected, {
148
+ expectedOrigin: "https://app.example.com", // exact clientDataJSON origin
149
+ expectedRpId: "app.example.com", // hashed into authenticatorData
150
+ });
151
+ ```
152
+
153
+ The verifier then checks the assertion is a `webauthn.get` (not a registration), that its origin matches,
154
+ that `authenticatorData`'s rpIdHash matches your RP ID, that the assertion was **not** produced inside a
155
+ cross-origin frame (`crossOrigin: true`, or a `topOrigin` that differs from `origin`), and that the user
156
+ was present **and verified** (biometric/PIN). Pass `requireUserVerification: false` only if you
157
+ consciously accept mere possession; `verifyPlatformReceipt` ignores it, because a platform receipt
158
+ always requires user verification (DIV §5c.3).
159
+
160
+ The cross-origin refusal is on by default and `origin` alone cannot substitute for it: inside a
161
+ cross-origin iframe the browser reports the *frame's* origin — the RP's own — and rpIdHash matches too,
162
+ so a third-party embedder with `publickey-credentials-get` delegated could drive the whole ceremony
163
+ while every other check passes. If your approval UI is legitimately framed, opt in with
164
+ `allowCrossOrigin: true`.
165
+
166
+ When the signed policy sets `requireHardwareKey`, a WebAuthn witness whose signed `authenticatorData`
167
+ has the Backup Eligible or Backup State flag set is not counted (DIV §4.4.5 rule 6): a synced passkey
168
+ cannot satisfy a hardware-key policy, and the flags are covered by the signature. Clear flags are the
169
+ authenticator's claim, not attestation — the authenticator model still comes only from enrollment.
170
+
171
+ ## Policy auto-approvals (break-glass / pre-approval windows)
172
+
173
+ Some receipts are `sigAlg: "AUTO_APPROVED"` — the action was pre-authorized by a policy window, so **no
174
+ human signed it and there is nothing to cryptographically verify**. Such a receipt is trivially
175
+ forgeable, so `verifyApprovalReceipt` **refuses it by default** (`{ ok: false, autoApproved: true }`) —
176
+ your `if (!verify().ok) throw` correctly blocks it. If your relying party has consciously accepted policy
177
+ pre-approval, opt in explicitly:
178
+
179
+ ```ts
180
+ verifyApprovalReceipt(receipt, expected, { allowAutoApproved: true }); // → { ok: true, autoApproved: true }
181
+ ```
182
+
183
+ `ok: true` without `allowAutoApproved` therefore always means **a real human signature verified**.
184
+
185
+ ## API
186
+ - `verifyApprovalReceipt(receipt, { approvers, target, actionType, params, nonce, requesterDid? }, { allowAutoApproved?, allowOffline?, delegation?, expectedOrigin?, expectedRpId?, requireUserVerification?, allowCrossOrigin?, allowExpired?, asOf?, clockSkewSeconds? })` → `{ ok, reason?, autoApproved?, signers? }` — `approvers`, `target` and `nonce` are all required and asserted from your own state, never read from the receipt
187
+ - `verifyDelegation(receipt, { approvers, target, actionType, params }, opts?)` → `{ ok, reason?, delegation? }` — checks that the ORDINARY approvers signed away their entitlement (DIV §4.4.6). The result is an input to a later `verifyApprovalReceipt` via `delegation`, never a substitute for one.
188
+ - `verifyAgentAuthority(receipt, { approvers, target, agentDid }, opts?)` → `{ ok, reason?, authority? }` — a sealed §5b scope grant, not an approval. Revocation is authoritative online only, so treat a seal like a certificate, not a bearer token.
189
+ - `canonicalIntentPayload({ target, actionType, display, params, requester, requirement, nonce, expiresAt })` → the exact signed string (DIV v1). Also exported: `canonicalOfflineIntentPayload` (DIV §5a offline approval), `canonicalDelegationPayload` and `canonicalAgentAuthorityPayload` (DIV §5b). The pre-DIV `canonicalAuthorizationPayload`/`V3` builders were removed with the v2/v3 formats (ADR 005/014); `verifyApprovalReceipt` rejects anything where `v !== 1`.
190
+ - `verificationCode(canonical)` → the short `XXXX-XXXX` code shown on the approval screen
191
+ - `verifyEcdsaP256(publicKeyB64, payload, signatureB64)` → `boolean`
192
+ - `verifyWebAuthnWitness({ signedPayload, publicKey, authenticatorData, clientDataJSON, signature }, { expectedOrigin, expectedRpId, requireUserVerification?, allowCrossOrigin? })` → `{ ok, reason? }` — the DIV §4.4.5 checks on ONE assertion (e.g. a single stored ledger witness), under a COSE or SPKI P-256 key you supply from your own records. It checks the signature binding only: no quorum, window, payload type or signed requirement — verify an approval with `verifyApprovalReceipt`.
193
+
194
+ > The canonicalization here is byte-for-byte identical to the INTYGA gateway, the approval UI, and
195
+ > `@intyga/mcp-schemas`. That identity is the whole point — don't reformat it.
196
+
197
+ ## DIV / DEWP conformance
198
+
199
+ This is the reference verifier with the broadest surface of the five ports. Beyond the **DEWP Core
200
+ Profile** ([`docs/DEWP.md`](../../docs/DEWP.md) §9.1) it implements single-anchor **and**
201
+ multi-anchor quorum verification (§5.2/§5.3, including `requiredAnchors`, issuer trust and
202
+ divergence detection), the §5.4 checkpoint continuity chain (`0x04` domain tag), proof-bundle
203
+ parsing with the §7.1 verification levels, evidence bundles, and gapless `tenantSeq` completeness
204
+ validation. All five ports also verify **agent-authority seals** (DIV §5b) and platform receipts
205
+ (DIV §5c), in addition to ordinary approval and delegation receipts.
206
+ Byte parity with the Go, Rust, Java and Python ports is locked by the shared golden vectors
207
+ in `packages/mcp-schemas/vectors/`.
208
+
209
+ It does **not** implement NDJSON evidence streaming (§6.4), so — like every port, this one
210
+ included — it does not claim the §9.2 **Extended Profile**. The narrower ports state their own
211
+ limits: [`verify-go`](../verify-go/README.md), [`verify-rust`](../verify-rust/README.md),
212
+ [`verify-java`](../verify-java/README.md), [`sdk-python`](../sdk-python/README.md).
213
+
214
+ Requires Node ≥18 (`node:crypto`).
215
+
216
+ Apache-2.0 licensed — see [`LICENSE`](./LICENSE).
217
+
218
+ ## RFC 3161 timestamps
219
+
220
+ All five verifier ports can count verified TSA evidence toward DEWP quorum. In Node, configure
221
+ `externalKeys.rfc3161` by issuer when calling `verifyAnchorQuorum`, `verifyBundle`, or
222
+ `verifyEvidenceBundle`:
223
+
224
+ ```ts
225
+ const externalKeys = {
226
+ rekor: pinnedRekorPublicKey,
227
+ rekorIssuer: "https://rekor.sigstore.dev",
228
+ rfc3161: {
229
+ "https://tsa.example": {
230
+ caPem: trustedCaPem,
231
+ signerCertificateSha256: pinnedSignerCertificateSha256,
232
+ revocation: "crl" as const,
233
+ crlPem: currentOfflineCrlPem,
234
+ },
235
+ },
236
+ }
237
+ ```
238
+
239
+ An external witness (Rekor, TSA) is time-bounded against the checkpoint's claimed time, and that time
240
+ must come from somewhere you trust (DEWP §5.3). Pass the chain-verified roots-file lines you hold as
241
+ `trustedCheckpoints` to `verifyEvidenceBundle` (a bundle checkpoint that contradicts one fails, and
242
+ anchors are held to your record), and the matching line as `trustedCheckpoint` to `verifyBundle`: a
243
+ single proof carries no checkpoint, so without it Rekor/TSA anchors do not count. An evidence
244
+ checkpoint with no `chainHash`/`anchoredAt` and no record never counts as anchored.
245
+
246
+ For policies trusting multiple issuers, `rekorIssuer` explicitly binds the log key to its issuer.
247
+ An unscoped legacy Rekor key is accepted only when the policy trusts exactly one issuer. A log
248
+ attests arbitrary submitted digests, so its key must not credit a different issuer name.
249
+
250
+ Obtain the CA and SHA-256 fingerprint of the DER TSA signer certificate independently of the bundle.
251
+ The certificate pin is scoped to its issuer: a CA capable of issuing certificates for several TSAs
252
+ is not itself proof of a particular TSA's identity. Rotate the pin deliberately when the TSA rotates
253
+ its certificate. Extra intermediate certificates can be supplied in `untrustedPem`.
254
+
255
+ `verifyRfc3161Anchor(anchor, trust)` also verifies individual tokens. It checks the SHA-256 imprint
256
+ over the raw DEWP anchor digest, CMS signature, signer pin, timestamping EKU and certificate chain.
257
+ CMS signer digests must be SHA-256, SHA-384 or SHA-512; SHA-1 and MD5 are refused.
258
+ `verificationTime` is optional Unix seconds; it defaults to the current time rounded up by less than
259
+ one second. The token must not claim a later time. Certificates must be valid both at that evaluation
260
+ time and at the authenticated TSA time. `revocation: "crl"` requires valid caller-supplied offline
261
+ CRLs for the chain; missing, stale or revoked evidence fails. `"unchecked"` is an explicit opt-out
262
+ and makes **no revocation assertion**. No CA, CRL, OCSP or intermediate is downloaded.
263
+
264
+ For historical validation, retain the certificates, applicable CRLs and the relying party's chosen
265
+ evaluation time. A past evaluation is an explicit historical claim, not proof of current validity;
266
+ this adapter does not implement archival evidence renewal or qualified-timestamp legal validation.
267
+ Timestamp evidence establishes that the commitment existed by the TSA time, not when its underlying
268
+ action occurred. The anchor's own timestamp remains producer-supplied data bound by the imprint.
269
+
270
+ The adapter invokes OpenSSL 3 without a shell, with private temporary files and a five-second limit
271
+ per command. `opensslPath` can select a caller-controlled executable. Without that runtime or the
272
+ required trust configuration, TSA evidence is reported as unverified and never counts toward quorum.
273
+ Applications should bound bundle sizes and run large offline audits away from request handlers.
274
+
275
+
276
+ ### Audit event signatures
277
+
278
+ The `trust.intyga.audit.v1` profile carries WebAuthn assertion data in the committed
279
+ `canonical.metadata.webauthn.authenticatorData` and `clientDataJSON` fields. Both single-proof and
280
+ bulk-evidence verification check these assertions when given caller-owned signer trust. This is a
281
+ signature over the exact `signedPayload`, not approval quorum, action authorization, hardware
282
+ attestation, current credential status or proof that the deploy executed. Verify the full DIV receipt
283
+ against the expected operation and approval policy for those authorization checks.
284
+
285
+ The per-event signature result distinguishes `verified`, `invalid`, `not_checked` (missing trust,
286
+ missing material or unsupported algorithm) and `not_applicable` (unsigned/system or AUTO_APPROVED).
287
+ A reason accompanies each status. `trusted: true` requires a valid signature under a caller-supplied
288
+ key mapped to that signer DID. WebAuthn requires caller-selected origin and RP ID, user presence and
289
+ user verification, and refuses cross-origin assertions. Supply COSE keys for WebAuthn and SPKI keys
290
+ for ES256. Multiple keys per DID support deliberate key rotation; the evidence's key is never added
291
+ to the caller's trusted set.
292
+
293
+ Without a signature policy, legacy ES256 checks still use the embedded key and report `trusted: false`;
294
+ WebAuthn reports `not_checked`. Diagnostic ledger validity does not imply signature validity. The
295
+ strict signature option requires **every selected entry** to have a verified, caller-trusted signature;
296
+ unsigned, redacted, incomplete and invalid entries fail that option. Anchor quorum is a separate policy.
297
+
298
+ Use `signaturePolicy: { trustedSigners: { [did]: [publicKey] }, expectedOrigin, expectedRpId }`
299
+ and `requireSignatures: true` in `verifyBundle` / `verifyEvidenceBundle`.
@@ -0,0 +1,37 @@
1
+ /** Pure policy resolution. No crypto, database, or network dependency. */
2
+ export interface PolicyRule {
3
+ actionPattern: string;
4
+ requiredApprovals: number;
5
+ approverDids: string[];
6
+ requireHardwareKey?: boolean;
7
+ allowedAaguids?: string[];
8
+ requesterCannotApprove?: boolean;
9
+ requireAttestedRequester?: boolean;
10
+ allowedIssuers?: string[];
11
+ approverGroupIds?: string[];
12
+ escalationApproverDids?: string[];
13
+ escalationGroupIds?: string[];
14
+ escalateAfterSeconds?: number | null;
15
+ autoApproveRequesterDid?: string | null;
16
+ autoApproveDayOfWeek?: number | null;
17
+ autoApproveWindowStart?: string | null;
18
+ autoApproveWindowEnd?: string | null;
19
+ }
20
+ export type UnmatchedActionPolicy = "DENY" | "OWNER_APPROVAL" | "BASELINE";
21
+ /** Version 3 uses stable action IDs; display text is never an authorization input. */
22
+ export declare function validApprovalActionId(value: string): boolean;
23
+ /** Validate the whole v3 policy so a corrupt/duplicate rule cannot be hidden by another action. */
24
+ export declare function validateExactApprovalPolicy(rules: PolicyRule[]): void;
25
+ export declare class ApprovalPolicyConflict extends Error {
26
+ readonly fields: string[];
27
+ constructor(fields: string[]);
28
+ }
29
+ export declare function ruleStrictness(r: PolicyRule): number;
30
+ export declare function ruleSelectionKey(r: PolicyRule): string;
31
+ export declare function matchingApprovalRules<T extends PolicyRule>(rules: T[], actionType: string | undefined, display: string, version?: 1 | 2 | 3): T[];
32
+ /** Empty eligible lists denote the same owner fallback, NOT unrestricted eligibility. */
33
+ export declare function lostApprovalConstraints(selected: PolicyRule, other: PolicyRule): string[];
34
+ /** Legacy ranking is accepted ONLY when the caller explicitly requests version 1. */
35
+ export declare function selectApprovalRule<T extends PolicyRule>(rules: T[], actionType: string | undefined, display: string, version?: 1 | 2 | 3, unmatched?: UnmatchedActionPolicy): T | undefined;
36
+ /** An empty list is owner-only. Distinct identities, not keys or duplicate entries, count. */
37
+ export declare function insufficientApprovalQuorum(rule: PolicyRule, requesterDid?: string): boolean;
@@ -0,0 +1,149 @@
1
+ const ACTION_ID = /^[A-Za-z][A-Za-z0-9]*(?:[._:/-][A-Za-z0-9]+)*$/;
2
+ /** Version 3 uses stable action IDs; display text is never an authorization input. */
3
+ export function validApprovalActionId(value) {
4
+ return value.length <= 200 && ACTION_ID.test(value);
5
+ }
6
+ /** Validate the whole v3 policy so a corrupt/duplicate rule cannot be hidden by another action. */
7
+ export function validateExactApprovalPolicy(rules) {
8
+ const seen = new Set();
9
+ for (const rule of rules) {
10
+ if ((rule.actionPattern !== "*" && !validApprovalActionId(rule.actionPattern)) ||
11
+ !Number.isSafeInteger(rule.requiredApprovals) ||
12
+ rule.requiredApprovals < 1 ||
13
+ seen.has(rule.actionPattern.toLowerCase()))
14
+ throw new ApprovalPolicyConflict(["invalidOrDuplicateActionId"]);
15
+ seen.add(rule.actionPattern.toLowerCase());
16
+ }
17
+ if (rules.length && !seen.has("*"))
18
+ throw new ApprovalPolicyConflict(["missingBaseline"]);
19
+ const baseline = rules.find((r) => r.actionPattern === "*");
20
+ if (!baseline)
21
+ return;
22
+ for (const rule of rules) {
23
+ const fields = lostApprovalConstraints(rule, baseline);
24
+ if (fields.length)
25
+ throw new ApprovalPolicyConflict(fields);
26
+ }
27
+ }
28
+ export class ApprovalPolicyConflict extends Error {
29
+ fields;
30
+ constructor(fields) {
31
+ super(`Conflicting approval requirements: ${fields.join(", ")}`);
32
+ this.name = "ApprovalPolicyConflict";
33
+ this.fields = fields;
34
+ }
35
+ }
36
+ export function ruleStrictness(r) {
37
+ return (Math.max(1, r.requiredApprovals) * 32 +
38
+ (r.requireHardwareKey ? 16 : 0) +
39
+ (r.requireAttestedRequester ? 8 : 0) +
40
+ (r.requesterCannotApprove ? 4 : 0) +
41
+ (r.allowedAaguids?.length ? 2 : 0) +
42
+ (r.allowedIssuers?.length ? 1 : 0));
43
+ }
44
+ export function ruleSelectionKey(r) {
45
+ return JSON.stringify([
46
+ r.actionPattern,
47
+ [...(r.allowedAaguids ?? [])].sort(),
48
+ [...(r.allowedIssuers ?? [])].sort(),
49
+ [...r.approverDids].sort(),
50
+ ]);
51
+ }
52
+ export function matchingApprovalRules(rules, actionType, display, version = 2) {
53
+ if (version === 3) {
54
+ if (!actionType || !validApprovalActionId(actionType))
55
+ return [];
56
+ return rules.filter((r) => r.actionPattern === "*" || r.actionPattern === actionType);
57
+ }
58
+ const labels = `${actionType ?? ""}\n${display}`.toLowerCase();
59
+ return rules.filter((r) => r.actionPattern === "*" || labels.includes(r.actionPattern.toLowerCase()));
60
+ }
61
+ function subset(a, b) {
62
+ return a.every((value) => b.includes(value));
63
+ }
64
+ function sameSet(a, b) {
65
+ return subset(a, b) && subset(b, a);
66
+ }
67
+ function windowKey(r) {
68
+ return JSON.stringify([
69
+ r.autoApproveRequesterDid ?? null,
70
+ r.autoApproveDayOfWeek ?? null,
71
+ r.autoApproveWindowStart ?? null,
72
+ r.autoApproveWindowEnd ?? null,
73
+ ]);
74
+ }
75
+ /** Empty eligible lists denote the same owner fallback, NOT unrestricted eligibility. */
76
+ export function lostApprovalConstraints(selected, other) {
77
+ const lost = [];
78
+ if (selected.requiredApprovals < other.requiredApprovals)
79
+ lost.push("requiredApprovals");
80
+ for (const field of ["requireHardwareKey", "requesterCannotApprove", "requireAttestedRequester"]) {
81
+ if (other[field] && !selected[field])
82
+ lost.push(field);
83
+ }
84
+ for (const field of ["allowedAaguids", "allowedIssuers"]) {
85
+ const a = selected[field] ?? [], b = other[field] ?? [];
86
+ if (b.length && (!a.length || !subset(a, b)))
87
+ lost.push(field);
88
+ }
89
+ // Different unresolved groups cannot be compared safely. DB callers expand them first.
90
+ if (!sameSet(selected.approverGroupIds ?? [], other.approverGroupIds ?? []))
91
+ lost.push("approverGroups");
92
+ const a = selected.approverDids, b = other.approverDids;
93
+ if ((a.length === 0) !== (b.length === 0) || !subset(a, b))
94
+ lost.push("approverDids");
95
+ // Escalation widens eligibility with time. Conservatively require the same schedule and added set.
96
+ if ((selected.escalateAfterSeconds ?? null) !== (other.escalateAfterSeconds ?? null) ||
97
+ !sameSet(selected.escalationApproverDids ?? [], other.escalationApproverDids ?? []) ||
98
+ !sameSet(selected.escalationGroupIds ?? [], other.escalationGroupIds ?? []))
99
+ lost.push("escalation");
100
+ if (selected.autoApproveRequesterDid || other.autoApproveRequesterDid) {
101
+ if (windowKey(selected) !== windowKey(other) ||
102
+ selected.requiredApprovals !== other.requiredApprovals ||
103
+ selected.requireHardwareKey !== other.requireHardwareKey ||
104
+ selected.requesterCannotApprove !== other.requesterCannotApprove ||
105
+ selected.requireAttestedRequester !== other.requireAttestedRequester ||
106
+ !sameSet(a, b) ||
107
+ !sameSet(selected.allowedAaguids ?? [], other.allowedAaguids ?? []) ||
108
+ !sameSet(selected.allowedIssuers ?? [], other.allowedIssuers ?? []))
109
+ lost.push("autoApproval");
110
+ }
111
+ return lost;
112
+ }
113
+ /** Legacy ranking is accepted ONLY when the caller explicitly requests version 1. */
114
+ export function selectApprovalRule(rules, actionType, display, version = 2, unmatched = "DENY") {
115
+ if (version === 3) {
116
+ validateExactApprovalPolicy(rules);
117
+ if (unmatched === "OWNER_APPROVAL")
118
+ throw new ApprovalPolicyConflict(["invalidFallback"]);
119
+ if (!actionType || !validApprovalActionId(actionType))
120
+ return undefined;
121
+ // IDs remain case-sensitive on the wire, but a differently cased spelling of a protected ID
122
+ // must not drop to a weaker baseline. Require the caller to use the configured spelling.
123
+ if (rules.some((r) => r.actionPattern !== "*" &&
124
+ r.actionPattern !== actionType &&
125
+ r.actionPattern.toLowerCase() === actionType.toLowerCase()))
126
+ throw new ApprovalPolicyConflict(["actionIdCaseMismatch"]);
127
+ return (rules.find((r) => r.actionPattern === actionType) ??
128
+ (unmatched === "BASELINE" ? rules.find((r) => r.actionPattern === "*") : undefined));
129
+ }
130
+ const matches = matchingApprovalRules(rules, actionType, display, version);
131
+ const selected = [...matches].sort((a, b) => ruleStrictness(b) - ruleStrictness(a) ||
132
+ (ruleSelectionKey(a) < ruleSelectionKey(b) ? -1 : ruleSelectionKey(a) > ruleSelectionKey(b) ? 1 : 0))[0];
133
+ if (!selected || version === 1)
134
+ return selected;
135
+ const invalid = matches.some((r) => !r.actionPattern.trim() || !Number.isSafeInteger(r.requiredApprovals) || r.requiredApprovals < 1);
136
+ if (invalid)
137
+ throw new ApprovalPolicyConflict(["invalidRule"]);
138
+ const fields = [...new Set(matches.flatMap((r) => lostApprovalConstraints(selected, r)))].sort();
139
+ if (fields.length)
140
+ throw new ApprovalPolicyConflict(fields);
141
+ return selected;
142
+ }
143
+ /** An empty list is owner-only. Distinct identities, not keys or duplicate entries, count. */
144
+ export function insufficientApprovalQuorum(rule, requesterDid) {
145
+ if (!rule.approverDids.length)
146
+ return rule.requiredApprovals > 1;
147
+ const eligible = new Set(rule.approverDids.filter((did) => !rule.requesterCannotApprove || did !== requesterDid));
148
+ return eligible.size < rule.requiredApprovals;
149
+ }