@kya-brasil/core 0.12.0 → 0.14.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 +22 -8
- package/dist/challenge.d.ts +11 -1
- package/dist/challenge.js +25 -4
- package/dist/credential.d.ts +14 -0
- package/dist/credential.js +20 -2
- package/dist/freshness.d.ts +50 -19
- package/dist/freshness.js +147 -85
- package/dist/person.d.ts +3 -1
- package/dist/person.js +5 -2
- package/dist/revocation.d.ts +7 -1
- package/dist/revocation.js +8 -2
- package/dist/types.d.ts +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,10 +25,13 @@ A credential from any KYA issuer (the hosted one, yours, a partner's) is checked
|
|
|
25
25
|
published at `<issuer>/.well-known/kya.json`.
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
import { verifyCredentialSignature, effectiveStatus, checkAction, hashPaymentRef } from "@kya-brasil/core";
|
|
28
|
+
import { verifyCredentialSignature, verifyMandateAuthorization, effectiveStatus, checkAction, hashPaymentRef } from "@kya-brasil/core";
|
|
29
29
|
|
|
30
30
|
const sig = await verifyCredentialSignature(credential, issuerPublicJwk); // { valid, key_id, signed_at }
|
|
31
31
|
if (!sig.valid || effectiveStatus(credential) !== "active") deny();
|
|
32
|
+
// D-33: the e-CNPJ / e-CPF signature covers the mandate when the credential carries its hash.
|
|
33
|
+
const auth = await verifyMandateAuthorization(credential); // "signed" | "mismatch" | "not_covered" (issued before D-33)
|
|
34
|
+
if (auth === "mismatch") deny(); // decide yourself whether "not_covered" (issuer attestation only) is enough
|
|
32
35
|
// For live status (revoked or suspended after issuance) read credential.verify_url, or accept that risk until expires_at.
|
|
33
36
|
|
|
34
37
|
const verdict = checkAction(credential.mandate, {
|
|
@@ -63,29 +66,40 @@ the text the delegator signs with its agent key; `mandateSubsetProblems(parent,
|
|
|
63
66
|
Offline, a verifier only has status evidence of some age: an answer the issuer signed (the credential's status) or a
|
|
64
67
|
CRL (the certificate's). How old is acceptable is the verifier's call, because the verifier runs the risk (SPEC
|
|
65
68
|
section 18). Declare the expected loss you accept per action (`L`) and the revocation rate you assume (`λ`, per hour);
|
|
66
|
-
the maximum age is `L / (amount × λ)`, capped
|
|
69
|
+
the maximum age is `L / (amount × λ)`, capped by your ceiling. The library holds no business values: every
|
|
70
|
+
parameter below is required, and so is the clock.
|
|
67
71
|
|
|
68
72
|
```ts
|
|
69
73
|
import { freshnessPolicy, checkFreshAction, evidenceFromSignedStatus, evidenceFromRevocation } from "@kya-brasil/core";
|
|
70
74
|
|
|
71
75
|
const policy = freshnessPolicy({
|
|
72
76
|
max_expected_loss: { amount: 1, currency: "BRL" }, // L: your tolerance, per action
|
|
73
|
-
revocation_rate_per_hour: { credential: lambda, certificate: mu }, // λ: what you assume
|
|
77
|
+
revocation_rate_per_hour: { credential: lambda, certificate: mu }, // λ: what you assume
|
|
74
78
|
require: ["credential"], // add "certificate" to also require a CRL
|
|
75
|
-
|
|
79
|
+
max_age_seconds: ceiling, // your ceiling, whatever the value
|
|
80
|
+
max_clock_skew_seconds: skew, // tolerance for evidence dated in the future
|
|
81
|
+
unvalued_action: "refuse", // or "ceiling": actions of unknown value
|
|
82
|
+
past_valid_until: "refuse", // or "accept": a CRL past nextUpdate, judged by age
|
|
83
|
+
// action_risk_value: { sign_contract: 100_000 }, max_age: ({ amount, kind, action }) => seconds
|
|
76
84
|
});
|
|
77
85
|
|
|
78
86
|
// The agent may staple a recent signed verdict to its request: then no network is needed.
|
|
79
87
|
const evidence = [await evidenceFromSignedStatus(stapledVerdict, issuerPublicJwk, credential.credential_id)];
|
|
80
|
-
// evidence.push(evidenceFromRevocation(await checkRevocation({ crlDer, serial, issuerCertificate })));
|
|
88
|
+
// evidence.push(evidenceFromRevocation(await checkRevocation({ crlDer, serial, issuerCertificate, pastNextUpdate: "accept" })));
|
|
81
89
|
|
|
82
|
-
const r = checkFreshAction(credential.mandate, { action: "buy", amount: 4200, currency: "BRL" }, { policy, evidence });
|
|
90
|
+
const r = checkFreshAction(credential.mandate, { action: "buy", amount: 4200, currency: "BRL", now: trustedClock() }, { policy, evidence });
|
|
83
91
|
// r.allowed; r.freshness.refresh = kinds to fetch again (or ask the agent for); r.freshness.applied = policy and
|
|
84
92
|
// clock used, keep it with the verdict as the risk you accepted
|
|
85
93
|
```
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
Actions without an amount (signing, reading data, calling an API) take the risk value you assign them:
|
|
96
|
+
`action_risk_value: { sign_contract: 100_000, read_data: 50 }` in the policy's currency; an action with neither an
|
|
97
|
+
amount nor a risk value is refused or gets the ceiling, as `unvalued_action` says. A custom `max_age` also receives the action.
|
|
98
|
+
The daily ceiling (`max_per_day` with your own `spent_today`) is what bounds a stolen key inside the window; the
|
|
99
|
+
counting, and any stricter ceiling of your own, are yours.
|
|
100
|
+
|
|
101
|
+
It fails closed: no evidence, another currency, an invalid amount, a missing or invalid clock, evidence dated in the
|
|
102
|
+
future beyond your skew, or a CRL past its `nextUpdate` when `past_valid_until` is `"refuse"`. The freshest statement wins, so a later revocation beats an earlier "active".
|
|
89
103
|
`checkFreshness` and `maxStatusAgeSeconds` are available on their own.
|
|
90
104
|
|
|
91
105
|
## Payment instruments (card networks, acquirers, issuers)
|
package/dist/challenge.d.ts
CHANGED
|
@@ -9,20 +9,30 @@ export declare function normalizeAgentKey(input: {
|
|
|
9
9
|
kind?: unknown;
|
|
10
10
|
id?: unknown;
|
|
11
11
|
}): AgentKey;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The exact text the agent and the e-CNPJ sign. Line-based so wallets show it readably. Since D-33 it carries
|
|
14
|
+
* the mandate's SHA-256, so the certificate signature covers the powers granted, not only the binding.
|
|
15
|
+
*/
|
|
13
16
|
export declare function challengeText(p: {
|
|
14
17
|
issuer: string;
|
|
15
18
|
challenge_id: string;
|
|
16
19
|
agent: AgentKey;
|
|
17
20
|
cnpj: string;
|
|
21
|
+
mandate_sha256: string;
|
|
18
22
|
expires_at: string;
|
|
19
23
|
}): string;
|
|
20
24
|
export declare function newChallenge(p: {
|
|
21
25
|
issuer: string;
|
|
22
26
|
agent: AgentKey;
|
|
23
27
|
cnpj: string;
|
|
28
|
+
mandate_sha256: string;
|
|
24
29
|
now?: Date;
|
|
25
30
|
}): Challenge;
|
|
31
|
+
/**
|
|
32
|
+
* The mandate SHA-256 a signed challenge text commits to (its `mandate-sha256:` line), or null when the text
|
|
33
|
+
* has none (a challenge from before D-33). The issuer compares it with the mandate it is about to issue.
|
|
34
|
+
*/
|
|
35
|
+
export declare function mandateSha256OfChallenge(text: string): string | null;
|
|
26
36
|
/** True when `signature` is the agent key's signature over `text`. Never throws on a bad signature. */
|
|
27
37
|
export declare function verifyAgentSignature(agent: AgentKey, text: string, signature: string): Promise<boolean>;
|
|
28
38
|
export declare const base64url: {
|
package/dist/challenge.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* evm EIP-191 personal_sign over the challenge text; address recovered and compared
|
|
5
5
|
* web-bot-auth Ed25519 (RFC 9421 key material) raw signature over the challenge bytes
|
|
6
6
|
* erc8004 EIP-191 by the agent's on-chain agentWallet (resolved by the caller, see erc8004.ts)
|
|
7
|
-
* The challenge text binds agent key, CNPJ, issuer and
|
|
8
|
-
* for another company or
|
|
7
|
+
* The challenge text binds agent key, CNPJ, issuer, expiry and the mandate (its SHA-256, D-33), so a
|
|
8
|
+
* signature cannot be replayed for another company, another issuer or other powers.
|
|
9
9
|
*/
|
|
10
10
|
import { verifyMessage, getAddress, isAddress } from "viem";
|
|
11
11
|
import { KyaError } from "./types.js";
|
|
@@ -49,7 +49,16 @@ export function normalizeAgentKey(input) {
|
|
|
49
49
|
}
|
|
50
50
|
throw new KyaError("INVALID_INPUT", "agent.kind must be evm, web-bot-auth or erc8004");
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
53
|
+
function requireMandateSha256(h) {
|
|
54
|
+
if (typeof h !== "string" || !HEX64.test(h))
|
|
55
|
+
throw new KyaError("INVALID_INPUT", "mandate_sha256 must be 64 lowercase hex characters (see mandateSha256)");
|
|
56
|
+
return h;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The exact text the agent and the e-CNPJ sign. Line-based so wallets show it readably. Since D-33 it carries
|
|
60
|
+
* the mandate's SHA-256, so the certificate signature covers the powers granted, not only the binding.
|
|
61
|
+
*/
|
|
53
62
|
export function challengeText(p) {
|
|
54
63
|
return [
|
|
55
64
|
"KYA Brasil: prove this agent key acts for a company",
|
|
@@ -57,6 +66,7 @@ export function challengeText(p) {
|
|
|
57
66
|
`challenge: ${p.challenge_id}`,
|
|
58
67
|
`agent: ${p.agent.kind}:${p.agent.id}`,
|
|
59
68
|
`cnpj: ${p.cnpj}`,
|
|
69
|
+
`mandate-sha256: ${requireMandateSha256(p.mandate_sha256)}`,
|
|
60
70
|
`expires: ${p.expires_at}`
|
|
61
71
|
].join("\n");
|
|
62
72
|
}
|
|
@@ -64,7 +74,18 @@ export function newChallenge(p) {
|
|
|
64
74
|
const now = p.now ?? new Date();
|
|
65
75
|
const challenge_id = crypto.randomUUID();
|
|
66
76
|
const expires_at = new Date(now.getTime() + CHALLENGE_TTL_SECONDS * 1000).toISOString();
|
|
67
|
-
return { challenge_id, expires_at, text: challengeText({ issuer: p.issuer, challenge_id, agent: p.agent, cnpj: p.cnpj, expires_at }) };
|
|
77
|
+
return { challenge_id, expires_at, text: challengeText({ issuer: p.issuer, challenge_id, agent: p.agent, cnpj: p.cnpj, mandate_sha256: p.mandate_sha256, expires_at }) };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The mandate SHA-256 a signed challenge text commits to (its `mandate-sha256:` line), or null when the text
|
|
81
|
+
* has none (a challenge from before D-33). The issuer compares it with the mandate it is about to issue.
|
|
82
|
+
*/
|
|
83
|
+
export function mandateSha256OfChallenge(text) {
|
|
84
|
+
const lines = text.split("\n").filter((l) => l.startsWith("mandate-sha256: "));
|
|
85
|
+
if (lines.length !== 1)
|
|
86
|
+
return null;
|
|
87
|
+
const h = lines[0].slice("mandate-sha256: ".length);
|
|
88
|
+
return HEX64.test(h) ? h : null;
|
|
68
89
|
}
|
|
69
90
|
/** True when `signature` is the agent key's signature over `text`. Never throws on a bad signature. */
|
|
70
91
|
export async function verifyAgentSignature(agent, text, signature) {
|
package/dist/credential.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare function buildCredential(p: {
|
|
|
13
13
|
signed_at: string;
|
|
14
14
|
};
|
|
15
15
|
mandate: Mandate;
|
|
16
|
+
/** D-33: the mandate SHA-256 the signed challenge carried (mandateSha256OfChallenge). */
|
|
17
|
+
mandate_sha256?: string;
|
|
16
18
|
/** /v1 compatibility only; leave out (null). */
|
|
17
19
|
kyb?: KybSnapshot | null;
|
|
18
20
|
issuer: string;
|
|
@@ -30,6 +32,8 @@ export declare function buildPersonCredential(p: {
|
|
|
30
32
|
signed_at: string;
|
|
31
33
|
};
|
|
32
34
|
mandate: Mandate;
|
|
35
|
+
/** D-33: the mandate SHA-256 the signed challenge carried (mandateSha256OfChallenge). */
|
|
36
|
+
mandate_sha256?: string;
|
|
33
37
|
issuer: string;
|
|
34
38
|
now?: Date;
|
|
35
39
|
}): Credential;
|
|
@@ -54,3 +58,13 @@ export declare function principalDecision(registration_status: string): {
|
|
|
54
58
|
reason: string | null;
|
|
55
59
|
};
|
|
56
60
|
export declare function effectiveStatus(c: Pick<Credential, "status" | "expires_at">, now?: Date): CredentialStatus;
|
|
61
|
+
/** D-33: SHA-256 (lowercase hex) of the canonical JSON (section 7) of a mandate; what the challenge commits to. */
|
|
62
|
+
export declare function mandateSha256(mandate: Mandate): Promise<string>;
|
|
63
|
+
/**
|
|
64
|
+
* Offline: does the ICP-Brasil signature behind this credential cover its mandate (D-33)?
|
|
65
|
+
* "signed" authorized_by.mandate_sha256 equals the SHA-256 of credential.mandate;
|
|
66
|
+
* "mismatch" it does not: the mandate is not the one the principal signed (refuse the credential);
|
|
67
|
+
* "not_covered" the credential predates D-33 and carries no hash: the mandate rests on the issuer's attestation.
|
|
68
|
+
* The certificate signature itself is verified by the issuer at issuance; the signed .p7s stays with the principal.
|
|
69
|
+
*/
|
|
70
|
+
export declare function verifyMandateAuthorization(c: Pick<Credential, "mandate" | "authorized_by">): Promise<"signed" | "mismatch" | "not_covered">;
|
package/dist/credential.js
CHANGED
|
@@ -17,7 +17,7 @@ export function buildCredential(p) {
|
|
|
17
17
|
version: 1,
|
|
18
18
|
agent: p.agent,
|
|
19
19
|
principal: { cnpj, cnpj_formatted: formatCnpj(cnpj), legal_name: p.legal_name, registration_status: p.registration_status },
|
|
20
|
-
authorized_by: { method: "icp-brasil-e-cnpj", certificate_sha256: p.certificate.sha256, certificate_subject: p.certificate.subject, issuer: p.certificate.issuer, signed_at: p.certificate.signed_at },
|
|
20
|
+
authorized_by: { method: "icp-brasil-e-cnpj", certificate_sha256: p.certificate.sha256, certificate_subject: p.certificate.subject, issuer: p.certificate.issuer, signed_at: p.certificate.signed_at, ...(p.mandate_sha256 ? { mandate_sha256: p.mandate_sha256 } : {}) },
|
|
21
21
|
mandate: p.mandate,
|
|
22
22
|
kyb_snapshot: p.kyb ?? null,
|
|
23
23
|
status: p.status ?? "active",
|
|
@@ -40,7 +40,7 @@ export function buildPersonCredential(p) {
|
|
|
40
40
|
version: 1,
|
|
41
41
|
agent: p.agent,
|
|
42
42
|
principal: { kind: "person", verified_by: "icp-brasil-e-cpf" },
|
|
43
|
-
authorized_by: { method: "icp-brasil-e-cpf", certificate_sha256: p.certificate.sha256, issuer: p.certificate.issuer, signed_at: p.certificate.signed_at },
|
|
43
|
+
authorized_by: { method: "icp-brasil-e-cpf", certificate_sha256: p.certificate.sha256, issuer: p.certificate.issuer, signed_at: p.certificate.signed_at, ...(p.mandate_sha256 ? { mandate_sha256: p.mandate_sha256 } : {}) },
|
|
44
44
|
mandate: p.mandate,
|
|
45
45
|
kyb_snapshot: null,
|
|
46
46
|
status: "active",
|
|
@@ -86,3 +86,21 @@ export function effectiveStatus(c, now = new Date()) {
|
|
|
86
86
|
return "expired";
|
|
87
87
|
return c.status;
|
|
88
88
|
}
|
|
89
|
+
/** D-33: SHA-256 (lowercase hex) of the canonical JSON (section 7) of a mandate; what the challenge commits to. */
|
|
90
|
+
export async function mandateSha256(mandate) {
|
|
91
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalize(mandate)));
|
|
92
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Offline: does the ICP-Brasil signature behind this credential cover its mandate (D-33)?
|
|
96
|
+
* "signed" authorized_by.mandate_sha256 equals the SHA-256 of credential.mandate;
|
|
97
|
+
* "mismatch" it does not: the mandate is not the one the principal signed (refuse the credential);
|
|
98
|
+
* "not_covered" the credential predates D-33 and carries no hash: the mandate rests on the issuer's attestation.
|
|
99
|
+
* The certificate signature itself is verified by the issuer at issuance; the signed .p7s stays with the principal.
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyMandateAuthorization(c) {
|
|
102
|
+
const h = c.authorized_by?.mandate_sha256;
|
|
103
|
+
if (!h)
|
|
104
|
+
return "not_covered";
|
|
105
|
+
return (await mandateSha256(c.mandate)) === h ? "signed" : "mismatch";
|
|
106
|
+
}
|
package/dist/freshness.d.ts
CHANGED
|
@@ -16,9 +16,6 @@ export interface StatusEvidence {
|
|
|
16
16
|
/** Status as stated by the evidence, for the verdict ("active", "revoked", "certificateHold", ...). */
|
|
17
17
|
status?: string;
|
|
18
18
|
}
|
|
19
|
-
export declare const FRESHNESS_DEFAULT_MAX_AGE_SECONDS = 86400;
|
|
20
|
-
export declare const FRESHNESS_LIMIT_MAX_AGE_SECONDS: number;
|
|
21
|
-
export declare const FRESHNESS_DEFAULT_CLOCK_SKEW_SECONDS = 300;
|
|
22
19
|
export interface FreshnessPolicyInput {
|
|
23
20
|
/** L: the expected loss the verifier accepts per action, in the currency of the amounts it checks. */
|
|
24
21
|
max_expected_loss: {
|
|
@@ -27,20 +24,37 @@ export interface FreshnessPolicyInput {
|
|
|
27
24
|
};
|
|
28
25
|
/** λ: probability per hour that a subject in force stops being so, per kind of evidence. */
|
|
29
26
|
revocation_rate_per_hour: Partial<Record<EvidenceKind, number>>;
|
|
30
|
-
/** Kinds of evidence an action needs
|
|
31
|
-
require
|
|
32
|
-
/** Ceiling on the evidence age, whatever the
|
|
33
|
-
max_age_seconds
|
|
34
|
-
/** Tolerance for evidence dated after the verifier's clock.
|
|
35
|
-
max_clock_skew_seconds
|
|
27
|
+
/** Kinds of evidence an action needs: one or more of "credential", "certificate". */
|
|
28
|
+
require: EvidenceKind[];
|
|
29
|
+
/** Ceiling on the evidence age, in seconds, whatever the value at risk. */
|
|
30
|
+
max_age_seconds: number;
|
|
31
|
+
/** Tolerance, in seconds, for evidence dated after the verifier's clock. */
|
|
32
|
+
max_clock_skew_seconds: number;
|
|
36
33
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* What to do with an action that has neither an amount nor a risk value in `action_risk_value`:
|
|
35
|
+
* "refuse" fails the check; "ceiling" accepts evidence up to `max_age_seconds`.
|
|
36
|
+
*/
|
|
37
|
+
unvalued_action: "refuse" | "ceiling";
|
|
38
|
+
/**
|
|
39
|
+
* What to do with evidence past its own `valid_until` (a CRL past nextUpdate): "refuse" fails the check;
|
|
40
|
+
* "accept" lets its age alone decide, as Li and Feigenbaum (FC 2001) propose.
|
|
41
|
+
*/
|
|
42
|
+
past_valid_until: "refuse" | "accept";
|
|
43
|
+
/**
|
|
44
|
+
* Value at risk, in the policy's currency, of actions that carry no amount (`sign`, `read`, `call`...),
|
|
45
|
+
* keyed by action name. Used only when the action has no amount; see `unvalued_action` for the rest.
|
|
46
|
+
*/
|
|
47
|
+
action_risk_value?: Record<string, number>;
|
|
48
|
+
/**
|
|
49
|
+
* Replaces the default formula. Receives the value at risk (the amount, or the action's risk value; undefined
|
|
50
|
+
* when there is neither), the action and the kind; returns seconds. The result is clamped to
|
|
51
|
+
* [0, max_age_seconds]; anything not finite counts as 0.
|
|
39
52
|
*/
|
|
40
53
|
max_age?: (p: {
|
|
41
54
|
amount: number | undefined;
|
|
42
55
|
currency: string;
|
|
43
56
|
kind: EvidenceKind;
|
|
57
|
+
action?: string;
|
|
44
58
|
}) => number;
|
|
45
59
|
}
|
|
46
60
|
export interface FreshnessPolicy {
|
|
@@ -52,12 +66,20 @@ export interface FreshnessPolicy {
|
|
|
52
66
|
require: EvidenceKind[];
|
|
53
67
|
max_age_seconds: number;
|
|
54
68
|
max_clock_skew_seconds: number;
|
|
69
|
+
unvalued_action: "refuse" | "ceiling";
|
|
70
|
+
past_valid_until: "refuse" | "accept";
|
|
71
|
+
action_risk_value: Record<string, number>;
|
|
55
72
|
max_age?: FreshnessPolicyInput["max_age"];
|
|
56
73
|
}
|
|
57
74
|
/** Validates and normalizes a policy. Throws INVALID_INPUT on anything that would make it fail open. */
|
|
58
75
|
export declare function freshnessPolicy(input: FreshnessPolicyInput): FreshnessPolicy;
|
|
59
|
-
/**
|
|
60
|
-
export declare function
|
|
76
|
+
/** The value at risk of an action: its amount, or else the risk value the policy gives its name. */
|
|
77
|
+
export declare function valueAtRisk(policy: FreshnessPolicy, amount?: number, action?: string): number | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Maximum evidence age, in whole seconds, for an action of `amount` (in the policy's currency) or, with no
|
|
80
|
+
* amount, of the risk value the policy assigns to `action`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function maxStatusAgeSeconds(policy: FreshnessPolicy, kind: EvidenceKind, amount?: number, action?: string): number;
|
|
61
83
|
export interface FreshnessCheck {
|
|
62
84
|
rule: "freshness";
|
|
63
85
|
kind: EvidenceKind;
|
|
@@ -74,7 +96,10 @@ export interface FreshnessVerdict {
|
|
|
74
96
|
/** The policy and clock actually applied, to keep with the verdict as proof of the risk accepted. */
|
|
75
97
|
applied: {
|
|
76
98
|
now: string;
|
|
99
|
+
action: string | null;
|
|
77
100
|
amount: number | null;
|
|
101
|
+
/** The amount, or the risk value assigned to the action; null when there was neither (ceiling applied). */
|
|
102
|
+
value_at_risk: number | null;
|
|
78
103
|
currency: string;
|
|
79
104
|
max_expected_loss: {
|
|
80
105
|
amount: number;
|
|
@@ -83,24 +108,30 @@ export interface FreshnessVerdict {
|
|
|
83
108
|
revocation_rate_per_hour: Partial<Record<EvidenceKind, number>>;
|
|
84
109
|
max_age_seconds: number;
|
|
85
110
|
max_clock_skew_seconds: number;
|
|
111
|
+
unvalued_action: "refuse" | "ceiling";
|
|
112
|
+
past_valid_until: "refuse" | "accept";
|
|
86
113
|
custom_max_age: boolean;
|
|
87
114
|
};
|
|
88
115
|
}
|
|
89
116
|
/**
|
|
90
117
|
* Checks that, for every kind the policy requires, the freshest evidence given says "in force", is not
|
|
91
|
-
* past its own validity, is not dated in the future beyond the clock skew,
|
|
92
|
-
* age for this
|
|
118
|
+
* past its own validity (unless the policy accepts that), is not dated in the future beyond the clock skew,
|
|
119
|
+
* and is younger than the maximum age for this value. Fails closed: missing, malformed or foreign-currency
|
|
120
|
+
* input never passes.
|
|
93
121
|
*/
|
|
94
|
-
export declare function checkFreshness(policy: FreshnessPolicy, evidence: StatusEvidence[], q
|
|
122
|
+
export declare function checkFreshness(policy: FreshnessPolicy, evidence: StatusEvidence[], q: {
|
|
123
|
+
action?: string;
|
|
95
124
|
amount?: number;
|
|
96
125
|
currency?: string;
|
|
97
|
-
now
|
|
126
|
+
now: Date;
|
|
98
127
|
}): FreshnessVerdict;
|
|
99
128
|
/**
|
|
100
129
|
* The mandate (checkAction) and the freshness of the status, in one answer: allowed only when both pass.
|
|
101
|
-
* The freshness check uses the action's amount and currency and the same clock.
|
|
130
|
+
* The freshness check uses the action's name, amount and currency and the same clock.
|
|
102
131
|
*/
|
|
103
|
-
export declare function checkFreshAction(mandate: Mandate, q: ActionQuery
|
|
132
|
+
export declare function checkFreshAction(mandate: Mandate, q: ActionQuery & {
|
|
133
|
+
now: Date;
|
|
134
|
+
}, p: {
|
|
104
135
|
policy: FreshnessPolicy;
|
|
105
136
|
evidence: StatusEvidence[];
|
|
106
137
|
}): {
|
package/dist/freshness.js
CHANGED
|
@@ -12,17 +12,19 @@
|
|
|
12
12
|
*
|
|
13
13
|
* max_age(v) = L / (v × λ) hours, capped by max_age_seconds.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Any action, not only payments: for an action with no amount (sign a document, read data, call an API) the
|
|
16
|
+
* verifier assigns a risk value per action (`action_risk_value`), or decides the age itself (`max_age`).
|
|
17
|
+
*
|
|
18
|
+
* Pure functions, no network, no business defaults: every value that expresses a risk decision (loss
|
|
19
|
+
* tolerance, rates, ceiling, clock skew, kinds required, what to do with an action of unknown value, the clock
|
|
20
|
+
* itself) is the caller's and must be passed. The module only validates that the values are well formed.
|
|
17
21
|
*/
|
|
18
22
|
import { verifyAttestation } from "./attestation.js";
|
|
19
23
|
import { checkAction } from "./mandate.js";
|
|
20
24
|
import { KyaError } from "./types.js";
|
|
21
|
-
export const FRESHNESS_DEFAULT_MAX_AGE_SECONDS = 86_400;
|
|
22
|
-
export const FRESHNESS_LIMIT_MAX_AGE_SECONDS = 90 * 86_400;
|
|
23
|
-
export const FRESHNESS_DEFAULT_CLOCK_SKEW_SECONDS = 300;
|
|
24
25
|
const KINDS = ["credential", "certificate"];
|
|
25
26
|
const CURRENCY = /^[A-Z0-9]{2,10}$/;
|
|
27
|
+
const ACTION = /^[a-z][a-z0-9_-]{1,31}$/;
|
|
26
28
|
function bad(message) {
|
|
27
29
|
throw new KyaError("INVALID_INPUT", `freshness policy: ${message}`);
|
|
28
30
|
}
|
|
@@ -36,7 +38,7 @@ export function freshnessPolicy(input) {
|
|
|
36
38
|
const currency = typeof loss.currency === "string" ? loss.currency.trim().toUpperCase() : "";
|
|
37
39
|
if (!CURRENCY.test(currency))
|
|
38
40
|
bad("max_expected_loss.currency must match ^[A-Z0-9]{2,10}$");
|
|
39
|
-
const require = input.require
|
|
41
|
+
const require = input.require;
|
|
40
42
|
if (!Array.isArray(require) || !require.length || require.some((k) => !KINDS.includes(k)))
|
|
41
43
|
bad(`require must list one or more of ${KINDS.join(", ")}`);
|
|
42
44
|
const rates = {};
|
|
@@ -51,28 +53,57 @@ export function freshnessPolicy(input) {
|
|
|
51
53
|
for (const k of require)
|
|
52
54
|
if (rates[k] === undefined)
|
|
53
55
|
bad(`revocation_rate_per_hour.${k} is required (or give max_age)`);
|
|
54
|
-
const ceiling = input.max_age_seconds
|
|
55
|
-
if (!Number.
|
|
56
|
-
bad(
|
|
57
|
-
const skew = input.max_clock_skew_seconds
|
|
58
|
-
if (!Number.
|
|
59
|
-
bad("max_clock_skew_seconds
|
|
56
|
+
const ceiling = input.max_age_seconds;
|
|
57
|
+
if (!Number.isSafeInteger(ceiling) || ceiling < 0)
|
|
58
|
+
bad("max_age_seconds is required: an integer >= 0");
|
|
59
|
+
const skew = input.max_clock_skew_seconds;
|
|
60
|
+
if (!Number.isSafeInteger(skew) || skew < 0)
|
|
61
|
+
bad("max_clock_skew_seconds is required: an integer >= 0");
|
|
62
|
+
const unvalued = input.unvalued_action;
|
|
63
|
+
if (unvalued !== "refuse" && unvalued !== "ceiling")
|
|
64
|
+
bad('unvalued_action is required: "refuse" or "ceiling"');
|
|
65
|
+
const pastValid = input.past_valid_until;
|
|
66
|
+
if (pastValid !== "refuse" && pastValid !== "accept")
|
|
67
|
+
bad('past_valid_until is required: "refuse" or "accept"');
|
|
60
68
|
if (input.max_age !== undefined && typeof input.max_age !== "function")
|
|
61
69
|
bad("max_age must be a function");
|
|
62
|
-
|
|
70
|
+
const values = {};
|
|
71
|
+
if (input.action_risk_value !== undefined) {
|
|
72
|
+
if (!input.action_risk_value || typeof input.action_risk_value !== "object" || Array.isArray(input.action_risk_value))
|
|
73
|
+
bad("action_risk_value must be an object of action -> value");
|
|
74
|
+
for (const [a, v] of Object.entries(input.action_risk_value)) {
|
|
75
|
+
if (!ACTION.test(a))
|
|
76
|
+
bad(`action_risk_value: "${a}" is not a mandate action name (^[a-z][a-z0-9_-]{1,31}$)`);
|
|
77
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
|
|
78
|
+
bad(`action_risk_value.${a} must be a finite number >= 0`);
|
|
79
|
+
values[a] = v;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { max_expected_loss: { amount: loss.amount, currency }, revocation_rate_per_hour: rates, require: [...new Set(require)], max_age_seconds: ceiling, max_clock_skew_seconds: skew, unvalued_action: unvalued, past_valid_until: pastValid, action_risk_value: values, max_age: input.max_age };
|
|
83
|
+
}
|
|
84
|
+
/** The value at risk of an action: its amount, or else the risk value the policy gives its name. */
|
|
85
|
+
export function valueAtRisk(policy, amount, action) {
|
|
86
|
+
if (amount !== undefined)
|
|
87
|
+
return amount;
|
|
88
|
+
const a = action?.trim().toLowerCase();
|
|
89
|
+
return a && Object.hasOwn(policy.action_risk_value, a) ? policy.action_risk_value[a] : undefined;
|
|
63
90
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Maximum evidence age, in whole seconds, for an action of `amount` (in the policy's currency) or, with no
|
|
93
|
+
* amount, of the risk value the policy assigns to `action`.
|
|
94
|
+
*/
|
|
95
|
+
export function maxStatusAgeSeconds(policy, kind, amount, action) {
|
|
66
96
|
const ceiling = policy.max_age_seconds;
|
|
97
|
+
const value = valueAtRisk(policy, amount, action);
|
|
67
98
|
let seconds;
|
|
68
99
|
if (policy.max_age) {
|
|
69
|
-
seconds = policy.max_age({ amount, currency: policy.max_expected_loss.currency, kind });
|
|
100
|
+
seconds = policy.max_age({ amount: value, currency: policy.max_expected_loss.currency, kind, action: action?.trim().toLowerCase() || undefined });
|
|
70
101
|
}
|
|
71
102
|
else {
|
|
72
103
|
const rate = policy.revocation_rate_per_hour[kind] ?? 0;
|
|
73
|
-
if (
|
|
104
|
+
if (value === undefined || value <= 0 || rate === 0)
|
|
74
105
|
return ceiling;
|
|
75
|
-
seconds = (policy.max_expected_loss.amount / (
|
|
106
|
+
seconds = (policy.max_expected_loss.amount / (value * rate)) * 3600;
|
|
76
107
|
}
|
|
77
108
|
if (!Number.isFinite(seconds) || seconds < 0)
|
|
78
109
|
return seconds === Infinity ? ceiling : 0;
|
|
@@ -88,91 +119,122 @@ function human(seconds) {
|
|
|
88
119
|
return `${Math.floor(seconds / 3600)} h`;
|
|
89
120
|
return `${Math.floor(seconds / 86_400)} d`;
|
|
90
121
|
}
|
|
122
|
+
function failAll(policy, detail) {
|
|
123
|
+
return policy.require.map((kind) => ({ rule: "freshness", kind, ok: false, detail, age_seconds: null, max_age_seconds: 0 }));
|
|
124
|
+
}
|
|
125
|
+
/** Why the action cannot be judged at all (bad clock, bad amount, other currency, unknown value refused), or null. */
|
|
126
|
+
function preconditionProblem(policy, nowValid, amountValid, qCurrency, c) {
|
|
127
|
+
const currency = policy.max_expected_loss.currency;
|
|
128
|
+
if (!nowValid)
|
|
129
|
+
return "the verifier's clock is not a valid date";
|
|
130
|
+
if (!amountValid)
|
|
131
|
+
return "amount is not a finite number >= 0";
|
|
132
|
+
if (qCurrency && qCurrency !== currency)
|
|
133
|
+
return `loss tolerance is in ${currency}, operation in ${qCurrency}`;
|
|
134
|
+
if (c.value === undefined && c.amount === undefined && policy.unvalued_action === "refuse") {
|
|
135
|
+
const subject = c.action ? `"${c.action}"` : "an unnamed action";
|
|
136
|
+
return `no amount and no risk value for ${subject}; the policy refuses actions of unknown value`;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
/** What the limit is based on, for the verdict's text. */
|
|
141
|
+
function basisOf(c, currency) {
|
|
142
|
+
if (c.amount !== undefined)
|
|
143
|
+
return `for ${c.amount} ${currency}`;
|
|
144
|
+
if (c.value !== undefined)
|
|
145
|
+
return `for "${c.action}" (risk value ${c.value} ${currency})`;
|
|
146
|
+
const forAction = c.action ? ` and no risk value for "${c.action}"` : "";
|
|
147
|
+
return `no amount${forAction}, ceiling applies`;
|
|
148
|
+
}
|
|
149
|
+
/** The evidence with the latest as_of: a newer statement supersedes an older one, whatever it says. */
|
|
150
|
+
function freshestOf(usable) {
|
|
151
|
+
let e = usable[0];
|
|
152
|
+
let asOf = Date.parse(e.as_of);
|
|
153
|
+
for (const candidate of usable) {
|
|
154
|
+
const t = Date.parse(candidate.as_of);
|
|
155
|
+
if (t > asOf) {
|
|
156
|
+
e = candidate;
|
|
157
|
+
asOf = t;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { e, asOf };
|
|
161
|
+
}
|
|
162
|
+
function isPastValidity(e, nowMs) {
|
|
163
|
+
if (!e.valid_until)
|
|
164
|
+
return false;
|
|
165
|
+
const until = Date.parse(e.valid_until);
|
|
166
|
+
return !Number.isNaN(until) && until < nowMs;
|
|
167
|
+
}
|
|
168
|
+
/** One required kind: its check, and whether fresher (or any) evidence would change the answer. */
|
|
169
|
+
function checkKind(kind, evidence, c) {
|
|
170
|
+
const { policy, nowMs } = c;
|
|
171
|
+
const max = maxStatusAgeSeconds(policy, kind, c.amount, c.action);
|
|
172
|
+
const limit = `${basisOf(c, policy.max_expected_loss.currency)}: at most ${human(max)}`;
|
|
173
|
+
const result = (ok, detail, age, refresh = false) => ({ check: { rule: "freshness", kind, ok, detail, age_seconds: age, max_age_seconds: max }, refresh });
|
|
174
|
+
const usable = evidence.filter((e) => e && e.kind === kind && !Number.isNaN(Date.parse(e.as_of)));
|
|
175
|
+
if (!usable.length)
|
|
176
|
+
return result(false, `no ${kind} status evidence; ${limit}`, null, true);
|
|
177
|
+
const { e, asOf } = freshestOf(usable);
|
|
178
|
+
const age = Math.floor((nowMs - asOf) / 1000);
|
|
179
|
+
const ageShown = Math.max(0, age);
|
|
180
|
+
const from = e.source ? ` (${e.source})` : "";
|
|
181
|
+
if (asOf - nowMs > policy.max_clock_skew_seconds * 1000) {
|
|
182
|
+
return result(false, `${kind} evidence${from} is dated ${e.as_of}, after the verifier's clock ${c.nowText} beyond the ${policy.max_clock_skew_seconds} s tolerance`, age);
|
|
183
|
+
}
|
|
184
|
+
if (policy.past_valid_until === "refuse" && isPastValidity(e, nowMs))
|
|
185
|
+
return result(false, `${kind} evidence${from} expired at ${e.valid_until}`, ageShown, true);
|
|
186
|
+
if (!e.in_force) {
|
|
187
|
+
const status = e.status ? ` (${e.status})` : "";
|
|
188
|
+
return result(false, `${kind} is not in force${status} as of ${e.as_of}${from}`, ageShown);
|
|
189
|
+
}
|
|
190
|
+
const fresh = ageShown <= max;
|
|
191
|
+
return result(fresh, `${kind} status${from} is ${human(ageShown)} old; ${limit}`, ageShown, !fresh);
|
|
192
|
+
}
|
|
91
193
|
/**
|
|
92
194
|
* Checks that, for every kind the policy requires, the freshest evidence given says "in force", is not
|
|
93
|
-
* past its own validity, is not dated in the future beyond the clock skew,
|
|
94
|
-
* age for this
|
|
195
|
+
* past its own validity (unless the policy accepts that), is not dated in the future beyond the clock skew,
|
|
196
|
+
* and is younger than the maximum age for this value. Fails closed: missing, malformed or foreign-currency
|
|
197
|
+
* input never passes.
|
|
95
198
|
*/
|
|
96
|
-
export function checkFreshness(policy, evidence, q
|
|
97
|
-
const now = q.now
|
|
98
|
-
const
|
|
99
|
-
const currency = policy.max_expected_loss.currency;
|
|
100
|
-
const qCurrency = q.currency?.trim().toUpperCase();
|
|
199
|
+
export function checkFreshness(policy, evidence, q) {
|
|
200
|
+
const now = q.now;
|
|
201
|
+
const nowValid = now instanceof Date && !Number.isNaN(now.getTime());
|
|
101
202
|
const amount = q.amount;
|
|
203
|
+
const action = q.action?.trim().toLowerCase() || undefined;
|
|
204
|
+
const amountValid = amount === undefined || (typeof amount === "number" && Number.isFinite(amount) && amount >= 0);
|
|
205
|
+
const value = amountValid ? valueAtRisk(policy, amount, action) : undefined;
|
|
206
|
+
const nowText = nowValid ? now.toISOString() : "invalid";
|
|
102
207
|
const applied = {
|
|
103
|
-
now:
|
|
208
|
+
now: nowText,
|
|
209
|
+
action: action ?? null,
|
|
104
210
|
amount: amount ?? null,
|
|
105
|
-
|
|
211
|
+
value_at_risk: value ?? null,
|
|
212
|
+
currency: policy.max_expected_loss.currency,
|
|
106
213
|
max_expected_loss: policy.max_expected_loss,
|
|
107
214
|
revocation_rate_per_hour: policy.revocation_rate_per_hour,
|
|
108
215
|
max_age_seconds: policy.max_age_seconds,
|
|
109
216
|
max_clock_skew_seconds: policy.max_clock_skew_seconds,
|
|
217
|
+
unvalued_action: policy.unvalued_action,
|
|
218
|
+
past_valid_until: policy.past_valid_until,
|
|
110
219
|
custom_max_age: !!policy.max_age
|
|
111
220
|
};
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!amountOk || (qCurrency && qCurrency !== currency)) {
|
|
121
|
-
const detail = !amountOk ? "amount is not a finite number >= 0" : `loss tolerance is in ${currency}, operation in ${qCurrency}`;
|
|
122
|
-
for (const kind of policy.require)
|
|
123
|
-
checks.push({ rule: "freshness", kind, ok: false, detail, age_seconds: null, max_age_seconds: 0 });
|
|
124
|
-
return { ok: false, checks, refresh, applied };
|
|
125
|
-
}
|
|
126
|
-
for (const kind of policy.require) {
|
|
127
|
-
const max = maxStatusAgeSeconds(policy, kind, amount);
|
|
128
|
-
const limit = `${amount === undefined ? "no amount given, ceiling applies" : `for ${amount} ${currency}`}: at most ${human(max)}`;
|
|
129
|
-
const usable = evidence.filter((e) => e && e.kind === kind && !Number.isNaN(Date.parse(e.as_of)));
|
|
130
|
-
if (!usable.length) {
|
|
131
|
-
checks.push({ rule: "freshness", kind, ok: false, detail: `no ${kind} status evidence; ${limit}`, age_seconds: null, max_age_seconds: max });
|
|
132
|
-
refresh.push(kind);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
// Freshest first: a newer statement supersedes an older one, whatever it says.
|
|
136
|
-
let e = usable[0];
|
|
137
|
-
let asOf = Date.parse(e.as_of);
|
|
138
|
-
for (const candidate of usable) {
|
|
139
|
-
const t = Date.parse(candidate.as_of);
|
|
140
|
-
if (t > asOf) {
|
|
141
|
-
e = candidate;
|
|
142
|
-
asOf = t;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
const age = Math.floor((nowMs - asOf) / 1000);
|
|
146
|
-
const from = e.source ? ` (${e.source})` : "";
|
|
147
|
-
if (asOf - nowMs > policy.max_clock_skew_seconds * 1000) {
|
|
148
|
-
checks.push({ rule: "freshness", kind, ok: false, detail: `${kind} evidence${from} is dated ${e.as_of}, after the verifier's clock ${applied.now} beyond the ${policy.max_clock_skew_seconds} s tolerance`, age_seconds: age, max_age_seconds: max });
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (e.valid_until && !Number.isNaN(Date.parse(e.valid_until)) && Date.parse(e.valid_until) < nowMs) {
|
|
152
|
-
checks.push({ rule: "freshness", kind, ok: false, detail: `${kind} evidence${from} expired at ${e.valid_until}`, age_seconds: Math.max(0, age), max_age_seconds: max });
|
|
153
|
-
refresh.push(kind);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (!e.in_force) {
|
|
157
|
-
checks.push({ rule: "freshness", kind, ok: false, detail: `${kind} is not in force${e.status ? ` (${e.status})` : ""} as of ${e.as_of}${from}`, age_seconds: Math.max(0, age), max_age_seconds: max });
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
const ageShown = Math.max(0, age);
|
|
161
|
-
const fresh = ageShown <= max;
|
|
162
|
-
checks.push({ rule: "freshness", kind, ok: fresh, detail: `${kind} status${from} is ${human(ageShown)} old; ${limit}`, age_seconds: ageShown, max_age_seconds: max });
|
|
163
|
-
if (!fresh)
|
|
164
|
-
refresh.push(kind);
|
|
165
|
-
}
|
|
166
|
-
return { ok: checks.length > 0 && checks.every((c) => c.ok), checks, refresh, applied };
|
|
221
|
+
const c = { policy, nowMs: nowValid ? now.getTime() : Number.NaN, nowText, amount, action, value };
|
|
222
|
+
const problem = preconditionProblem(policy, nowValid, amountValid, q.currency?.trim().toUpperCase(), c);
|
|
223
|
+
if (problem)
|
|
224
|
+
return { ok: false, checks: failAll(policy, problem), refresh: [], applied };
|
|
225
|
+
const results = policy.require.map((kind) => checkKind(kind, evidence, c));
|
|
226
|
+
const checks = results.map((r) => r.check);
|
|
227
|
+
const refresh = policy.require.filter((_, i) => results[i].refresh);
|
|
228
|
+
return { ok: checks.length > 0 && checks.every((x) => x.ok), checks, refresh, applied };
|
|
167
229
|
}
|
|
168
230
|
/**
|
|
169
231
|
* The mandate (checkAction) and the freshness of the status, in one answer: allowed only when both pass.
|
|
170
|
-
* The freshness check uses the action's amount and currency and the same clock.
|
|
232
|
+
* The freshness check uses the action's name, amount and currency and the same clock.
|
|
171
233
|
*/
|
|
172
234
|
export function checkFreshAction(mandate, q, p) {
|
|
173
|
-
const now = q.now
|
|
235
|
+
const now = q.now;
|
|
174
236
|
const m = checkAction(mandate, { ...q, now });
|
|
175
|
-
const f = checkFreshness(p.policy, p.evidence, { amount: q.amount, currency: q.currency, now });
|
|
237
|
+
const f = checkFreshness(p.policy, p.evidence, { action: q.action, amount: q.amount, currency: q.currency, now });
|
|
176
238
|
return { allowed: m.allowed && f.ok, mandate: m, freshness: f };
|
|
177
239
|
}
|
|
178
240
|
/** Evidence about the certificate from a CRL lookup (checkRevocation / verifyCrl().lookup). */
|
package/dist/person.d.ts
CHANGED
|
@@ -26,11 +26,12 @@ export declare function personKeys(json: string | undefined): {
|
|
|
26
26
|
} | null;
|
|
27
27
|
/** Consent the person signs with the e-CPF; part of the challenge text, so the signature records it. */
|
|
28
28
|
export declare const PERSON_CONSENT = "I authorize this issuer to keep a keyed hash of my CPF while this credential is valid and to answer, to verifiers who pay for each check, only whether a CPF they already have matches. My name and CPF are never stored in clear, published or revealed. / Autorizo este emissor a guardar um hash com chave do meu CPF enquanto esta credencial valer e a responder, a verificadores que pagam por consulta, apenas se um CPF que eles ja tem confere. Meu nome e meu CPF nunca sao guardados em claro, publicados ou revelados.";
|
|
29
|
-
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate). */
|
|
29
|
+
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate); with the mandate's SHA-256 (D-33). */
|
|
30
30
|
export declare function personChallengeText(p: {
|
|
31
31
|
issuer: string;
|
|
32
32
|
challenge_id: string;
|
|
33
33
|
agent: AgentKey;
|
|
34
|
+
mandate_sha256: string;
|
|
34
35
|
expires_at: string;
|
|
35
36
|
}): string;
|
|
36
37
|
/** The text a person signs with the e-CPF to see their own agents (accepted for 10 minutes after `at`). */
|
|
@@ -38,5 +39,6 @@ export declare const personAgentsText: (at?: Date) => string;
|
|
|
38
39
|
export declare function newPersonChallenge(p: {
|
|
39
40
|
issuer: string;
|
|
40
41
|
agent: AgentKey;
|
|
42
|
+
mandate_sha256: string;
|
|
41
43
|
now?: Date;
|
|
42
44
|
}): Challenge;
|
package/dist/person.js
CHANGED
|
@@ -66,8 +66,10 @@ export function personKeys(json) {
|
|
|
66
66
|
}
|
|
67
67
|
/** Consent the person signs with the e-CPF; part of the challenge text, so the signature records it. */
|
|
68
68
|
export const PERSON_CONSENT = "I authorize this issuer to keep a keyed hash of my CPF while this credential is valid and to answer, to verifiers who pay for each check, only whether a CPF they already have matches. My name and CPF are never stored in clear, published or revealed. / Autorizo este emissor a guardar um hash com chave do meu CPF enquanto esta credencial valer e a responder, a verificadores que pagam por consulta, apenas se um CPF que eles ja tem confere. Meu nome e meu CPF nunca sao guardados em claro, publicados ou revelados.";
|
|
69
|
-
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate). */
|
|
69
|
+
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate); with the mandate's SHA-256 (D-33). */
|
|
70
70
|
export function personChallengeText(p) {
|
|
71
|
+
if (typeof p.mandate_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(p.mandate_sha256))
|
|
72
|
+
throw new KyaError("INVALID_INPUT", "mandate_sha256 must be 64 lowercase hex characters (see mandateSha256)");
|
|
71
73
|
return [
|
|
72
74
|
"KYA Brasil: prove this agent key acts for me, a natural person",
|
|
73
75
|
`issuer: ${p.issuer}`,
|
|
@@ -75,6 +77,7 @@ export function personChallengeText(p) {
|
|
|
75
77
|
`agent: ${p.agent.kind}:${p.agent.id}`,
|
|
76
78
|
"principal: person (e-CPF)",
|
|
77
79
|
`consent: ${PERSON_CONSENT}`,
|
|
80
|
+
`mandate-sha256: ${p.mandate_sha256}`,
|
|
78
81
|
`expires: ${p.expires_at}`
|
|
79
82
|
].join("\n");
|
|
80
83
|
}
|
|
@@ -84,5 +87,5 @@ export function newPersonChallenge(p) {
|
|
|
84
87
|
const now = p.now ?? new Date();
|
|
85
88
|
const challenge_id = crypto.randomUUID();
|
|
86
89
|
const expires_at = new Date(now.getTime() + CHALLENGE_TTL_SECONDS * 1000).toISOString();
|
|
87
|
-
return { challenge_id, expires_at, text: personChallengeText({ issuer: p.issuer, challenge_id, agent: p.agent, expires_at }) };
|
|
90
|
+
return { challenge_id, expires_at, text: personChallengeText({ issuer: p.issuer, challenge_id, agent: p.agent, mandate_sha256: p.mandate_sha256, expires_at }) };
|
|
88
91
|
}
|
package/dist/revocation.d.ts
CHANGED
|
@@ -26,11 +26,16 @@ export interface VerifiedCrl {
|
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Parses and verifies a DER CRL against `issuerCertificate` (base64 DER). Throws REVOCATION_UNAVAILABLE
|
|
29
|
-
* when the CRL cannot be trusted (bad encoding, other issuer, bad signature
|
|
29
|
+
* when the CRL cannot be trusted (bad encoding, other issuer, bad signature).
|
|
30
|
+
*
|
|
31
|
+
* `pastNextUpdate` is the caller's choice for a CRL whose nextUpdate has passed: "refuse" throws (the usual
|
|
32
|
+
* reading of RFC 5280); "accept" returns it, still reporting next_update, for callers that bound the risk by
|
|
33
|
+
* the CRL's age instead (Li and Feigenbaum, FC 2001; SPEC section 18).
|
|
30
34
|
*/
|
|
31
35
|
export declare function verifyCrl(p: {
|
|
32
36
|
crlDer: ArrayBuffer | Uint8Array;
|
|
33
37
|
issuerCertificate: string;
|
|
38
|
+
pastNextUpdate: "refuse" | "accept";
|
|
34
39
|
now?: Date;
|
|
35
40
|
}): Promise<VerifiedCrl>;
|
|
36
41
|
/** One-shot check of `serial` against a DER CRL signed by `issuerCertificate`; see verifyCrl. */
|
|
@@ -38,6 +43,7 @@ export declare function checkRevocation(p: {
|
|
|
38
43
|
crlDer: ArrayBuffer | Uint8Array;
|
|
39
44
|
serial: string;
|
|
40
45
|
issuerCertificate: string;
|
|
46
|
+
pastNextUpdate: "refuse" | "accept";
|
|
41
47
|
now?: Date;
|
|
42
48
|
}): Promise<RevocationStatus>;
|
|
43
49
|
/**
|
package/dist/revocation.js
CHANGED
|
@@ -27,9 +27,15 @@ export function crlUrlsOf(cert) {
|
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Parses and verifies a DER CRL against `issuerCertificate` (base64 DER). Throws REVOCATION_UNAVAILABLE
|
|
30
|
-
* when the CRL cannot be trusted (bad encoding, other issuer, bad signature
|
|
30
|
+
* when the CRL cannot be trusted (bad encoding, other issuer, bad signature).
|
|
31
|
+
*
|
|
32
|
+
* `pastNextUpdate` is the caller's choice for a CRL whose nextUpdate has passed: "refuse" throws (the usual
|
|
33
|
+
* reading of RFC 5280); "accept" returns it, still reporting next_update, for callers that bound the risk by
|
|
34
|
+
* the CRL's age instead (Li and Feigenbaum, FC 2001; SPEC section 18).
|
|
31
35
|
*/
|
|
32
36
|
export async function verifyCrl(p) {
|
|
37
|
+
if (p.pastNextUpdate !== "refuse" && p.pastNextUpdate !== "accept")
|
|
38
|
+
throw new KyaError("INVALID_INPUT", 'pastNextUpdate is required: "refuse" or "accept"');
|
|
33
39
|
const now = p.now ?? new Date();
|
|
34
40
|
let crl;
|
|
35
41
|
let issuer;
|
|
@@ -44,7 +50,7 @@ export async function verifyCrl(p) {
|
|
|
44
50
|
throw new KyaError("REVOCATION_UNAVAILABLE", `CRL issued by "${crl.issuer}", not by the certificate's AC "${issuer.subject}"`);
|
|
45
51
|
if (!(await crl.verify({ publicKey: issuer }, globalThis.crypto)))
|
|
46
52
|
throw new KyaError("REVOCATION_UNAVAILABLE", "CRL signature does not match the AC certificate");
|
|
47
|
-
if (crl.nextUpdate && crl.nextUpdate < now)
|
|
53
|
+
if (p.pastNextUpdate === "refuse" && crl.nextUpdate && crl.nextUpdate < now)
|
|
48
54
|
throw new KyaError("REVOCATION_UNAVAILABLE", `CRL is stale (nextUpdate ${crl.nextUpdate.toISOString()})`);
|
|
49
55
|
const this_update = crl.thisUpdate.toISOString();
|
|
50
56
|
const next_update = crl.nextUpdate ? crl.nextUpdate.toISOString() : null;
|
package/dist/types.d.ts
CHANGED
|
@@ -97,13 +97,18 @@ export interface Credential {
|
|
|
97
97
|
agent: AgentKey;
|
|
98
98
|
/** A company (e-CNPJ) or, since D-29, a natural person (e-CPF) with no personal data: tell them apart by `kind`. */
|
|
99
99
|
principal: CompanyPrincipal | PersonPrincipal;
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* How the principal authorized it: hash of the ICP-Brasil certificate that signed the challenge. No subject for
|
|
102
|
+
* persons. `mandate_sha256` (D-33) is the mandate's SHA-256 that the signed challenge carried: when it equals
|
|
103
|
+
* mandateSha256(mandate), the certificate signature covers these powers (see verifyMandateAuthorization).
|
|
104
|
+
*/
|
|
101
105
|
authorized_by: {
|
|
102
106
|
method: "icp-brasil-e-cnpj" | "icp-brasil-e-cpf";
|
|
103
107
|
certificate_sha256: string;
|
|
104
108
|
certificate_subject?: string;
|
|
105
109
|
issuer: string;
|
|
106
110
|
signed_at: string;
|
|
111
|
+
mandate_sha256?: string;
|
|
107
112
|
};
|
|
108
113
|
mandate: Mandate;
|
|
109
114
|
/** Informative since D-15: the issuance decision uses only principal.registration_status and the certificate's revocation status. Always null from the reference issuer since D-23 (no KYB dependency); kept in /v1. Null when the issuer has no KYB source (D-17). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kya-brasil/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "KYA Brasil as a library: verify and issue know-your-agent credentials for Brazilian companies without any hosted service. e-CNPJ (ICP-Brasil) CMS signature and chain validation, CRL revocation, CNPJ, agent key challenges (EVM, Ed25519 / Web Bot Auth, ERC-8004), mandate schema and evaluation (including payment instruments for card networks and acquirers), credential build, Ed25519 signing and verification, and e-CNPJ signing for the holder (A1 .pfx, or any token/HSM/cloud signer through a raw-sign callback). Pure functions: storage and network are yours.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|