@fora-protocol/sdk 1.0.3
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 +201 -0
- package/README.md +37 -0
- package/gen/ts/vocab/functiontokens.js +78 -0
- package/gen/ts/vocab/functiontokens.ts +84 -0
- package/gen/ts/vocab/geographytokens.js +30 -0
- package/gen/ts/vocab/geographytokens.ts +37 -0
- package/gen/ts/vocab/pricingunits.js +56 -0
- package/gen/ts/vocab/pricingunits.ts +63 -0
- package/gen/ts/vocab/quotametrics.js +40 -0
- package/gen/ts/vocab/quotametrics.ts +47 -0
- package/gen/ts/vocab/usertypes.js +40 -0
- package/gen/ts/vocab/usertypes.ts +46 -0
- package/gen/ts/wire/base.js +245 -0
- package/gen/ts/wire/base.ts +255 -0
- package/gen/ts/wire/names.js +41 -0
- package/gen/ts/wire/names.ts +42 -0
- package/gen/ts/wire/schemas.js +111 -0
- package/gen/ts/wire/schemas.ts +216 -0
- package/package.json +164 -0
- package/sdk/ts/client/content.d.ts +74 -0
- package/sdk/ts/client/content.js +466 -0
- package/sdk/ts/client/errors.d.ts +79 -0
- package/sdk/ts/client/errors.js +148 -0
- package/sdk/ts/client/index.d.ts +241 -0
- package/sdk/ts/client/index.js +740 -0
- package/sdk/ts/client/route.d.ts +41 -0
- package/sdk/ts/client/route.js +107 -0
- package/sdk/ts/client/send.d.ts +64 -0
- package/sdk/ts/client/send.js +142 -0
- package/sdk/ts/client/transport.d.ts +187 -0
- package/sdk/ts/client/transport.js +381 -0
- package/sdk/ts/core/multisig-parse.d.ts +47 -0
- package/sdk/ts/core/multisig-parse.js +234 -0
- package/sdk/ts/core/sign-request.d.ts +83 -0
- package/sdk/ts/core/sign-request.js +146 -0
- package/sdk/ts/core/sign.d.ts +31 -0
- package/sdk/ts/core/sign.js +82 -0
- package/sdk/ts/core/signing-transport.d.ts +75 -0
- package/sdk/ts/core/signing-transport.js +172 -0
- package/sdk/ts/core/verifier.d.ts +177 -0
- package/sdk/ts/core/verifier.js +269 -0
- package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
- package/sdk/ts/core/verify-multisig-request.js +112 -0
- package/sdk/ts/core/verify-request.d.ts +181 -0
- package/sdk/ts/core/verify-request.js +284 -0
- package/sdk/ts/core/window.d.ts +22 -0
- package/sdk/ts/core/window.js +43 -0
- package/sdk/ts/core/wire-canon.d.ts +20 -0
- package/sdk/ts/core/wire-canon.js +174 -0
- package/sdk/ts/hono/middleware.d.ts +38 -0
- package/sdk/ts/hono/middleware.js +64 -0
- package/sdk/ts/resolvers/errors.d.ts +130 -0
- package/sdk/ts/resolvers/errors.js +150 -0
- package/sdk/ts/resolvers/http.d.ts +98 -0
- package/sdk/ts/resolvers/http.js +274 -0
- package/sdk/ts/resolvers/index.d.ts +9 -0
- package/sdk/ts/resolvers/index.js +15 -0
- package/sdk/ts/resolvers/jwks.d.ts +2 -0
- package/sdk/ts/resolvers/jwks.js +43 -0
- package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
- package/sdk/ts/resolvers/offer-key-cache.js +152 -0
- package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
- package/sdk/ts/resolvers/registration-requirements.js +220 -0
- package/sdk/ts/resolvers/ssrf.d.ts +28 -0
- package/sdk/ts/resolvers/ssrf.js +235 -0
- package/sdk/ts/resolvers/static.d.ts +7 -0
- package/sdk/ts/resolvers/static.js +16 -0
- package/sdk/ts/resolvers/wba.d.ts +134 -0
- package/sdk/ts/resolvers/wba.js +506 -0
- package/sdk/ts/resolvers/wellknown.d.ts +28 -0
- package/sdk/ts/resolvers/wellknown.js +169 -0
- package/sdk/ts/src/acceptance.d.ts +46 -0
- package/sdk/ts/src/acceptance.js +162 -0
- package/sdk/ts/src/base64url.d.ts +19 -0
- package/sdk/ts/src/base64url.js +55 -0
- package/sdk/ts/src/crossfield.d.ts +28 -0
- package/sdk/ts/src/crossfield.js +215 -0
- package/sdk/ts/src/endpoint-rule.d.ts +30 -0
- package/sdk/ts/src/endpoint-rule.js +63 -0
- package/sdk/ts/src/errordetail.d.ts +115 -0
- package/sdk/ts/src/errordetail.js +256 -0
- package/sdk/ts/src/hashurl.d.ts +5 -0
- package/sdk/ts/src/hashurl.js +15 -0
- package/sdk/ts/src/host-ref.d.ts +70 -0
- package/sdk/ts/src/host-ref.js +320 -0
- package/sdk/ts/src/hosts.d.ts +138 -0
- package/sdk/ts/src/hosts.js +214 -0
- package/sdk/ts/src/idempotency.d.ts +11 -0
- package/sdk/ts/src/idempotency.js +27 -0
- package/sdk/ts/src/jsondepth.d.ts +27 -0
- package/sdk/ts/src/jsondepth.js +77 -0
- package/sdk/ts/src/licenseterm.d.ts +113 -0
- package/sdk/ts/src/licenseterm.js +433 -0
- package/sdk/ts/src/money.d.ts +21 -0
- package/sdk/ts/src/money.js +60 -0
- package/sdk/ts/src/offer-sign.d.ts +11 -0
- package/sdk/ts/src/offer-sign.js +31 -0
- package/sdk/ts/src/opaque-url.d.ts +3 -0
- package/sdk/ts/src/opaque-url.js +20 -0
- package/sdk/ts/src/pop.d.ts +51 -0
- package/sdk/ts/src/pop.js +166 -0
- package/sdk/ts/src/regschema.d.ts +229 -0
- package/sdk/ts/src/regschema.js +1315 -0
- package/sdk/ts/src/scopes.d.ts +20 -0
- package/sdk/ts/src/scopes.js +45 -0
- package/sdk/ts/src/signurl.d.ts +23 -0
- package/sdk/ts/src/signurl.js +107 -0
- package/sdk/ts/src/thumbprint.d.ts +14 -0
- package/sdk/ts/src/thumbprint.js +24 -0
- package/sdk/ts/src/verify.d.ts +49 -0
- package/sdk/ts/src/verify.js +109 -0
- package/sdk/ts/src/wire-names.d.ts +1 -0
- package/sdk/ts/src/wire-names.js +16 -0
- package/sdk/ts/src/wire.d.ts +68 -0
- package/sdk/ts/src/wire.js +114 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export declare const OFFER_SIGNATURE_ALGORITHM = "EdDSA";
|
|
2
|
+
export type Mode = "strict" | "off";
|
|
3
|
+
/**
|
|
4
|
+
* OfferKeyResolver resolves an exchange identity to its raw 32-byte Ed25519
|
|
5
|
+
* offer-signing public key. Injected — the core owns no key state. Returns
|
|
6
|
+
* undefined when no key is known for the exchange (→ fail-closed reject under
|
|
7
|
+
* strict).
|
|
8
|
+
*/
|
|
9
|
+
export interface OfferKeyResolver {
|
|
10
|
+
resolve(exchange: string): Promise<Uint8Array<ArrayBuffer> | undefined>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Ed25519 verify primitive: (publicKey, signature, message) -> valid?. Injected
|
|
14
|
+
* so a non-WebCrypto runtime can supply its own without changing the byte
|
|
15
|
+
* contract. Defaults to WebCrypto crypto.subtle. Byte params are
|
|
16
|
+
* `Uint8Array<ArrayBuffer>` (never SharedArrayBuffer-backed) so the default can
|
|
17
|
+
* hand them to WebCrypto's BufferSource without casts.
|
|
18
|
+
*/
|
|
19
|
+
export type Ed25519Verify = (publicKey: Uint8Array<ArrayBuffer>, signature: Uint8Array<ArrayBuffer>, message: Uint8Array<ArrayBuffer>) => Promise<boolean>;
|
|
20
|
+
declare const VERIFIED_BRAND: unique symbol;
|
|
21
|
+
/**
|
|
22
|
+
* VerifiedOffer wraps an offer that passed the core's fail-closed verification
|
|
23
|
+
* (genuine exchange signature, not expired) — OR was surfaced under mode "off" /
|
|
24
|
+
* RejectedOffer.unsafe(). The brand is module-private, so `execute(offer:
|
|
25
|
+
* VerifiedOffer)` accepts only offers the core minted: an app cannot construct one
|
|
26
|
+
* with an object literal (tsc rejects the missing brand). Reading the wrapped
|
|
27
|
+
* offer is fine; only MINTING one is gated.
|
|
28
|
+
*/
|
|
29
|
+
export interface VerifiedOffer {
|
|
30
|
+
readonly [VERIFIED_BRAND]: true;
|
|
31
|
+
readonly offer: unknown;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* RejectedOffer is an offer the Verifier could NOT accept: the wrapped offer plus
|
|
35
|
+
* the reason it failed. It is VISIBLE (the app learns which offers failed and why)
|
|
36
|
+
* but not directly executable — acting on it requires the explicit .unsafe()
|
|
37
|
+
* escape.
|
|
38
|
+
*/
|
|
39
|
+
export interface RejectedOffer {
|
|
40
|
+
readonly offer: unknown;
|
|
41
|
+
readonly reason: string;
|
|
42
|
+
/** The single, audit-visible escape hatch: mint an executable VerifiedOffer. */
|
|
43
|
+
unsafe(): VerifiedOffer;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Result is the fail-closed {verified, rejected} contract every discover/resolve
|
|
47
|
+
* call returns. Neither list is silently dropped. Canonical cross-language shape
|
|
48
|
+
* (mirror sdk/go core.Result).
|
|
49
|
+
*/
|
|
50
|
+
export interface Result {
|
|
51
|
+
verified: VerifiedOffer[];
|
|
52
|
+
rejected: RejectedOffer[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* OfferGroupResult is one requested URI's answer (mirror sdk/go core.OfferGroupResult).
|
|
56
|
+
*
|
|
57
|
+
* `absenceReason` is undefined when the responder stated no reason — a legitimate
|
|
58
|
+
* answer, not an omission: where the existence of a resource must itself stay hidden, a
|
|
59
|
+
* responder MAY withhold the reason rather than confirm the resource exists. The Go
|
|
60
|
+
* oracle keeps a pointer to tell that apart from the unspecified enum value; here the
|
|
61
|
+
* generated enums carry no `*_UNSPECIFIED` member at all, so a present value is always a
|
|
62
|
+
* real reason and undefined is the only way to say nothing.
|
|
63
|
+
*/
|
|
64
|
+
export interface OfferGroupResult {
|
|
65
|
+
readonly uri: string;
|
|
66
|
+
readonly result: Result;
|
|
67
|
+
readonly absenceReason?: string;
|
|
68
|
+
readonly discoveryMethod?: string;
|
|
69
|
+
/**
|
|
70
|
+
* The restriction axes that drove a convenience pre-filter, when the absence reason
|
|
71
|
+
* is a restriction filter. Advisory diagnostics, not an enforcement verdict — but
|
|
72
|
+
* they tell an agent which axis to vary on a retry.
|
|
73
|
+
*/
|
|
74
|
+
readonly restrictionFilters: string[];
|
|
75
|
+
}
|
|
76
|
+
/** DiscoveryResult is what discover and resolve return (mirror sdk/go core.DiscoveryResult). */
|
|
77
|
+
export interface DiscoveryResult {
|
|
78
|
+
/** One entry per requested URI, in the order the responder returned. */
|
|
79
|
+
readonly groups: OfferGroupResult[];
|
|
80
|
+
/**
|
|
81
|
+
* Why the CALL as a whole yielded nothing. Set only on that path — when any group
|
|
82
|
+
* carries offers it stays undefined, and the per-URI causes ride on each group
|
|
83
|
+
* instead. Only a Broker resolve can set it: an Exchange's own discovery response has
|
|
84
|
+
* no whole-call reason field.
|
|
85
|
+
*/
|
|
86
|
+
readonly absenceReason?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Canonical domain of the responding Exchange. Empty from a Broker resolve, whose
|
|
89
|
+
* response names no single Exchange — each offer carries its own issuing domain.
|
|
90
|
+
*/
|
|
91
|
+
readonly exchange: string;
|
|
92
|
+
/**
|
|
93
|
+
* The caller's rate-limit standing, when the responder reported it, so an agent can
|
|
94
|
+
* throttle before a fan-out meets a hard limit. Undefined from a Broker resolve.
|
|
95
|
+
*/
|
|
96
|
+
readonly rateLimit?: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Every verified offer across all groups, for a caller that does not care which URI an
|
|
100
|
+
* offer answers.
|
|
101
|
+
*
|
|
102
|
+
* A free function rather than a method, unlike the Go and Python faces: DiscoveryResult
|
|
103
|
+
* here is a plain record — what the client hands back after parsing — and putting
|
|
104
|
+
* methods on the interface would force every hand-built literal to supply them. Same
|
|
105
|
+
* contract, spelled the way this language spells it.
|
|
106
|
+
*
|
|
107
|
+
* A convenience over `groups`, never a substitute. A URI that was REFUSED contributes
|
|
108
|
+
* nothing here — it has no offer to contribute — so a caller reading only this cannot
|
|
109
|
+
* tell a refusal from a resource it never asked about. That is exactly the information
|
|
110
|
+
* `groups` exists to keep.
|
|
111
|
+
*/
|
|
112
|
+
export declare function verifiedOffers(result: DiscoveryResult): VerifiedOffer[];
|
|
113
|
+
/**
|
|
114
|
+
* Every rejected offer across all groups, with the reason each failed. The same caveat as
|
|
115
|
+
* {@link verifiedOffers} applies: a URI that yielded no offers at all is not a rejection
|
|
116
|
+
* and appears only in `groups`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function rejectedOffers(result: DiscoveryResult): RejectedOffer[];
|
|
119
|
+
/** Runtime brand check — a plain object cannot carry the module-private Symbol. */
|
|
120
|
+
export declare function isVerifiedOffer(value: unknown): value is VerifiedOffer;
|
|
121
|
+
/**
|
|
122
|
+
* canonicalOfferPayload reproduces the signed bytes: clear signature +
|
|
123
|
+
* signature_algorithm from the offer's canonical proto-JSON, then apply RFC 8785
|
|
124
|
+
* JCS. MUST stay byte-identical to the Go oracle (helpers.CanonicalOfferBytes).
|
|
125
|
+
* The offer is already canonical proto-JSON (snake_case, enums-as-names,
|
|
126
|
+
* omit-unpopulated) — the core only clears the two signature fields and re-JCS-es.
|
|
127
|
+
*/
|
|
128
|
+
export declare function canonicalOfferPayload(offer: Record<string, unknown>): Uint8Array<ArrayBuffer>;
|
|
129
|
+
/**
|
|
130
|
+
* Verifier runs the per-offer authenticity + freshness check, keyed through the
|
|
131
|
+
* injected KeyResolver. It is PURE apart from the resolver IO — no state, no clock
|
|
132
|
+
* beyond the injected now. Transport-neutral: any Fetch/WebCrypto consumer composes
|
|
133
|
+
* it directly, without any framework binding.
|
|
134
|
+
*/
|
|
135
|
+
export declare class Verifier {
|
|
136
|
+
private readonly mode;
|
|
137
|
+
private readonly resolver;
|
|
138
|
+
private readonly now;
|
|
139
|
+
private readonly verifyEd25519;
|
|
140
|
+
constructor(mode: Mode, resolver: OfferKeyResolver, now: () => number, verifyEd25519: Ed25519Verify);
|
|
141
|
+
/**
|
|
142
|
+
* sort splits offers into verified and rejected per the configured mode. Under
|
|
143
|
+
* "off" every offer is surfaced verified with no check. Under "strict" each offer
|
|
144
|
+
* is verified against its resolved exchange key and its expiry — a failure of
|
|
145
|
+
* either lands it in rejected with the reason.
|
|
146
|
+
*/
|
|
147
|
+
sort(offers: unknown[]): Promise<Result>;
|
|
148
|
+
/**
|
|
149
|
+
* sortGroups verifies every group's offers through THIS Verifier and returns the
|
|
150
|
+
* per-URI results, preserving each group's URI and its typed reasons (mirror sdk/go
|
|
151
|
+
* `Verifier.SortGroups`).
|
|
152
|
+
*
|
|
153
|
+
* One Verifier sorts every group deliberately: it is stateless apart from the
|
|
154
|
+
* injected resolver and clock, so a fresh one per group would mean N resolver caches
|
|
155
|
+
* and N clock readings for a single logical answer.
|
|
156
|
+
*
|
|
157
|
+
* A non-object element is SKIPPED rather than surfaced as an empty URI: it is not a
|
|
158
|
+
* group with no offers, it is nothing at all, and inventing an answer the responder
|
|
159
|
+
* never gave is worse than dropping a malformed one.
|
|
160
|
+
*/
|
|
161
|
+
sortGroups(groups: readonly unknown[]): Promise<OfferGroupResult[]>;
|
|
162
|
+
private check;
|
|
163
|
+
private expired;
|
|
164
|
+
}
|
|
165
|
+
/** Options for createVerifier (all injected — the core owns no state). */
|
|
166
|
+
export interface VerifierOptions {
|
|
167
|
+
resolve: (exchange: string) => Promise<Uint8Array<ArrayBuffer> | undefined>;
|
|
168
|
+
now: () => number;
|
|
169
|
+
verifyEd25519?: Ed25519Verify;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* createVerifier is the transport-neutral constructor. Mirror of sdk/go
|
|
173
|
+
* core.NewVerifier: the verification mode, offer KeyResolver, and clock are all
|
|
174
|
+
* injected; the Ed25519 primitive defaults to WebCrypto.
|
|
175
|
+
*/
|
|
176
|
+
export declare function createVerifier(mode: Mode, opts: VerifierOptions): Verifier;
|
|
177
|
+
export {};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// sdk/ts/core — the transport-neutral L2 core (Verifier + {verified, rejected} +
|
|
2
|
+
// the unforgeable VerifiedOffer). Mirror of sdk/go/core, translated to idiomatic
|
|
3
|
+
// TS. It imposes NOTHING beyond the WHATWG Fetch/WebCrypto web standard — no Hono,
|
|
4
|
+
// no Connect-ES. Framework bindings (sdk/ts/hono) depend one-directionally on this
|
|
5
|
+
// core, never the reverse.
|
|
6
|
+
//
|
|
7
|
+
// The Verifier splits received offers into {verified, rejected} by ed25519-
|
|
8
|
+
// verifying the canonical offer signature. Per the JCS switch (ADR-020 §4), the
|
|
9
|
+
// signed payload is RFC 8785 JCS over the canonical proto-JSON of the offer with
|
|
10
|
+
// signature/signature_algorithm cleared:
|
|
11
|
+
//
|
|
12
|
+
// signed_payload = JCS(protojson(offer with sig+alg cleared))
|
|
13
|
+
//
|
|
14
|
+
// so the core reproduces the exact signed bytes without a protobuf binary codec.
|
|
15
|
+
//
|
|
16
|
+
// VerifiedOffer is a BRANDED/opaque type: it carries a module-private Symbol tag
|
|
17
|
+
// that only this core's verify path (or RejectedOffer.unsafe()) can stamp, so an
|
|
18
|
+
// application cannot forge one with an object literal and slip it into
|
|
19
|
+
// execute(offer: VerifiedOffer). That is the TS analogue of the Go compile guard
|
|
20
|
+
// (unexported field + no exported constructor).
|
|
21
|
+
import canonicalize from "canonicalize";
|
|
22
|
+
import { utf8Bytes } from "../src/base64url.js";
|
|
23
|
+
// OFFER_SIGNATURE_ALGORITHM is the JOSE/JWA algorithm identifier advertised on
|
|
24
|
+
// signed offers. Always
|
|
25
|
+
// EdDSA for Ed25519 (mirror helpers.OfferSignatureAlgorithm).
|
|
26
|
+
export const OFFER_SIGNATURE_ALGORITHM = "EdDSA";
|
|
27
|
+
// The module-private brand. Only code in this module can read/stamp it, so a
|
|
28
|
+
// VerifiedOffer cannot be fabricated outside the core's verify path.
|
|
29
|
+
const VERIFIED_BRAND = Symbol("fora.core.VerifiedOffer");
|
|
30
|
+
/**
|
|
31
|
+
* Every verified offer across all groups, for a caller that does not care which URI an
|
|
32
|
+
* offer answers.
|
|
33
|
+
*
|
|
34
|
+
* A free function rather than a method, unlike the Go and Python faces: DiscoveryResult
|
|
35
|
+
* here is a plain record — what the client hands back after parsing — and putting
|
|
36
|
+
* methods on the interface would force every hand-built literal to supply them. Same
|
|
37
|
+
* contract, spelled the way this language spells it.
|
|
38
|
+
*
|
|
39
|
+
* A convenience over `groups`, never a substitute. A URI that was REFUSED contributes
|
|
40
|
+
* nothing here — it has no offer to contribute — so a caller reading only this cannot
|
|
41
|
+
* tell a refusal from a resource it never asked about. That is exactly the information
|
|
42
|
+
* `groups` exists to keep.
|
|
43
|
+
*/
|
|
44
|
+
export function verifiedOffers(result) {
|
|
45
|
+
return result.groups.flatMap((g) => g.result.verified);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Every rejected offer across all groups, with the reason each failed. The same caveat as
|
|
49
|
+
* {@link verifiedOffers} applies: a URI that yielded no offers at all is not a rejection
|
|
50
|
+
* and appears only in `groups`.
|
|
51
|
+
*/
|
|
52
|
+
export function rejectedOffers(result) {
|
|
53
|
+
return result.groups.flatMap((g) => g.result.rejected);
|
|
54
|
+
}
|
|
55
|
+
// mintVerified is the SOLE constructor of a branded VerifiedOffer, module-private.
|
|
56
|
+
function mintVerified(offer) {
|
|
57
|
+
return { [VERIFIED_BRAND]: true, offer };
|
|
58
|
+
}
|
|
59
|
+
function makeRejected(offer, reason) {
|
|
60
|
+
return { offer, reason, unsafe: () => mintVerified(offer) };
|
|
61
|
+
}
|
|
62
|
+
/** Runtime brand check — a plain object cannot carry the module-private Symbol. */
|
|
63
|
+
export function isVerifiedOffer(value) {
|
|
64
|
+
return (typeof value === "object" &&
|
|
65
|
+
value !== null &&
|
|
66
|
+
value[VERIFIED_BRAND] === true);
|
|
67
|
+
}
|
|
68
|
+
const defaultVerifyEd25519 = async (pubkey, sig, message) => {
|
|
69
|
+
try {
|
|
70
|
+
const key = await crypto.subtle.importKey("raw", pubkey, { name: "Ed25519" }, false, ["verify"]);
|
|
71
|
+
return await crypto.subtle.verify("Ed25519", key, sig, message);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
// stringOr returns a wire string, or the fallback. A responder that sent the wrong JSON
|
|
78
|
+
// type for an enum or a URI has said nothing usable, and reading it as its own text
|
|
79
|
+
// would put an unvalidated value where a typed reason belongs.
|
|
80
|
+
function stringOr(value, fallback) {
|
|
81
|
+
return typeof value === "string" ? value : fallback;
|
|
82
|
+
}
|
|
83
|
+
// stringList returns the string members of a wire array; anything else contributes
|
|
84
|
+
// nothing.
|
|
85
|
+
function stringList(value) {
|
|
86
|
+
return Array.isArray(value)
|
|
87
|
+
? value.filter((v) => typeof v === "string")
|
|
88
|
+
: [];
|
|
89
|
+
}
|
|
90
|
+
// hexToBytes decodes a hex string (the Offer.signature encoding) to bytes.
|
|
91
|
+
//
|
|
92
|
+
// Strict about what a hex digit is. Number.parseInt accepts a sign, leading whitespace and
|
|
93
|
+
// a trailing tail — "-1", "+f" and " a" all produce a number — so a signature carrying any
|
|
94
|
+
// of them would decode to bytes rather than being refused, where Go's hex.DecodeString and
|
|
95
|
+
// Python's bytes.fromhex refuse it. The value comes from a peer, so the three languages
|
|
96
|
+
// have to agree on what is a signature and what is garbage.
|
|
97
|
+
const HEX_PAIR = /^[0-9a-fA-F]{2}$/;
|
|
98
|
+
function hexToBytes(hex) {
|
|
99
|
+
if (hex.length % 2 !== 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
const out = new Uint8Array(hex.length / 2);
|
|
102
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
103
|
+
const pair = hex.slice(i * 2, i * 2 + 2);
|
|
104
|
+
if (!HEX_PAIR.test(pair))
|
|
105
|
+
return undefined;
|
|
106
|
+
out[i] = Number.parseInt(pair, 16);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* canonicalOfferPayload reproduces the signed bytes: clear signature +
|
|
112
|
+
* signature_algorithm from the offer's canonical proto-JSON, then apply RFC 8785
|
|
113
|
+
* JCS. MUST stay byte-identical to the Go oracle (helpers.CanonicalOfferBytes).
|
|
114
|
+
* The offer is already canonical proto-JSON (snake_case, enums-as-names,
|
|
115
|
+
* omit-unpopulated) — the core only clears the two signature fields and re-JCS-es.
|
|
116
|
+
*/
|
|
117
|
+
export function canonicalOfferPayload(offer) {
|
|
118
|
+
const stripped = { ...offer };
|
|
119
|
+
delete stripped.signature;
|
|
120
|
+
delete stripped.signature_algorithm;
|
|
121
|
+
const jcs = canonicalize(stripped);
|
|
122
|
+
if (jcs === undefined)
|
|
123
|
+
throw new Error("fora/core: offer is not JSON-serializable");
|
|
124
|
+
return utf8Bytes(jcs);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Verifier runs the per-offer authenticity + freshness check, keyed through the
|
|
128
|
+
* injected KeyResolver. It is PURE apart from the resolver IO — no state, no clock
|
|
129
|
+
* beyond the injected now. Transport-neutral: any Fetch/WebCrypto consumer composes
|
|
130
|
+
* it directly, without any framework binding.
|
|
131
|
+
*/
|
|
132
|
+
export class Verifier {
|
|
133
|
+
mode;
|
|
134
|
+
resolver;
|
|
135
|
+
now;
|
|
136
|
+
verifyEd25519;
|
|
137
|
+
constructor(mode, resolver, now, verifyEd25519) {
|
|
138
|
+
this.mode = mode;
|
|
139
|
+
this.resolver = resolver;
|
|
140
|
+
this.now = now;
|
|
141
|
+
this.verifyEd25519 = verifyEd25519;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* sort splits offers into verified and rejected per the configured mode. Under
|
|
145
|
+
* "off" every offer is surfaced verified with no check. Under "strict" each offer
|
|
146
|
+
* is verified against its resolved exchange key and its expiry — a failure of
|
|
147
|
+
* either lands it in rejected with the reason.
|
|
148
|
+
*/
|
|
149
|
+
async sort(offers) {
|
|
150
|
+
const result = { verified: [], rejected: [] };
|
|
151
|
+
for (const offer of offers) {
|
|
152
|
+
if (this.mode === "off") {
|
|
153
|
+
result.verified.push(mintVerified(offer));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const reason = await this.check(offer);
|
|
157
|
+
if (reason === undefined) {
|
|
158
|
+
result.verified.push(mintVerified(offer));
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
result.rejected.push(makeRejected(offer, reason));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* sortGroups verifies every group's offers through THIS Verifier and returns the
|
|
168
|
+
* per-URI results, preserving each group's URI and its typed reasons (mirror sdk/go
|
|
169
|
+
* `Verifier.SortGroups`).
|
|
170
|
+
*
|
|
171
|
+
* One Verifier sorts every group deliberately: it is stateless apart from the
|
|
172
|
+
* injected resolver and clock, so a fresh one per group would mean N resolver caches
|
|
173
|
+
* and N clock readings for a single logical answer.
|
|
174
|
+
*
|
|
175
|
+
* A non-object element is SKIPPED rather than surfaced as an empty URI: it is not a
|
|
176
|
+
* group with no offers, it is nothing at all, and inventing an answer the responder
|
|
177
|
+
* never gave is worse than dropping a malformed one.
|
|
178
|
+
*/
|
|
179
|
+
async sortGroups(groups) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const group of groups) {
|
|
182
|
+
if (typeof group !== "object" || group === null || Array.isArray(group)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const rec = group;
|
|
186
|
+
const offers = rec["offers"];
|
|
187
|
+
// The two optional reasons are OMITTED when the responder gave none, rather
|
|
188
|
+
// than set to undefined: under exactOptionalPropertyTypes those are different
|
|
189
|
+
// facts, and "the responder said nothing" is the one being preserved.
|
|
190
|
+
const absenceReason = stringOr(rec["absence_reason"], undefined);
|
|
191
|
+
const discoveryMethod = stringOr(rec["discovery_method"], undefined);
|
|
192
|
+
out.push({
|
|
193
|
+
uri: stringOr(rec["uri"], ""),
|
|
194
|
+
result: await this.sort(Array.isArray(offers) ? offers : []),
|
|
195
|
+
...(absenceReason !== undefined ? { absenceReason } : {}),
|
|
196
|
+
...(discoveryMethod !== undefined ? { discoveryMethod } : {}),
|
|
197
|
+
restrictionFilters: stringList(rec["restriction_filters"]),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
// check verifies a single offer: resolve the exchange offer-signing key, verify
|
|
203
|
+
// the JCS signature, and enforce the not-in-the-past expiry. Any step failing
|
|
204
|
+
// rejects the offer (fail-closed) — including an unresolvable key.
|
|
205
|
+
async check(offer) {
|
|
206
|
+
if (typeof offer !== "object" || offer === null)
|
|
207
|
+
return "offer is not an object";
|
|
208
|
+
const rec = offer;
|
|
209
|
+
const exchange = typeof rec.exchange === "string" ? rec.exchange : "";
|
|
210
|
+
// A resolver that THROWS rejects this offer and nothing else. The shipped
|
|
211
|
+
// resolvers raise on a network failure, and on a Broker fan-out the exchange comes
|
|
212
|
+
// off a relayed offer — so one Exchange whose key endpoint hangs would otherwise
|
|
213
|
+
// deny the agent every offer in the response, as an untyped exception out of a
|
|
214
|
+
// call that promises a typed one. Go returns the resolver's error as this offer's
|
|
215
|
+
// rejection reason and moves on.
|
|
216
|
+
let pub;
|
|
217
|
+
try {
|
|
218
|
+
pub = await this.resolver.resolve(exchange);
|
|
219
|
+
}
|
|
220
|
+
catch (cause) {
|
|
221
|
+
return `no offer-signing key for exchange ${JSON.stringify(exchange)}: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
222
|
+
}
|
|
223
|
+
if (!pub)
|
|
224
|
+
return `no offer-signing key for exchange ${JSON.stringify(exchange)}`;
|
|
225
|
+
const sigHex = typeof rec.signature === "string" ? rec.signature : "";
|
|
226
|
+
const sig = hexToBytes(sigHex);
|
|
227
|
+
if (!sig)
|
|
228
|
+
return "offer signature is not valid hex";
|
|
229
|
+
const payload = canonicalOfferPayload(rec);
|
|
230
|
+
const valid = await this.verifyEd25519(pub, sig, payload);
|
|
231
|
+
if (!valid)
|
|
232
|
+
return "offer signature invalid";
|
|
233
|
+
if (this.expired(rec))
|
|
234
|
+
return "offer expires_at is in the past";
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
// expired mirrors the Go oracle (core.Verifier.expired), fail-closed: an offer
|
|
238
|
+
// with no expires_at, or one whose expires_at cannot be parsed, is treated as
|
|
239
|
+
// EXPIRED — FORA offers are minted now+TTL, so a missing/broken bound is
|
|
240
|
+
// malformed bearer state, never an eternal grant. A present bound is inclusive
|
|
241
|
+
// at now (strictly-before is expired). The wire form is UTC, so an offset-less
|
|
242
|
+
// instant is read as UTC, not host-local.
|
|
243
|
+
expired(rec) {
|
|
244
|
+
const expiresAt = rec.expires_at;
|
|
245
|
+
if (typeof expiresAt !== "string")
|
|
246
|
+
return true;
|
|
247
|
+
const ms = parseUtcMillis(expiresAt);
|
|
248
|
+
if (Number.isNaN(ms))
|
|
249
|
+
return true;
|
|
250
|
+
return ms < this.now();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// parseUtcMillis parses an RFC 3339 instant as UTC epoch-millis. An instant with
|
|
254
|
+
// no timezone designator (no trailing Z / ±hh:mm) is read as UTC — the protobuf
|
|
255
|
+
// Timestamp wire form is always UTC — rather than host-local, which is what
|
|
256
|
+
// Date.parse would silently assume and which diverges from Go/Python.
|
|
257
|
+
function parseUtcMillis(s) {
|
|
258
|
+
const hasTz = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
|
|
259
|
+
return Date.parse(hasTz ? s : `${s}Z`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* createVerifier is the transport-neutral constructor. Mirror of sdk/go
|
|
263
|
+
* core.NewVerifier: the verification mode, offer KeyResolver, and clock are all
|
|
264
|
+
* injected; the Ed25519 primitive defaults to WebCrypto.
|
|
265
|
+
*/
|
|
266
|
+
export function createVerifier(mode, opts) {
|
|
267
|
+
const resolver = { resolve: opts.resolve };
|
|
268
|
+
return new Verifier(mode, resolver, opts.now, opts.verifyEd25519 ?? defaultVerifyEd25519);
|
|
269
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type RequestKeyResolver } from "./verify-request.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The classified multisig reject reason (mirrors the Go taxonomy): "hop_budget"
|
|
4
|
+
* (count exceeds the configured budget), "broken_chain" (labels non-contiguous /
|
|
5
|
+
* reordered / missing or wrong link), and "signature" (any per-hop
|
|
6
|
+
* authenticity/freshness/key/covered-set failure — the default).
|
|
7
|
+
*/
|
|
8
|
+
export type MultisigRejectReason = "signature" | "broken_chain" | "hop_budget";
|
|
9
|
+
/** The injected keyid-keyed verifying-key resolver — every hop's key resolves
|
|
10
|
+
* through it (the SDK owns no keys). Structurally identical to RequestKeyResolver. */
|
|
11
|
+
export type MultisigKeyResolver = RequestKeyResolver;
|
|
12
|
+
/**
|
|
13
|
+
* The request headers a multisig server-verify reads.
|
|
14
|
+
*
|
|
15
|
+
* Read exactly as the single-sig sibling reads them: case-insensitively, joining
|
|
16
|
+
* repeated spellings of one name. Declared as a type alias for the same reason —
|
|
17
|
+
* a real request carries names beyond the ones spelled out below.
|
|
18
|
+
*/
|
|
19
|
+
export type MultisigVerifyHeaders = {
|
|
20
|
+
"content-digest": string;
|
|
21
|
+
"signature-input": string;
|
|
22
|
+
signature: string;
|
|
23
|
+
/**
|
|
24
|
+
* The two covered headers whose value may legitimately be EMPTY. Optional so an
|
|
25
|
+
* ABSENT one is distinguishable from an empty one, which is the whole reason an
|
|
26
|
+
* empty one is put on the wire: the base is rebuilt from the request that ARRIVED,
|
|
27
|
+
* so a name the signature covers and the request does not carry cannot be
|
|
28
|
+
* reconstructed, and defaulting it to "" would invent a value the signer may never
|
|
29
|
+
* have bound. The oracle draws the same line by reading these with Values rather
|
|
30
|
+
* than Get. See docs/design-history.md, "A covered header the peer never receives
|
|
31
|
+
* is not bound".
|
|
32
|
+
*/
|
|
33
|
+
authorization?: string;
|
|
34
|
+
"signature-agent"?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The entitlement-token header (mirrors Go entitlementHeaderLower). When
|
|
37
|
+
* present, EVERY hop's covered set MUST commit to it (enforceEntitlementCoverage
|
|
38
|
+
* runs per hop in Go's verifySingleSignature, which the multisig loop calls per
|
|
39
|
+
* hop); omit/empty when the request carries no entitlement.
|
|
40
|
+
*/
|
|
41
|
+
"x-entitlement-token"?: string;
|
|
42
|
+
};
|
|
43
|
+
/** Inputs for verifyMultisigRequestServer. */
|
|
44
|
+
export interface VerifyMultisigRequestInput {
|
|
45
|
+
method: string;
|
|
46
|
+
url: string;
|
|
47
|
+
body: Uint8Array<ArrayBuffer>;
|
|
48
|
+
headers: MultisigVerifyHeaders;
|
|
49
|
+
resolve: MultisigKeyResolver;
|
|
50
|
+
/** Injected clock returning unix seconds — verify reads time ONLY through this. */
|
|
51
|
+
now: () => number;
|
|
52
|
+
/** The Exchange hop bound: reject a chain longer than this BEFORE any crypto.
|
|
53
|
+
* 0 / omitted means unbounded (mirrors Go opts.MaxSignatures). */
|
|
54
|
+
maxSignatures?: number;
|
|
55
|
+
/** The per-hop signature-lifetime clamp in SECONDS (mirrors Go
|
|
56
|
+
* VerifyOptions.MaxSignatureAge), enforced on EVERY hop exactly like the
|
|
57
|
+
* single-sig path. 0 / omitted means unbounded; the bound is inclusive. */
|
|
58
|
+
maxSignatureAge?: number;
|
|
59
|
+
}
|
|
60
|
+
/** The returned verdict: valid with the verified keyids in chain order, or invalid
|
|
61
|
+
* with the classified reason. Never thrown — always returned. */
|
|
62
|
+
export interface MultisigVerifyVerdict {
|
|
63
|
+
valid: boolean;
|
|
64
|
+
reason?: MultisigRejectReason;
|
|
65
|
+
keyids?: string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Verify an inbound MULTISIG forwarding-chain FORA request; return a reason-tagged
|
|
69
|
+
* verdict carrying the verified keyids in chain order. Enforces the hop budget,
|
|
70
|
+
* then the structural chain, then every hop's signature — in that precedence.
|
|
71
|
+
*/
|
|
72
|
+
export declare function verifyMultisigRequestServer(input: VerifyMultisigRequestInput): Promise<MultisigVerifyVerdict>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// sdk/ts framework-agnostic RFC 9421 MULTISIG forwarding-chain SERVER-verify face
|
|
2
|
+
// — the TS port of Go helpers.VerifyMultisigRequest[Resolved] (verify.go /
|
|
3
|
+
// keyresolver.go). Where core/verify-request.ts is the SINGLE-SIG surface, this is
|
|
4
|
+
// the multi-hop verify a Broker/Exchange built in TS wires when a request carries
|
|
5
|
+
// a forwarding chain: it parses ALL labels, enforces the hop budget
|
|
6
|
+
// FIRST, the structural forwarding chain NEXT, then cryptographically verifies
|
|
7
|
+
// every hop — returning a verdict with the verified keyids in chain order, never
|
|
8
|
+
// throwing.
|
|
9
|
+
//
|
|
10
|
+
// Reject precedence (parity-critical, mirrors Go VerifyMultisigRequest):
|
|
11
|
+
// hop_budget → broken_chain → signature.
|
|
12
|
+
//
|
|
13
|
+
// NO replay: the Go helpers oracle VerifyMultisigRequest[Resolved]
|
|
14
|
+
// performs no replay (replay lives at the connectserver layer), so this face
|
|
15
|
+
// reuses the replay-free per-signature core (verifyParsedSignature) and never
|
|
16
|
+
// touches a ReplayStore. The chain link resolves to the LIVE predecessor bytes, so
|
|
17
|
+
// a stripped, reordered, or tampered predecessor is rejected.
|
|
18
|
+
import { stdBase64 } from "./sign.js";
|
|
19
|
+
import { parseMultisigSignatureInput, signatureBytesByLabel, } from "./multisig-parse.js";
|
|
20
|
+
import { readHeader, requestVerifyFields, verifyParsedSignature, } from "./verify-request.js";
|
|
21
|
+
const rejectMultisig = (reason) => ({
|
|
22
|
+
valid: false,
|
|
23
|
+
reason,
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* enforceSignatureChain — the STRUCTURAL forwarding-chain gate (Go
|
|
27
|
+
* enforceSignatureChain): labels are exactly sig1..sigN contiguous in header
|
|
28
|
+
* order, sig1 carries no "signature" component, and every sigK (K>1) covers
|
|
29
|
+
* exactly one "signature";key="sig(K-1)". Cryptographic binding is the per-hop
|
|
30
|
+
* verify's job; this is structure only.
|
|
31
|
+
*/
|
|
32
|
+
function enforceSignatureChain(members) {
|
|
33
|
+
for (let i = 0; i < members.length; i += 1) {
|
|
34
|
+
const member = members[i];
|
|
35
|
+
if (member.label !== `sig${i + 1}`)
|
|
36
|
+
return false;
|
|
37
|
+
const links = member.covered.filter((c) => c.name === "signature");
|
|
38
|
+
if (i === 0) {
|
|
39
|
+
if (links.length !== 0)
|
|
40
|
+
return false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (links.length !== 1 || links[0]?.chainKey !== `sig${i}`)
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
// chainLinkFor builds the forwarding-chain base line for hop i (>0): the token
|
|
49
|
+
// `"signature";key="sig(i)"` and its value `:<std-base64(live predecessor bytes)>:`
|
|
50
|
+
// resolved from the LIVE Signature member, or undefined if the predecessor bytes
|
|
51
|
+
// are absent (a per-hop signature failure).
|
|
52
|
+
function chainLinkFor(i, sigMap) {
|
|
53
|
+
const prevLabel = `sig${i}`;
|
|
54
|
+
const prevBytes = sigMap[prevLabel];
|
|
55
|
+
if (!prevBytes)
|
|
56
|
+
return undefined;
|
|
57
|
+
return { token: `"signature";key="${prevLabel}"`, value: `:${stdBase64(prevBytes)}:` };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Verify an inbound MULTISIG forwarding-chain FORA request; return a reason-tagged
|
|
61
|
+
* verdict carrying the verified keyids in chain order. Enforces the hop budget,
|
|
62
|
+
* then the structural chain, then every hop's signature — in that precedence.
|
|
63
|
+
*/
|
|
64
|
+
export async function verifyMultisigRequestServer(input) {
|
|
65
|
+
// Every read goes through the case-insensitive, duplicate-joining fold — see
|
|
66
|
+
// readHeader for why both properties are load-bearing.
|
|
67
|
+
const members = parseMultisigSignatureInput([
|
|
68
|
+
readHeader(input.headers, "signature-input") ?? "",
|
|
69
|
+
]);
|
|
70
|
+
if (!members)
|
|
71
|
+
return rejectMultisig("signature");
|
|
72
|
+
const budget = input.maxSignatures ?? 0;
|
|
73
|
+
if (budget > 0 && members.length > budget)
|
|
74
|
+
return rejectMultisig("hop_budget");
|
|
75
|
+
if (!enforceSignatureChain(members))
|
|
76
|
+
return rejectMultisig("broken_chain");
|
|
77
|
+
// A covered header the request never carried is refused here and NOT sooner: the
|
|
78
|
+
// oracle finds it while rebuilding a hop's base, which happens after the budget
|
|
79
|
+
// and the chain are enforced. Checking earlier would answer "signature" to an
|
|
80
|
+
// over-budget or reordered chain that the oracle answers "hop_budget" and
|
|
81
|
+
// "broken_chain" for — the corpus pins all three.
|
|
82
|
+
//
|
|
83
|
+
// The fields carry the entitlement-token header too, so enforceEntitlementCoverage
|
|
84
|
+
// gates EVERY hop (Go runs it inside verifySingleSignature, called per hop).
|
|
85
|
+
const fields = requestVerifyFields(input.method, input.url, input.body, input.headers);
|
|
86
|
+
if (fields === undefined)
|
|
87
|
+
return rejectMultisig("signature");
|
|
88
|
+
const sigMap = signatureBytesByLabel(readHeader(input.headers, "signature") ?? "");
|
|
89
|
+
const nowSec = Math.floor(input.now());
|
|
90
|
+
const keyids = [];
|
|
91
|
+
for (let i = 0; i < members.length; i += 1) {
|
|
92
|
+
const member = members[i];
|
|
93
|
+
let chainLink;
|
|
94
|
+
if (i > 0) {
|
|
95
|
+
chainLink = chainLinkFor(i, sigMap);
|
|
96
|
+
if (!chainLink)
|
|
97
|
+
return rejectMultisig("signature");
|
|
98
|
+
}
|
|
99
|
+
const ok = await verifyParsedSignature(fields, {
|
|
100
|
+
rawParams: member.rawInner,
|
|
101
|
+
covered: new Set(member.covered.map((c) => c.name)),
|
|
102
|
+
keyid: member.keyid,
|
|
103
|
+
alg: member.alg,
|
|
104
|
+
...(member.created !== undefined ? { created: member.created } : {}),
|
|
105
|
+
...(member.expires !== undefined ? { expires: member.expires } : {}),
|
|
106
|
+
}, sigMap[member.label], input.resolve, nowSec, chainLink, input.maxSignatureAge);
|
|
107
|
+
if (!ok || member.keyid === null)
|
|
108
|
+
return rejectMultisig("signature");
|
|
109
|
+
keyids.push(member.keyid);
|
|
110
|
+
}
|
|
111
|
+
return { valid: true, keyids };
|
|
112
|
+
}
|