@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,83 @@
|
|
|
1
|
+
export declare const COVERED_COMPONENTS: readonly ["@method", "@target-uri", "content-digest", "authorization", "signature-agent"];
|
|
2
|
+
/** Inputs for signRequest. All fields required; signatureAgent is "" to pin its absence. */
|
|
3
|
+
export interface SignRequestOptions {
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
body: Uint8Array<ArrayBuffer>;
|
|
7
|
+
authorization: string;
|
|
8
|
+
signatureAgent: string;
|
|
9
|
+
keyid: string;
|
|
10
|
+
created: number;
|
|
11
|
+
expires: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The RFC 9421 artifacts a signed request carries, plus the covered base bytes.
|
|
15
|
+
*
|
|
16
|
+
* EVERY covered header is here, at the value that entered the base — the two whose value
|
|
17
|
+
* may be empty included. A verifier rebuilds the base from the request it received, so a
|
|
18
|
+
* covered value bound but never sent is not bound at all; the oracle's signer reaches the
|
|
19
|
+
* same place by mutating the request it was handed (helpers.SignRequest), which is why it
|
|
20
|
+
* has no such field to omit. See docs/design-history.md, "A covered header the peer never
|
|
21
|
+
* receives is not bound".
|
|
22
|
+
*/
|
|
23
|
+
export interface SignedRequest {
|
|
24
|
+
contentDigest: string;
|
|
25
|
+
signatureInput: string;
|
|
26
|
+
signature: string;
|
|
27
|
+
signatureBase: string;
|
|
28
|
+
/** Echoed from the input so a caller attaching what this returns sends what was
|
|
29
|
+
* signed. Empty is a value, not an absence. */
|
|
30
|
+
authorization: string;
|
|
31
|
+
/** The signer's WBA directory, echoed for the same reason. Empty is the
|
|
32
|
+
* static-bootstrap case and is still carried. */
|
|
33
|
+
signatureAgent: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function contentDigest(body: Uint8Array<ArrayBuffer>): Promise<string>;
|
|
36
|
+
export interface ChainLink {
|
|
37
|
+
token: string;
|
|
38
|
+
value: string;
|
|
39
|
+
}
|
|
40
|
+
/** The covered request field values a 5-component FORA signature base is built over. */
|
|
41
|
+
export interface RequestBaseFields {
|
|
42
|
+
method: string;
|
|
43
|
+
url: string;
|
|
44
|
+
digestHeader: string;
|
|
45
|
+
authorization: string;
|
|
46
|
+
signatureAgent: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The RFC 9421 signature base over the FORA 5-component covered set: one
|
|
50
|
+
* `"name": value` line per component, joined by "\n", terminated by
|
|
51
|
+
* `"@signature-params": <sigParams>` with NO trailing newline. Empty
|
|
52
|
+
* authorization/signature-agent still render (name + ": " + "") — the trailing
|
|
53
|
+
* space is part of the covered bytes and must never be trimmed. Exported and
|
|
54
|
+
* shared with the verify sibling (core/verify-request.ts) so sign and verify
|
|
55
|
+
* reconstruct byte-identical bases; `sigParams` is the VERBATIM inner-list +
|
|
56
|
+
* params tail (the signer's own on sign, the wire's on verify).
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildRequestSignatureBase(fields: RequestBaseFields, sigParams: string, chainLink?: ChainLink): string;
|
|
59
|
+
/**
|
|
60
|
+
* signRequest signs opts over the FORA 5-component covered set with privKey and
|
|
61
|
+
* returns the RFC 9421 Content-Digest, Signature-Input, and Signature header
|
|
62
|
+
* values plus the exact signature base the signature covers. The Signature is
|
|
63
|
+
* `sig1=:<STANDARD-base64(sig)>:` (NOT b64url — do not unify with the thumbprint
|
|
64
|
+
* encoding); Signature-Input is `sig1=` + the same inner the base carries.
|
|
65
|
+
*/
|
|
66
|
+
export declare function signRequest(privKey: CryptoKey, opts: SignRequestOptions): Promise<SignedRequest>;
|
|
67
|
+
/** A request's prior signature state — the Signature-Input / Signature header
|
|
68
|
+
* values already on the request (both "" for an unsigned request). */
|
|
69
|
+
export interface PriorSignatures {
|
|
70
|
+
signatureInput: string;
|
|
71
|
+
signature: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* appendSignature chains sig(N+1) onto `prev` WITHOUT disturbing existing members
|
|
75
|
+
* (forwarding chain), the TS port of Go helpers.AppendSignature. It finds
|
|
76
|
+
* the next label sig(N+1) and predecessor sigN, binds the FORA base plus a
|
|
77
|
+
* `"signature";key="sigN"` link whose value is `:<std-base64(decoded predecessor
|
|
78
|
+
* sig bytes)>:` (re-encoded canonically, NOT a wire splice), Ed25519-signs, and
|
|
79
|
+
* returns the APPENDED Signature-Input / Signature strings. Appending to an
|
|
80
|
+
* unsigned request (empty prev) produces a sig1 byte-for-byte identical to
|
|
81
|
+
* signRequest — single-sig is the N=1 case.
|
|
82
|
+
*/
|
|
83
|
+
export declare function appendSignature(privKey: CryptoKey, prev: PriorSignatures, opts: SignRequestOptions): Promise<SignedRequest>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// sdk/ts outbound RFC 9421 request signer over the full 5-component FORA covered
|
|
2
|
+
// set (@method @target-uri content-digest authorization signature-agent) — the
|
|
3
|
+
// TS sibling of Go helpers.SignRequest and Python httpsig.sign_request. It signs
|
|
4
|
+
// byte-identical to the shared Go oracle so a request signed in TS verifies
|
|
5
|
+
// unchanged at any Go/Python broker/exchange.
|
|
6
|
+
//
|
|
7
|
+
// This is the 5-component sibling of core/sign.ts::signInbound (the 2-component
|
|
8
|
+
// GET PoP). It does NOT reuse pop.ts::signatureBase (hardcoded to 2 components);
|
|
9
|
+
// it renders its own base, mirroring Python fora_sdk/httpsig.py::sign_request.
|
|
10
|
+
//
|
|
11
|
+
// created/expires are INJECTED unix seconds (L1-pure, no wall clock).
|
|
12
|
+
// authorization + signature-agent are ALWAYS bound — an empty string still
|
|
13
|
+
// renders (with the load-bearing trailing space), so a later token/directory
|
|
14
|
+
// injection cannot piggy-back the signature. Values pass through verbatim: the
|
|
15
|
+
// caller supplies an absolute @target-uri; the signer never normalizes it.
|
|
16
|
+
import { opaqueUrl } from "../src/opaque-url.js";
|
|
17
|
+
import { maxSigLabelN, signatureBytesByLabel, } from "./multisig-parse.js";
|
|
18
|
+
import { stdBase64 } from "./sign.js";
|
|
19
|
+
// The FORA request covered set: exactly these five, in this order. No conditional
|
|
20
|
+
// biscuit component — it is bound only when an entitlement header is present,
|
|
21
|
+
// which this signer's inputs never carry. Exported so the verify sibling
|
|
22
|
+
// (core/verify-request.ts) enforces the SAME required set without duplicating it.
|
|
23
|
+
export const COVERED_COMPONENTS = [
|
|
24
|
+
"@method",
|
|
25
|
+
"@target-uri",
|
|
26
|
+
"content-digest",
|
|
27
|
+
"authorization",
|
|
28
|
+
"signature-agent",
|
|
29
|
+
];
|
|
30
|
+
// RFC 9530 Content-Digest header value: `sha-256=:<STANDARD-base64(SHA-256)>:`.
|
|
31
|
+
// Exported so the verify sibling recomputes the digest over the exact body bytes
|
|
32
|
+
// and compares against the covered Content-Digest header byte-for-byte.
|
|
33
|
+
export async function contentDigest(body) {
|
|
34
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", body));
|
|
35
|
+
return `sha-256=:${stdBase64(digest)}:`;
|
|
36
|
+
}
|
|
37
|
+
// The @signature-params inner list: the covered components then keyid/alg/
|
|
38
|
+
// created/expires, RFC 9421 order — byte-identical to Go signatureInputInner. An
|
|
39
|
+
// optional chain-link token appends as the final covered component (sigN, N>1),
|
|
40
|
+
// defaulting absent so the single-sig (N=1) inner list stays byte-identical.
|
|
41
|
+
function signatureParams(keyid, created, expires, chainLinkToken) {
|
|
42
|
+
const tokens = COVERED_COMPONENTS.map((c) => `"${c}"`);
|
|
43
|
+
if (chainLinkToken !== undefined)
|
|
44
|
+
tokens.push(chainLinkToken);
|
|
45
|
+
const covered = tokens.join(" ");
|
|
46
|
+
return `(${covered});keyid="${keyid}";alg="ed25519";created=${created};expires=${expires}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The RFC 9421 signature base over the FORA 5-component covered set: one
|
|
50
|
+
* `"name": value` line per component, joined by "\n", terminated by
|
|
51
|
+
* `"@signature-params": <sigParams>` with NO trailing newline. Empty
|
|
52
|
+
* authorization/signature-agent still render (name + ": " + "") — the trailing
|
|
53
|
+
* space is part of the covered bytes and must never be trimmed. Exported and
|
|
54
|
+
* shared with the verify sibling (core/verify-request.ts) so sign and verify
|
|
55
|
+
* reconstruct byte-identical bases; `sigParams` is the VERBATIM inner-list +
|
|
56
|
+
* params tail (the signer's own on sign, the wire's on verify).
|
|
57
|
+
*/
|
|
58
|
+
export function buildRequestSignatureBase(fields, sigParams, chainLink) {
|
|
59
|
+
const lines = [
|
|
60
|
+
`"@method": ${fields.method.toUpperCase()}`,
|
|
61
|
+
`"@target-uri": ${opaqueUrl(fields.url)}`,
|
|
62
|
+
`"content-digest": ${fields.digestHeader}`,
|
|
63
|
+
`"authorization": ${fields.authorization}`,
|
|
64
|
+
`"signature-agent": ${fields.signatureAgent}`,
|
|
65
|
+
];
|
|
66
|
+
// The chain-link line is the LAST covered component BEFORE @signature-params
|
|
67
|
+
// (Go buildSignatureBase renders params.Covered in order, chain link last).
|
|
68
|
+
if (chainLink !== undefined) {
|
|
69
|
+
lines.push(`${chainLink.token}: ${chainLink.value}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push(`"@signature-params": ${sigParams}`);
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* signRequest signs opts over the FORA 5-component covered set with privKey and
|
|
76
|
+
* returns the RFC 9421 Content-Digest, Signature-Input, and Signature header
|
|
77
|
+
* values plus the exact signature base the signature covers. The Signature is
|
|
78
|
+
* `sig1=:<STANDARD-base64(sig)>:` (NOT b64url — do not unify with the thumbprint
|
|
79
|
+
* encoding); Signature-Input is `sig1=` + the same inner the base carries.
|
|
80
|
+
*/
|
|
81
|
+
export async function signRequest(privKey, opts) {
|
|
82
|
+
const digestHeader = await contentDigest(opts.body);
|
|
83
|
+
const sigParams = signatureParams(opts.keyid, opts.created, opts.expires);
|
|
84
|
+
const base = buildRequestSignatureBase({
|
|
85
|
+
method: opts.method,
|
|
86
|
+
url: opts.url,
|
|
87
|
+
digestHeader,
|
|
88
|
+
authorization: opts.authorization,
|
|
89
|
+
signatureAgent: opts.signatureAgent,
|
|
90
|
+
}, sigParams);
|
|
91
|
+
const sig = await crypto.subtle.sign("Ed25519", privKey, new TextEncoder().encode(base));
|
|
92
|
+
return {
|
|
93
|
+
contentDigest: digestHeader,
|
|
94
|
+
signatureInput: `sig1=${sigParams}`,
|
|
95
|
+
signature: `sig1=:${stdBase64(new Uint8Array(sig))}:`,
|
|
96
|
+
signatureBase: base,
|
|
97
|
+
authorization: opts.authorization,
|
|
98
|
+
signatureAgent: opts.signatureAgent,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* appendSignature chains sig(N+1) onto `prev` WITHOUT disturbing existing members
|
|
103
|
+
* (forwarding chain), the TS port of Go helpers.AppendSignature. It finds
|
|
104
|
+
* the next label sig(N+1) and predecessor sigN, binds the FORA base plus a
|
|
105
|
+
* `"signature";key="sigN"` link whose value is `:<std-base64(decoded predecessor
|
|
106
|
+
* sig bytes)>:` (re-encoded canonically, NOT a wire splice), Ed25519-signs, and
|
|
107
|
+
* returns the APPENDED Signature-Input / Signature strings. Appending to an
|
|
108
|
+
* unsigned request (empty prev) produces a sig1 byte-for-byte identical to
|
|
109
|
+
* signRequest — single-sig is the N=1 case.
|
|
110
|
+
*/
|
|
111
|
+
export async function appendSignature(privKey, prev, opts) {
|
|
112
|
+
const digestHeader = await contentDigest(opts.body);
|
|
113
|
+
const hasPrev = prev.signatureInput !== "";
|
|
114
|
+
const prevN = hasPrev ? maxSigLabelN(prev.signatureInput) : 0;
|
|
115
|
+
const label = `sig${prevN + 1}`;
|
|
116
|
+
let chainLink;
|
|
117
|
+
let chainLinkToken;
|
|
118
|
+
if (prevN > 0) {
|
|
119
|
+
const prevLabel = `sig${prevN}`;
|
|
120
|
+
const prevBytes = signatureBytesByLabel(prev.signature)[prevLabel];
|
|
121
|
+
if (!prevBytes) {
|
|
122
|
+
throw new Error(`appendSignature: predecessor ${prevLabel} not in Signature`);
|
|
123
|
+
}
|
|
124
|
+
chainLinkToken = `"signature";key="${prevLabel}"`;
|
|
125
|
+
chainLink = { token: chainLinkToken, value: `:${stdBase64(prevBytes)}:` };
|
|
126
|
+
}
|
|
127
|
+
const sigParams = signatureParams(opts.keyid, opts.created, opts.expires, chainLinkToken);
|
|
128
|
+
const base = buildRequestSignatureBase({
|
|
129
|
+
method: opts.method,
|
|
130
|
+
url: opts.url,
|
|
131
|
+
digestHeader,
|
|
132
|
+
authorization: opts.authorization,
|
|
133
|
+
signatureAgent: opts.signatureAgent,
|
|
134
|
+
}, sigParams, chainLink);
|
|
135
|
+
const sig = await crypto.subtle.sign("Ed25519", privKey, new TextEncoder().encode(base));
|
|
136
|
+
const memberInput = `${label}=${sigParams}`;
|
|
137
|
+
const memberSig = `${label}=:${stdBase64(new Uint8Array(sig))}:`;
|
|
138
|
+
return {
|
|
139
|
+
contentDigest: digestHeader,
|
|
140
|
+
signatureInput: hasPrev ? `${prev.signatureInput}, ${memberInput}` : memberInput,
|
|
141
|
+
signature: prev.signature !== "" ? `${prev.signature}, ${memberSig}` : memberSig,
|
|
142
|
+
signatureBase: base,
|
|
143
|
+
authorization: opts.authorization,
|
|
144
|
+
signatureAgent: opts.signatureAgent,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Window } from "./window.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Ed25519 sign primitive: (privateKey, message) -> signature. Injected so a
|
|
4
|
+
* non-WebCrypto runtime can supply its own without changing the byte contract.
|
|
5
|
+
* Defaults to WebCrypto crypto.subtle over a CryptoKey.
|
|
6
|
+
*/
|
|
7
|
+
export type Ed25519SignFn = (message: Uint8Array) => Promise<Uint8Array>;
|
|
8
|
+
/** Options for signInbound — the created/expires window + an injectable clock. */
|
|
9
|
+
export interface SignInboundOptions {
|
|
10
|
+
now?: () => number;
|
|
11
|
+
ttlSec?: number;
|
|
12
|
+
/**
|
|
13
|
+
* An injectable signature Window sourcing (created, expires). Defaults to a
|
|
14
|
+
* clockWindow over `now`/`ttlSec` (both floored to integer seconds). Supply a
|
|
15
|
+
* monotonicWindow to keep back-to-back signatures' expires cutoffs unique.
|
|
16
|
+
*/
|
|
17
|
+
window?: Window;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* signInbound produces a genuinely RFC 9421 GET-PoP-signed inbound Request over
|
|
21
|
+
* @method + @target-uri, bound to the agent keypair's RFC 7638 thumbprint (keyid =
|
|
22
|
+
* agent_id). It is the sign side the Hono server-verify binding accepts; it returns
|
|
23
|
+
* a NEW Request carrying the X-FORA-Agent-Key, Signature-Input, and Signature
|
|
24
|
+
* headers (the Fetch Request is immutable — we clone + set headers, never mutate).
|
|
25
|
+
*
|
|
26
|
+
* The covered set is exactly ("@method" "@target-uri"), matching the L1 pop
|
|
27
|
+
* verifier's coversExactly, and the signature base is the L1 signatureBase byte
|
|
28
|
+
* contract, so the produced request verifies through verifyAgentBinding unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export declare function signInbound(kp: CryptoKeyPair, url: string, opts?: SignInboundOptions): Promise<Request>;
|
|
31
|
+
export declare function stdBase64(bytes: Uint8Array): string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// sdk/ts/core sign seam — the client/inbound sign face over the WHATWG Fetch
|
|
2
|
+
// Request, mirror of the Go RoundTripper sign face (transport.go) but adapted to
|
|
3
|
+
// the IMMUTABLE Fetch Request: it returns a NEW signed Request (headers set on a
|
|
4
|
+
// clone), never mutating in place. It signs the RFC 9421 covered components over
|
|
5
|
+
// the EXACT request (for a GET PoP: @method + @target-uri), reusing the L1
|
|
6
|
+
// signatureBase byte contract so the signed bytes are byte-identical to what the
|
|
7
|
+
// L1 pop verifier reconstructs.
|
|
8
|
+
//
|
|
9
|
+
// ZERO framework import (no hono, no connect-es) — only WebCrypto + the L1
|
|
10
|
+
// helpers. The Hono binding (sdk/ts/hono) composes this seam; this seam composes
|
|
11
|
+
// nothing above the web standard.
|
|
12
|
+
import { encodeBase64Url } from "../src/base64url.js";
|
|
13
|
+
import { opaqueUrl } from "../src/opaque-url.js";
|
|
14
|
+
import { AGENT_KEY_HEADER, signatureBase } from "../src/pop.js";
|
|
15
|
+
import { thumbprint } from "../src/thumbprint.js";
|
|
16
|
+
import { clockWindow } from "./window.js";
|
|
17
|
+
// A default signing window (seconds) for the GET PoP created/expires params.
|
|
18
|
+
const DEFAULT_POP_TTL_SEC = 600;
|
|
19
|
+
/**
|
|
20
|
+
* signInbound produces a genuinely RFC 9421 GET-PoP-signed inbound Request over
|
|
21
|
+
* @method + @target-uri, bound to the agent keypair's RFC 7638 thumbprint (keyid =
|
|
22
|
+
* agent_id). It is the sign side the Hono server-verify binding accepts; it returns
|
|
23
|
+
* a NEW Request carrying the X-FORA-Agent-Key, Signature-Input, and Signature
|
|
24
|
+
* headers (the Fetch Request is immutable — we clone + set headers, never mutate).
|
|
25
|
+
*
|
|
26
|
+
* The covered set is exactly ("@method" "@target-uri"), matching the L1 pop
|
|
27
|
+
* verifier's coversExactly, and the signature base is the L1 signatureBase byte
|
|
28
|
+
* contract, so the produced request verifies through verifyAgentBinding unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export async function signInbound(kp, url, opts = {}) {
|
|
31
|
+
const rawPub = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
|
|
32
|
+
const agentId = await thumbprint(rawPub);
|
|
33
|
+
// Source (created, expires) from the injected Window, defaulting to a
|
|
34
|
+
// clockWindow over now (ms → seconds) and ttlSec. clockWindow floors to
|
|
35
|
+
// integer seconds, so the @signature-params bytes stay byte-identical to the
|
|
36
|
+
// historical inline `Math.floor(now()/1000)` mint.
|
|
37
|
+
const ttlSec = opts.ttlSec ?? DEFAULT_POP_TTL_SEC;
|
|
38
|
+
const window = opts.window ??
|
|
39
|
+
clockWindow(() => (opts.now?.() ?? Date.now()) / 1000, ttlSec);
|
|
40
|
+
const [created, expires] = window();
|
|
41
|
+
// The @signature-params inner list the verifier rebuilds verbatim: covered
|
|
42
|
+
// components then keyid/alg/created/expires, RFC 9421 order.
|
|
43
|
+
const rawParams = `("@method" "@target-uri");keyid="${agentId}";alg="ed25519";created=${created};expires=${expires}`;
|
|
44
|
+
// Coerce a URL-like input (a Fastly Compute request URL object) to its opaque
|
|
45
|
+
// string form ONCE at the boundary, so the signed @target-uri and the emitted
|
|
46
|
+
// Request carry the same verbatim bytes. No-op for string callers.
|
|
47
|
+
const target = opaqueUrl(url);
|
|
48
|
+
// The signature base is line-delimited and `target` is written into it
|
|
49
|
+
// verbatim, so a control byte would add or split a component line and the bytes
|
|
50
|
+
// signed here would stop describing the request a verifier reconstructs.
|
|
51
|
+
// Refused rather than escaped, mirroring the Go signer. Checked AFTER the
|
|
52
|
+
// coercion above so it inspects the bytes that actually get signed — and before
|
|
53
|
+
// `new Request(target, …)` below, which would otherwise throw an opaque
|
|
54
|
+
// TypeError instead of naming the reason.
|
|
55
|
+
// Scanned over the UTF-8 BYTES, not the code points, so the reported offset is
|
|
56
|
+
// the same number Go's strings.IndexFunc reports for the same input. Which
|
|
57
|
+
// inputs are refused is unaffected — a control byte is always a single UTF-8
|
|
58
|
+
// byte — but an unlabelled index under identical wording meant three units.
|
|
59
|
+
const badAt = new TextEncoder()
|
|
60
|
+
.encode(target)
|
|
61
|
+
.findIndex((b) => b < 0x20 || b === 0x7f);
|
|
62
|
+
if (badAt !== -1) {
|
|
63
|
+
throw new TypeError(`target URI carries a control byte at byte ${badAt}`);
|
|
64
|
+
}
|
|
65
|
+
const base = signatureBase("GET", target, rawParams);
|
|
66
|
+
const sig = await crypto.subtle.sign("Ed25519", kp.privateKey, new TextEncoder().encode(base));
|
|
67
|
+
const sigStd = stdBase64(new Uint8Array(sig));
|
|
68
|
+
const headers = new Headers();
|
|
69
|
+
headers.set(AGENT_KEY_HEADER, encodeBase64Url(rawPub));
|
|
70
|
+
headers.set("signature-input", `sig1=${rawParams}`);
|
|
71
|
+
headers.set("signature", `sig1=:${sigStd}:`);
|
|
72
|
+
return new Request(target, { method: "GET", headers });
|
|
73
|
+
}
|
|
74
|
+
// stdBase64 encodes bytes as standard (padded) base64 — the RFC 9421 Signature
|
|
75
|
+
// header byte-string encoding the L1 parseSignature decodes. Exported so the
|
|
76
|
+
// 5-component request signer (core/sign-request.ts) shares the exact encoder.
|
|
77
|
+
export function stdBase64(bytes) {
|
|
78
|
+
let bin = "";
|
|
79
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
80
|
+
bin += String.fromCharCode(bytes[i]);
|
|
81
|
+
return btoa(bin);
|
|
82
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type PriorSignatures } from "./sign-request.ts";
|
|
2
|
+
import { type Window } from "./window.ts";
|
|
3
|
+
/** Inputs for signOutbound — the transport-neutral header core. */
|
|
4
|
+
export interface SignOutboundOptions {
|
|
5
|
+
privKey: CryptoKey;
|
|
6
|
+
keyid: string;
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
body: Uint8Array<ArrayBuffer>;
|
|
10
|
+
authorization: string;
|
|
11
|
+
signatureAgent: string;
|
|
12
|
+
window?: Window;
|
|
13
|
+
appendOnly?: boolean;
|
|
14
|
+
prior?: PriorSignatures;
|
|
15
|
+
}
|
|
16
|
+
/** The RFC 9421 header set (plus the untouched body) a signed request carries. */
|
|
17
|
+
export interface SignedOutbound {
|
|
18
|
+
headers: Record<string, string>;
|
|
19
|
+
body: Uint8Array<ArrayBuffer>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* signOutbound computes the RFC 9421 Content-Digest / Signature-Input / Signature
|
|
23
|
+
* headers for one outbound request and returns them with the body untouched — the
|
|
24
|
+
* transport-neutral core (Python SignedOutbound sibling). It orchestrates the
|
|
25
|
+
* parity-locked primitives: appendSignature when appendOnly is set OR a prior
|
|
26
|
+
* Signature is present (forwarding chain), signRequest otherwise.
|
|
27
|
+
*
|
|
28
|
+
* It returns EVERY covered header at the value that entered the signature base, empty
|
|
29
|
+
* values included — see the emit below for why. The keys are LOWERCASE, which is the
|
|
30
|
+
* spelling the corpus records and Python emits, and is what lets a merge over a caller's
|
|
31
|
+
* own headers replace rather than duplicate.
|
|
32
|
+
*/
|
|
33
|
+
export declare function signOutbound(o: SignOutboundOptions): Promise<SignedOutbound>;
|
|
34
|
+
/** The WHATWG-fetch-shaped outbound request the transport inspects / forwards. */
|
|
35
|
+
export interface OutboundInit {
|
|
36
|
+
method?: string;
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
body?: Uint8Array<ArrayBuffer>;
|
|
39
|
+
}
|
|
40
|
+
/** The seam createSigningTransport wraps: a WHATWG-fetch-shaped send(url, init). */
|
|
41
|
+
export type OutboundSend<R> = (url: string, init: OutboundInit) => Promise<R>;
|
|
42
|
+
/** What a sign predicate inspects to decide whether a request is signed. */
|
|
43
|
+
export interface OutboundRequest {
|
|
44
|
+
url: string;
|
|
45
|
+
method: string;
|
|
46
|
+
headers: Record<string, string>;
|
|
47
|
+
body: Uint8Array<ArrayBuffer> | undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Options for createSigningTransport — an idiomatic TS options object, one field per
|
|
51
|
+
* Go WithX (transport.go:47-96). window replaces the default freshness window;
|
|
52
|
+
* appendOnly forces the relay append branch; signatureAgent supplies the covered
|
|
53
|
+
* directory value stamped SET-IF-ABSENT; predicate gates which requests are signed
|
|
54
|
+
* (default: sign every bodied request).
|
|
55
|
+
*/
|
|
56
|
+
export interface SigningTransportOptions {
|
|
57
|
+
privKey: CryptoKey;
|
|
58
|
+
keyid: string;
|
|
59
|
+
window?: Window;
|
|
60
|
+
appendOnly?: boolean;
|
|
61
|
+
signatureAgent?: string;
|
|
62
|
+
predicate?: (req: OutboundRequest) => boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* createSigningTransport wraps a WHATWG-fetch-shaped send and returns a send with the
|
|
66
|
+
* same shape that auto-signs each outbound request: it buffers the body, computes
|
|
67
|
+
* the RFC 9421 headers via signOutbound, merges them, and forwards the SAME body
|
|
68
|
+
* bytes to the wrapped send. A request with no body — or one the predicate
|
|
69
|
+
* excludes — passes through UNSIGNED (there is nothing to bind a Content-Digest
|
|
70
|
+
* to); that is not an error. A request already carrying a Signature is CHAINED
|
|
71
|
+
* onto (appendSignature), never replaced. Naming stays family-consistent with the
|
|
72
|
+
* newXResolver siblings; the whole-surface newX -> create rename is a separate,
|
|
73
|
+
* broader change, not this module's concern.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createSigningTransport<R>(send: OutboundSend<R>, opts: SigningTransportOptions): OutboundSend<R>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// sdk/ts outbound auto-sign transport — the TS sibling of Go
|
|
2
|
+
// core.NewSigningTransport (sdk/go/core/transport.go) and Python
|
|
3
|
+
// SigningTransport / SignedOutbound (sdk/python/fora_sdk/signing_transport.py).
|
|
4
|
+
//
|
|
5
|
+
// Core Invariant: this module is a pure ORCHESTRATION of the already
|
|
6
|
+
// byte-parity-locked primitives signRequest / appendSignature (core/sign-request.ts)
|
|
7
|
+
// and clockWindow / monotonicWindow (core/window.ts). It stamps EVERY covered header
|
|
8
|
+
// at the value that entered the signature base — Content-Digest / Signature-Input /
|
|
9
|
+
// Signature, and the Authorization / Signature-Agent whose values may be empty —
|
|
10
|
+
// byte-identical to the shared Go/Python oracle, forwards the request body UNMODIFIED,
|
|
11
|
+
// and adds NO new crypto and NO new signature-base rendering.
|
|
12
|
+
//
|
|
13
|
+
// Two faces, mirroring Python's transport-neutral shape (the cleaner fit for TS's
|
|
14
|
+
// multi-runtime edge, which has no Go http.RoundTripper):
|
|
15
|
+
// - signOutbound(...): the transport-NEUTRAL header core (Python SignedOutbound
|
|
16
|
+
// sibling) — computes the RFC 9421 headers for one request and returns them
|
|
17
|
+
// with the untouched body. Wraps no client.
|
|
18
|
+
// - createSigningTransport(send, opts): wraps a WHATWG-fetch-shaped outbound seam
|
|
19
|
+
// send(url, init) — buffers the body, computes headers via signOutbound, and
|
|
20
|
+
// forwards the SAME body bytes to send.
|
|
21
|
+
//
|
|
22
|
+
// Signature-Agent is SET-IF-ABSENT (mirror Go transport.go:143-145, NOT Python's
|
|
23
|
+
// always-stamp): TS carries the relay/append chain, where an upstream sig1 already
|
|
24
|
+
// covers its own Signature-Agent value and must never be overwritten.
|
|
25
|
+
import { SignatureAgentHeader } from "../src/wire.js";
|
|
26
|
+
import { appendSignature, signRequest, } from "./sign-request.js";
|
|
27
|
+
import { clockWindow } from "./window.js";
|
|
28
|
+
// The DEFAULT freshness window: clock-derived created + a 5-minute TTL, matching
|
|
29
|
+
// Go's `signWindow = 5 * time.Minute` (transport.go:18) — NOT Python's 600s. The
|
|
30
|
+
// clock reads seconds (Date.now()/1000); clockWindow floors to Go's .Unix().
|
|
31
|
+
const DEFAULT_WINDOW_TTL_SEC = 300;
|
|
32
|
+
function defaultWindow() {
|
|
33
|
+
return clockWindow(() => Date.now() / 1000, DEFAULT_WINDOW_TTL_SEC);
|
|
34
|
+
}
|
|
35
|
+
// Case-insensitive header lookup over a plain header record. Incoming requests may
|
|
36
|
+
// spell header names in any case; the covered values (authorization, prior
|
|
37
|
+
// Signature state, Signature-Agent) must be read regardless of casing.
|
|
38
|
+
function getHeader(headers, name) {
|
|
39
|
+
const lower = name.toLowerCase();
|
|
40
|
+
for (const k of Object.keys(headers)) {
|
|
41
|
+
if (k.toLowerCase() === lower)
|
|
42
|
+
return headers[k];
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* mergeSigned overlays the signed headers onto the caller's, REPLACING any the caller
|
|
48
|
+
* spelled in a different case rather than letting both survive.
|
|
49
|
+
*
|
|
50
|
+
* A plain spread would not: header names are case-insensitive on the wire, JS object keys
|
|
51
|
+
* are not, so a caller's `Authorization` and a signed `authorization` become two field
|
|
52
|
+
* lines. getHeader above already reads case-insensitively; this is the write side
|
|
53
|
+
* agreeing with it. See docs/design-history.md, "A covered header the peer never receives
|
|
54
|
+
* is not bound".
|
|
55
|
+
*/
|
|
56
|
+
function mergeSigned(callerHeaders, signedHeaders) {
|
|
57
|
+
const claimed = new Set(Object.keys(signedHeaders).map((k) => k.toLowerCase()));
|
|
58
|
+
const merged = {};
|
|
59
|
+
for (const [name, value] of Object.entries(callerHeaders)) {
|
|
60
|
+
if (!claimed.has(name.toLowerCase()))
|
|
61
|
+
merged[name] = value;
|
|
62
|
+
}
|
|
63
|
+
return { ...merged, ...signedHeaders };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* signOutbound computes the RFC 9421 Content-Digest / Signature-Input / Signature
|
|
67
|
+
* headers for one outbound request and returns them with the body untouched — the
|
|
68
|
+
* transport-neutral core (Python SignedOutbound sibling). It orchestrates the
|
|
69
|
+
* parity-locked primitives: appendSignature when appendOnly is set OR a prior
|
|
70
|
+
* Signature is present (forwarding chain), signRequest otherwise.
|
|
71
|
+
*
|
|
72
|
+
* It returns EVERY covered header at the value that entered the signature base, empty
|
|
73
|
+
* values included — see the emit below for why. The keys are LOWERCASE, which is the
|
|
74
|
+
* spelling the corpus records and Python emits, and is what lets a merge over a caller's
|
|
75
|
+
* own headers replace rather than duplicate.
|
|
76
|
+
*/
|
|
77
|
+
export async function signOutbound(o) {
|
|
78
|
+
const [created, expires] = (o.window ?? defaultWindow())();
|
|
79
|
+
const signOpts = {
|
|
80
|
+
method: o.method,
|
|
81
|
+
url: o.url,
|
|
82
|
+
body: o.body,
|
|
83
|
+
authorization: o.authorization,
|
|
84
|
+
signatureAgent: o.signatureAgent,
|
|
85
|
+
keyid: o.keyid,
|
|
86
|
+
created,
|
|
87
|
+
expires,
|
|
88
|
+
};
|
|
89
|
+
const prior = o.prior ?? { signatureInput: "", signature: "" };
|
|
90
|
+
const chained = (o.appendOnly ?? false) || prior.signature !== "";
|
|
91
|
+
const signed = chained
|
|
92
|
+
? await appendSignature(o.privKey, prior, signOpts)
|
|
93
|
+
: await signRequest(o.privKey, signOpts);
|
|
94
|
+
// EVERY covered header is emitted, at exactly the value that entered the signature
|
|
95
|
+
// base — empty values included. A verifier rebuilds the base from the request it
|
|
96
|
+
// received, so a value bound but never sent is not bound at all: it reads the covered
|
|
97
|
+
// names off signature-input, finds nothing on the wire under one of them, and refuses.
|
|
98
|
+
// Measured: the covered set binds authorization and signature-agent unconditionally,
|
|
99
|
+
// and a request carrying neither is answered `header "authorization" missing from
|
|
100
|
+
// request` by every conformant verifier — so the ports agreed byte-for-byte with the
|
|
101
|
+
// oracle on what they signed and could not complete a single call.
|
|
102
|
+
//
|
|
103
|
+
// EVERY covered header, at exactly the value that entered the signature base — empty
|
|
104
|
+
// values included. See docs/design-history.md, "A covered header the peer never
|
|
105
|
+
// receives is not bound", for why binding one without sending it is not binding it,
|
|
106
|
+
// and why the emitted key is LOWERCASE (a signed key spelled differently from the
|
|
107
|
+
// caller's survives the merge beside it, putting the name on the wire twice).
|
|
108
|
+
//
|
|
109
|
+
// Taken straight off `signed`, never re-read from `o`: the primitive echoes what it
|
|
110
|
+
// bound, so there is one place the emitted value can come from and no way for the two
|
|
111
|
+
// to drift.
|
|
112
|
+
const headers = {
|
|
113
|
+
"content-digest": signed.contentDigest,
|
|
114
|
+
"signature-input": signed.signatureInput,
|
|
115
|
+
signature: signed.signature,
|
|
116
|
+
authorization: signed.authorization,
|
|
117
|
+
[SignatureAgentHeader.toLowerCase()]: signed.signatureAgent,
|
|
118
|
+
};
|
|
119
|
+
return { headers, body: o.body };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* createSigningTransport wraps a WHATWG-fetch-shaped send and returns a send with the
|
|
123
|
+
* same shape that auto-signs each outbound request: it buffers the body, computes
|
|
124
|
+
* the RFC 9421 headers via signOutbound, merges them, and forwards the SAME body
|
|
125
|
+
* bytes to the wrapped send. A request with no body — or one the predicate
|
|
126
|
+
* excludes — passes through UNSIGNED (there is nothing to bind a Content-Digest
|
|
127
|
+
* to); that is not an error. A request already carrying a Signature is CHAINED
|
|
128
|
+
* onto (appendSignature), never replaced. Naming stays family-consistent with the
|
|
129
|
+
* newXResolver siblings; the whole-surface newX -> create rename is a separate,
|
|
130
|
+
* broader change, not this module's concern.
|
|
131
|
+
*/
|
|
132
|
+
export function createSigningTransport(send, opts) {
|
|
133
|
+
const window = opts.window ?? defaultWindow();
|
|
134
|
+
return async (url, init) => {
|
|
135
|
+
const body = init.body;
|
|
136
|
+
const method = init.method ?? "GET";
|
|
137
|
+
const headers = init.headers ?? {};
|
|
138
|
+
// No body / predicate-excluded: pass through UNSIGNED, body untouched.
|
|
139
|
+
const excluded = opts.predicate !== undefined &&
|
|
140
|
+
!opts.predicate({ url, method, headers, body });
|
|
141
|
+
if (body === undefined || excluded) {
|
|
142
|
+
return send(url, init);
|
|
143
|
+
}
|
|
144
|
+
// Signature-Agent SET-IF-ABSENT: the incoming header (an upstream sig1's
|
|
145
|
+
// covered directory) wins; otherwise the transport stamps its own.
|
|
146
|
+
const existingAgent = getHeader(headers, SignatureAgentHeader);
|
|
147
|
+
const signatureAgent = existingAgent ?? opts.signatureAgent ?? "";
|
|
148
|
+
const prior = {
|
|
149
|
+
signatureInput: getHeader(headers, "signature-input") ?? "",
|
|
150
|
+
signature: getHeader(headers, "signature") ?? "",
|
|
151
|
+
};
|
|
152
|
+
const signed = await signOutbound({
|
|
153
|
+
privKey: opts.privKey,
|
|
154
|
+
keyid: opts.keyid,
|
|
155
|
+
method,
|
|
156
|
+
url,
|
|
157
|
+
body,
|
|
158
|
+
authorization: getHeader(headers, "authorization") ?? "",
|
|
159
|
+
signatureAgent,
|
|
160
|
+
window,
|
|
161
|
+
appendOnly: opts.appendOnly ?? false,
|
|
162
|
+
prior,
|
|
163
|
+
});
|
|
164
|
+
// Forward the SAME body bytes (body integrity — buffer for the digest
|
|
165
|
+
// but never consume/replace the payload; Go resets req.Body + GetBody).
|
|
166
|
+
return send(url, {
|
|
167
|
+
...init,
|
|
168
|
+
headers: mergeSigned(headers, signed.headers),
|
|
169
|
+
body,
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
}
|