@haven_ai/signer 0.0.0-dev.202610071501.0ff4069 → 0.0.0-dev.202610071535.a5affd7
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 +18 -3
- package/dist/cli.cjs +321 -16
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +321 -16
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +309 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -4
- package/dist/index.d.ts +36 -4
- package/dist/index.js +309 -4
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ metadata found in the credential file, and the sign-only tool list. It refuses
|
|
|
61
61
|
to start until acknowledged with either `HAVEN_SIGNER_ACK=<hash>` or
|
|
62
62
|
`npx @haven_ai/signer@alpha --credentials /path/to/haven-agent.json --ack`.
|
|
63
63
|
|
|
64
|
-
It exposes
|
|
64
|
+
It exposes five stdio MCP tools, all sign-only:
|
|
65
65
|
|
|
66
66
|
| Tool | Does | Emits |
|
|
67
67
|
|---|---|---|
|
|
@@ -69,6 +69,7 @@ It exposes four stdio MCP tools, all sign-only:
|
|
|
69
69
|
| `haven_sign_x402` | One-shot x402: funding signature **and** the merchant header in a single local call (`haven_sign` + `haven_x402_sign_header`). `{ payment_id }` alone is the preferred call | `{ signature, x402_binding, payment_header, accepted }` |
|
|
70
70
|
| `haven_x402_sign_header` | Build + sign the EIP-3009 merchant payment header, only when the fresh merchant `payment_required` matches the recorded `x402_binding` | `{ payment_header, accepted }` |
|
|
71
71
|
| `haven_sign_sweep_delegate` | Sign a Haven-prepared gasless EIP-3009 sweep that recovers stranded funds from the delegate wallet back to your own account. Never broadcasts | `{ signature }` |
|
|
72
|
+
| `haven_sign_siwx` | Answer a merchant x402 Sign-In-With-X (SIWX) challenge (#3728): the signer composes the EIP-4361 message ITSELF from the grammar-validated `sign-in-with-x` challenge and the delegate address read from the key — never a caller-supplied message — signs it EIP-191, and returns the finished header. Refuses (`SIWX_REFUSED`) unless the challenge domain matches the `url` host (the FINAL URL after redirects — never follow a redirect with the header or a resulting session token attached), the resource uri origin matches, the URL is https, the nonce is well-formed, the expiry window is ≤ 5 minutes (plus a small clock-skew tolerance), and `supportedChains` offers the credential chain with `eip191`. Moves no funds and grants no spend authority | `{ sign_in_with_x_header }` — retry the merchant with the `SIGN-IN-WITH-X` header set to that value |
|
|
72
73
|
|
|
73
74
|
The `initialize` handshake advertises which binding versions this signer
|
|
74
75
|
understands, under `capabilities.experimental['haven/signer-compatibility']`
|
|
@@ -99,8 +100,11 @@ const { paymentHeader } = await signer.buildX402PaymentHeader(
|
|
|
99
100
|
)
|
|
100
101
|
```
|
|
101
102
|
|
|
102
|
-
The signer also exposes `signSweepAuthorization(input)` for the gasless sweep
|
|
103
|
-
|
|
103
|
+
The signer also exposes `signSweepAuthorization(input)` for the gasless sweep and
|
|
104
|
+
`signSiwxMessage(message)` for #3728's composed EIP-4361 sign-in — the message
|
|
105
|
+
always reaches it composed in-package from grammar-validated challenge fields,
|
|
106
|
+
with the address taken from the key.
|
|
107
|
+
All five are methods on the object `createEdgeSigner` returns, not standalone
|
|
104
108
|
exports. There is NO raw-hash primitive: `signPaymentHash(hash)` (raw ECDSA over
|
|
105
109
|
the retired AllowanceModule rail's hash) was removed in #3169, and
|
|
106
110
|
`signX402FundingHash` (the expected-context v1 bare-hash path) in #3272 — the
|
|
@@ -289,6 +293,17 @@ what a payload means; they re-derive it.
|
|
|
289
293
|
- **A binding version it does not understand.** The refusal is machine-readable
|
|
290
294
|
— `code`, `supported_versions`, `received_version`, `fallback`, and (#3103)
|
|
291
295
|
`next_tool_omitted_reason` — and names updating the signer as the fix.
|
|
296
|
+
- **A sign-in challenge that does not describe the origin it is sent to (#3728).**
|
|
297
|
+
`haven_sign_siwx` composes the EIP-4361 message itself from the challenge's
|
|
298
|
+
grammar-validated fields and the delegate address from the key; a challenge
|
|
299
|
+
whose domain is not the host of the `url` being called, whose resource uri
|
|
300
|
+
origin differs, that is not https, that carries a malformed or missing nonce,
|
|
301
|
+
a `version` other than `"1"`, an expiry window beyond the spec's 5-minute max
|
|
302
|
+
age (plus a small clock-skew tolerance), or that does not offer the credential
|
|
303
|
+
chain with an `eip191` signature type is refused with no signature and no
|
|
304
|
+
audit entry. A caller-supplied message, hash or byte string is never signed —
|
|
305
|
+
a SIWX signature therefore cannot double as a payment authorization, a
|
|
306
|
+
UserOp signature or any other Haven signature.
|
|
292
307
|
- **A sweep that does not move funds out of this delegate's own key** — the
|
|
293
308
|
`from` check is unconditional. The **destination** check is not, and this is
|
|
294
309
|
the one asymmetry in this list: the signer compares the sweep's `to` against
|
package/dist/cli.cjs
CHANGED
|
@@ -154,6 +154,8 @@ function createSigningAuditEntry(tool, payloadHash, context, now = /* @__PURE__
|
|
|
154
154
|
};
|
|
155
155
|
if (context.accountAddress) entry.safe_address = context.accountAddress;
|
|
156
156
|
if (typeof context.chainId === "number") entry.chain_id = context.chainId;
|
|
157
|
+
if (context.domain !== void 0) entry.domain = context.domain;
|
|
158
|
+
if (context.nonce !== void 0) entry.nonce = context.nonce;
|
|
157
159
|
return entry;
|
|
158
160
|
}
|
|
159
161
|
function hashPayloadForAudit(payload) {
|
|
@@ -677,10 +679,237 @@ async function fetchTaskBudgetSignContext(identity, taskBudgetId, fetchImpl = fe
|
|
|
677
679
|
}
|
|
678
680
|
return malformed(`unknown purpose '${String(purpose)}'`);
|
|
679
681
|
}
|
|
682
|
+
var SIWX_REFUSED = "SIWX_REFUSED";
|
|
683
|
+
var HavenSiwxRefusedError = class extends edge.HavenSigningError {
|
|
684
|
+
next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
685
|
+
step;
|
|
686
|
+
constructor(message) {
|
|
687
|
+
super(message);
|
|
688
|
+
this.code = SIWX_REFUSED;
|
|
689
|
+
this.name = "HavenSiwxRefusedError";
|
|
690
|
+
this.step = signerRefusalStep({
|
|
691
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
692
|
+
nextTool: null,
|
|
693
|
+
nextToolOmittedReason: "check the 402 sign-in-with-x challenge and the URL against the refusal above, then call haven_sign_siwx again with corrected inputs \u2014 a challenge that fails validation is never signed"
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
var SIWX_MAX_AGE_SECONDS = 300;
|
|
698
|
+
var SIWX_CLOCK_SKEW_SECONDS = 30;
|
|
699
|
+
var SIWX_VERSION = "1";
|
|
700
|
+
function refuse(message) {
|
|
701
|
+
throw new HavenSiwxRefusedError(message);
|
|
702
|
+
}
|
|
703
|
+
function isPlainObject(value) {
|
|
704
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
705
|
+
}
|
|
706
|
+
function stringField(info, name) {
|
|
707
|
+
const value = info[name];
|
|
708
|
+
if (value === void 0) return void 0;
|
|
709
|
+
if (typeof value !== "string") {
|
|
710
|
+
refuse(`The sign-in-with-x challenge field "${name}" must be a string.`);
|
|
711
|
+
}
|
|
712
|
+
return value;
|
|
713
|
+
}
|
|
714
|
+
function assertSingleLine(value, name) {
|
|
715
|
+
if (/[\n\r]/.test(value)) {
|
|
716
|
+
refuse(`The sign-in-with-x challenge field "${name}" contains a line break; EIP-4361 fields are single-line. Refusing \u2014 a line break could smuggle an attacker-chosen line into the signed message.`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function validateSiwxChallenge(input) {
|
|
720
|
+
const { url, challenge, chainId } = input;
|
|
721
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
722
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
723
|
+
refuse("url is required: the URL the agent is calling, so the signature is bound to that origin.");
|
|
724
|
+
}
|
|
725
|
+
let urlObj;
|
|
726
|
+
try {
|
|
727
|
+
urlObj = new URL(url);
|
|
728
|
+
} catch {
|
|
729
|
+
refuse("url is not a valid absolute URL.");
|
|
730
|
+
}
|
|
731
|
+
if (urlObj.protocol !== "https:") {
|
|
732
|
+
refuse(
|
|
733
|
+
`url must be https \u2014 this signer never signs a sign-in bound to a plain-HTTP origin (got '${urlObj.protocol}//').`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if (!isPlainObject(challenge)) {
|
|
737
|
+
refuse('challenge must be the sign-in-with-x object from the merchant 402 (extensions["sign-in-with-x"]).');
|
|
738
|
+
}
|
|
739
|
+
const hasWrapper = isPlainObject(challenge.info);
|
|
740
|
+
const info = hasWrapper ? challenge.info : challenge;
|
|
741
|
+
const supportedChains = hasWrapper ? challenge.supportedChains : challenge.supportedChains;
|
|
742
|
+
const domain = stringField(info, "domain");
|
|
743
|
+
if (domain === void 0) refuse("The sign-in-with-x challenge carries no domain.");
|
|
744
|
+
assertSingleLine(domain, "domain");
|
|
745
|
+
if (/\s/.test(domain)) {
|
|
746
|
+
refuse("The sign-in-with-x challenge domain is not a valid authority (contains whitespace).");
|
|
747
|
+
}
|
|
748
|
+
if (domain.toLowerCase() !== urlObj.host.toLowerCase()) {
|
|
749
|
+
refuse(
|
|
750
|
+
`The challenge domain '${domain}' does not match the host of the URL being called ('${urlObj.host}'). A sign-in is only valid for the origin that issued it.`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
const uri = stringField(info, "uri");
|
|
754
|
+
if (uri === void 0) refuse("The sign-in-with-x challenge carries no uri.");
|
|
755
|
+
assertSingleLine(uri, "uri");
|
|
756
|
+
let uriObj;
|
|
757
|
+
try {
|
|
758
|
+
uriObj = new URL(uri);
|
|
759
|
+
} catch {
|
|
760
|
+
refuse("The sign-in-with-x challenge uri is not a valid absolute URI.");
|
|
761
|
+
}
|
|
762
|
+
if (uriObj.protocol !== urlObj.protocol || uriObj.host.toLowerCase() !== urlObj.host.toLowerCase()) {
|
|
763
|
+
refuse(
|
|
764
|
+
`The challenge resource uri origin ('${uriObj.protocol}//${uriObj.host}') does not match the origin of the URL being called ('${urlObj.protocol}//${urlObj.host}').`
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
const version = stringField(info, "version");
|
|
768
|
+
if (version === void 0) {
|
|
769
|
+
refuse("The sign-in-with-x challenge carries no version; this signer composes CAIP-122 version 1 only.");
|
|
770
|
+
}
|
|
771
|
+
if (version !== SIWX_VERSION) {
|
|
772
|
+
refuse(
|
|
773
|
+
`The sign-in-with-x challenge version is '${version}'; this signer composes version '${SIWX_VERSION}' only. Update @haven_ai/signer if the spec has moved on.`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const nonce = stringField(info, "nonce");
|
|
777
|
+
if (nonce === void 0) {
|
|
778
|
+
refuse("The sign-in-with-x challenge carries no nonce \u2014 refusing: the nonce is what makes the sign-in single-use.");
|
|
779
|
+
}
|
|
780
|
+
if (!/^[A-Za-z0-9]{8,}$/.test(nonce)) {
|
|
781
|
+
refuse("The sign-in-with-x challenge nonce fails the EIP-4361 grammar (alphanumeric, at least 8 characters).");
|
|
782
|
+
}
|
|
783
|
+
const issuedAt = stringField(info, "issuedAt");
|
|
784
|
+
if (issuedAt === void 0) refuse("The sign-in-with-x challenge carries no issuedAt timestamp.");
|
|
785
|
+
assertSingleLine(issuedAt, "issuedAt");
|
|
786
|
+
if (Number.isNaN(Date.parse(issuedAt))) {
|
|
787
|
+
refuse("The sign-in-with-x challenge issuedAt is not a parseable timestamp.");
|
|
788
|
+
}
|
|
789
|
+
const expirationTime = stringField(info, "expirationTime");
|
|
790
|
+
if (expirationTime === void 0) {
|
|
791
|
+
refuse(
|
|
792
|
+
"The sign-in-with-x challenge carries no absolute expirationTime. A relative expirationSeconds is not accepted by this signer \u2014 only a challenge with an absolute expiry window the signer can bound is signed."
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
assertSingleLine(expirationTime, "expirationTime");
|
|
796
|
+
const expiresAtMs = Date.parse(expirationTime);
|
|
797
|
+
if (Number.isNaN(expiresAtMs)) {
|
|
798
|
+
refuse("The sign-in-with-x challenge expirationTime is not a parseable timestamp.");
|
|
799
|
+
}
|
|
800
|
+
if (expiresAtMs < now.getTime() - SIWX_CLOCK_SKEW_SECONDS * 1e3) {
|
|
801
|
+
refuse("The sign-in-with-x challenge has already expired (expirationTime is in the past, beyond the clock-skew tolerance). Nothing was signed.");
|
|
802
|
+
}
|
|
803
|
+
if (expiresAtMs - now.getTime() > (SIWX_MAX_AGE_SECONDS + SIWX_CLOCK_SKEW_SECONDS) * 1e3) {
|
|
804
|
+
refuse(
|
|
805
|
+
`The sign-in-with-x challenge expiry window is longer than the spec's ${SIWX_MAX_AGE_SECONDS}-second max age (beyond the clock-skew tolerance). Refusing to sign a long-lived challenge.`
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
const statement = stringField(info, "statement");
|
|
809
|
+
if (statement !== void 0) assertSingleLine(statement, "statement");
|
|
810
|
+
const notBefore = stringField(info, "notBefore");
|
|
811
|
+
if (notBefore !== void 0) {
|
|
812
|
+
assertSingleLine(notBefore, "notBefore");
|
|
813
|
+
if (Number.isNaN(Date.parse(notBefore))) {
|
|
814
|
+
refuse("The sign-in-with-x challenge notBefore is not a parseable timestamp.");
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const requestId = stringField(info, "requestId");
|
|
818
|
+
if (requestId !== void 0) assertSingleLine(requestId, "requestId");
|
|
819
|
+
let resources;
|
|
820
|
+
if (info.resources !== void 0) {
|
|
821
|
+
if (!Array.isArray(info.resources) || info.resources.some((r) => typeof r !== "string")) {
|
|
822
|
+
refuse("The sign-in-with-x challenge resources must be an array of URI strings.");
|
|
823
|
+
}
|
|
824
|
+
for (const resource of info.resources) {
|
|
825
|
+
assertSingleLine(resource, "resources[]");
|
|
826
|
+
try {
|
|
827
|
+
new URL(resource);
|
|
828
|
+
} catch {
|
|
829
|
+
refuse(`The sign-in-with-x challenge resources entry '${resource}' is not a valid absolute URI.`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
resources = info.resources;
|
|
833
|
+
}
|
|
834
|
+
if (typeof chainId !== "number" || !Number.isInteger(chainId) || chainId <= 0) {
|
|
835
|
+
refuse(
|
|
836
|
+
"This signer credential carries no chain id, so the signer cannot check the challenge against the chain its delegate key pays on. Re-run the connector to refresh the credential."
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
const chainIdCaip2 = `eip155:${chainId}`;
|
|
840
|
+
if (!Array.isArray(supportedChains) || supportedChains.length === 0) {
|
|
841
|
+
refuse("The sign-in-with-x challenge carries no supportedChains array.");
|
|
842
|
+
}
|
|
843
|
+
const matched = supportedChains.some(
|
|
844
|
+
(entry) => isPlainObject(entry) && typeof entry.chainId === "string" && entry.chainId.toLowerCase() === chainIdCaip2 && entry.type === "eip191"
|
|
845
|
+
);
|
|
846
|
+
if (!matched) {
|
|
847
|
+
refuse(
|
|
848
|
+
`The challenge's supportedChains does not offer the credential chain ${chainIdCaip2} with an eip191 signature type \u2014 the delegate EOA cannot answer this challenge.`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
domain,
|
|
853
|
+
uri,
|
|
854
|
+
version,
|
|
855
|
+
nonce,
|
|
856
|
+
issuedAt,
|
|
857
|
+
expirationTime,
|
|
858
|
+
statement,
|
|
859
|
+
notBefore,
|
|
860
|
+
requestId,
|
|
861
|
+
resources,
|
|
862
|
+
chainIdCaip2,
|
|
863
|
+
numericChainId: chainId
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
function composeSiwxMessage(fields, address) {
|
|
867
|
+
const suffixLines = [
|
|
868
|
+
`URI: ${fields.uri}`,
|
|
869
|
+
`Version: ${fields.version ?? SIWX_VERSION}`,
|
|
870
|
+
`Chain ID: ${fields.numericChainId}`,
|
|
871
|
+
`Nonce: ${fields.nonce}`,
|
|
872
|
+
`Issued At: ${fields.issuedAt}`
|
|
873
|
+
];
|
|
874
|
+
if (fields.expirationTime !== void 0) suffixLines.push(`Expiration Time: ${fields.expirationTime}`);
|
|
875
|
+
if (fields.notBefore !== void 0) suffixLines.push(`Not Before: ${fields.notBefore}`);
|
|
876
|
+
if (fields.requestId !== void 0) suffixLines.push(`Request ID: ${fields.requestId}`);
|
|
877
|
+
if (fields.resources !== void 0) {
|
|
878
|
+
suffixLines.push(["Resources:", ...fields.resources.map((r) => `- ${r}`)].join("\n"));
|
|
879
|
+
}
|
|
880
|
+
let head = [`${fields.domain} wants you to sign in with your Ethereum account:`, address].join("\n");
|
|
881
|
+
if (fields.statement === void 0) {
|
|
882
|
+
head += "\n\n";
|
|
883
|
+
} else {
|
|
884
|
+
head = [head, fields.statement].join("\n\n") + "\n";
|
|
885
|
+
}
|
|
886
|
+
return [head, suffixLines.join("\n")].join("\n");
|
|
887
|
+
}
|
|
888
|
+
function buildSiwxPayload(fields, address, signature) {
|
|
889
|
+
return {
|
|
890
|
+
domain: fields.domain,
|
|
891
|
+
address,
|
|
892
|
+
statement: fields.statement,
|
|
893
|
+
uri: fields.uri,
|
|
894
|
+
version: fields.version ?? SIWX_VERSION,
|
|
895
|
+
chainId: fields.chainIdCaip2,
|
|
896
|
+
type: "eip191",
|
|
897
|
+
nonce: fields.nonce,
|
|
898
|
+
issuedAt: fields.issuedAt,
|
|
899
|
+
expirationTime: fields.expirationTime,
|
|
900
|
+
notBefore: fields.notBefore,
|
|
901
|
+
requestId: fields.requestId,
|
|
902
|
+
resources: fields.resources,
|
|
903
|
+
signature
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function encodeSiwxHeader(payload) {
|
|
907
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
908
|
+
}
|
|
680
909
|
|
|
681
910
|
// src/tools.ts
|
|
682
911
|
var SIGNER_NAME = "@haven_ai/signer";
|
|
683
|
-
var SIGNER_VERSION = "0.0.0-dev.
|
|
912
|
+
var SIGNER_VERSION = "0.0.0-dev.202610071535.a5affd7";
|
|
684
913
|
var UNSUPPORTED_ARGUMENT_MARKER = "HavenSignerUnsupportedArgument:";
|
|
685
914
|
var USEROP_BINDING_MISMATCH = "USEROP_BINDING_MISMATCH";
|
|
686
915
|
var HavenUserOpBindingRefusedError = class extends edge.HavenSigningError {
|
|
@@ -843,6 +1072,18 @@ var toolSchemas = {
|
|
|
843
1072
|
typed_data: v3.z.record(v3.z.string(), v3.z.unknown()).optional(),
|
|
844
1073
|
// #1255: see haven_sign.typed_data_b64 — the copy-through-safe form.
|
|
845
1074
|
typed_data_b64: v3.z.string().min(1).max(262144).optional()
|
|
1075
|
+
},
|
|
1076
|
+
haven_sign_siwx: {
|
|
1077
|
+
// #3728: the https URL the agent is calling — the challenge's domain and
|
|
1078
|
+
// resource uri are pinned to THIS origin before anything is signed.
|
|
1079
|
+
url: v3.z.string().min(1),
|
|
1080
|
+
// The merchant's sign-in-with-x challenge. Accepted as EITHER the 402
|
|
1081
|
+
// extension object (payment_required.extensions['sign-in-with-x'] —
|
|
1082
|
+
// { info, supportedChains, schema? }) or the flattened info object the
|
|
1083
|
+
// x402 reference client builds ({ ...info, chainId, type }). Validated
|
|
1084
|
+
// field by field in siwx.ts; the signer composes the EIP-4361 message
|
|
1085
|
+
// itself and never signs a caller-supplied message.
|
|
1086
|
+
challenge: v3.z.record(v3.z.string(), v3.z.unknown())
|
|
846
1087
|
}
|
|
847
1088
|
// #3101: keys survive on the type (see the hosted server's contracts.ts).
|
|
848
1089
|
};
|
|
@@ -923,8 +1164,28 @@ var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
|
|
|
923
1164
|
"when the credential carries one, and otherwise rests on Haven's binding signature. Then it",
|
|
924
1165
|
"returns { signature } to hand back to mcp__haven__haven_sweep_delegate to complete recovery."
|
|
925
1166
|
].join(" ");
|
|
1167
|
+
var SIGN_SIWX_DESCRIPTION = [
|
|
1168
|
+
"Answer a merchant x402 Sign-In-With-X (SIWX) sign-in challenge with the local delegate key (#3728).",
|
|
1169
|
+
"Use it when a merchant 402 carries a sign-in-with-x extension, or when the merchant documents a",
|
|
1170
|
+
"sign-in route (CAIP-122): pass url \u2014 the FINAL URL the header will be sent to, i.e. after any",
|
|
1171
|
+
"redirects, not the originally requested one \u2014 and challenge, the sign-in-with-x object from the",
|
|
1172
|
+
"402 (extensions['sign-in-with-x'], or the flattened info object). NEVER follow a redirect with",
|
|
1173
|
+
"the SIGN-IN-WITH-X header (or a resulting session token) attached: re-sign for the final origin.",
|
|
1174
|
+
"This signer composes the EIP-4361 message ITSELF from the validated challenge fields and the",
|
|
1175
|
+
"delegate address read from the key \u2014 it never signs a caller-supplied message, and an address",
|
|
1176
|
+
"inside the challenge cannot change whose wallet signs in. It refuses (SIWX_REFUSED) unless the",
|
|
1177
|
+
"challenge domain matches the URL host, its resource uri origin matches the URL origin, the URL is",
|
|
1178
|
+
"https, the nonce is well-formed, the expiry window is at most 5 minutes (plus a small clock-skew",
|
|
1179
|
+
"tolerance), and supportedChains offers the credential chain with an eip191 signature type. This",
|
|
1180
|
+
"tool MOVES NO FUNDS and grants no spend authority: it only proves the delegate wallet is the one",
|
|
1181
|
+
"that paid the merchant. Returns { sign_in_with_x_header } \u2014 retry the merchant request with the",
|
|
1182
|
+
"SIGN-IN-WITH-X header set to that value. The merchant then recognises the delegate wallet as a",
|
|
1183
|
+
"paying customer (a session it mints typically outlives the 5-minute signature; what the session",
|
|
1184
|
+
"reaches is merchant-defined)."
|
|
1185
|
+
].join(" ");
|
|
926
1186
|
var toolDescriptions = {
|
|
927
1187
|
haven_sign: SIGN_DESCRIPTION,
|
|
1188
|
+
haven_sign_siwx: SIGN_SIWX_DESCRIPTION,
|
|
928
1189
|
haven_x402_sign_header: X402_SIGN_HEADER_DESCRIPTION,
|
|
929
1190
|
haven_sign_x402: SIGN_X402_DESCRIPTION,
|
|
930
1191
|
haven_sign_sweep_delegate: SIGN_SWEEP_DELEGATE_DESCRIPTION
|
|
@@ -1313,15 +1574,35 @@ function createToolHandlers(signer, options = {}) {
|
|
|
1313
1574
|
hashPayloadForAudit(args.authorization)
|
|
1314
1575
|
);
|
|
1315
1576
|
return { signature: result.signature };
|
|
1577
|
+
}),
|
|
1578
|
+
haven_sign_siwx: async (input) => runTool(async () => {
|
|
1579
|
+
const args = parseStrictFor("haven_sign_siwx", input);
|
|
1580
|
+
const chainId = options.audit?.chainId;
|
|
1581
|
+
const fields = validateSiwxChallenge({
|
|
1582
|
+
url: args.url,
|
|
1583
|
+
challenge: args.challenge,
|
|
1584
|
+
chainId
|
|
1585
|
+
});
|
|
1586
|
+
const message = composeSiwxMessage(fields, signer.delegateAddress);
|
|
1587
|
+
const signature = await signer.signSiwxMessage(message);
|
|
1588
|
+
const header = encodeSiwxHeader(
|
|
1589
|
+
buildSiwxPayload(fields, signer.delegateAddress, signature)
|
|
1590
|
+
);
|
|
1591
|
+
await auditSigning("haven_sign_siwx", hashPayloadForAudit(message), {
|
|
1592
|
+
domain: fields.domain,
|
|
1593
|
+
nonce: fields.nonce
|
|
1594
|
+
});
|
|
1595
|
+
return { sign_in_with_x_header: header };
|
|
1316
1596
|
})
|
|
1317
1597
|
};
|
|
1318
|
-
async function auditSigning(tool, payloadHash) {
|
|
1598
|
+
async function auditSigning(tool, payloadHash, extra = {}) {
|
|
1319
1599
|
if (!options.audit) return;
|
|
1320
1600
|
const { auditPath, ...context } = options.audit;
|
|
1321
1601
|
try {
|
|
1322
1602
|
await appendSigningAuditEntry(
|
|
1323
1603
|
createSigningAuditEntry(tool, payloadHash, {
|
|
1324
1604
|
...context,
|
|
1605
|
+
...extra,
|
|
1325
1606
|
delegateAddress: signer.delegateAddress
|
|
1326
1607
|
}),
|
|
1327
1608
|
auditPath
|
|
@@ -1476,6 +1757,15 @@ function normalizeError(err) {
|
|
|
1476
1757
|
...err.next_tool_omitted_reason ? { next_tool_omitted_reason: err.next_tool_omitted_reason } : {}
|
|
1477
1758
|
};
|
|
1478
1759
|
}
|
|
1760
|
+
if (err instanceof HavenSiwxRefusedError) {
|
|
1761
|
+
return {
|
|
1762
|
+
success: false,
|
|
1763
|
+
code: err.code,
|
|
1764
|
+
message: err.message,
|
|
1765
|
+
next_action: err.next_action,
|
|
1766
|
+
...nextStepWireFields(err.step)
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1479
1769
|
if (err instanceof edge.HavenSigningError) {
|
|
1480
1770
|
return { success: false, code: err.code, message: err.message };
|
|
1481
1771
|
}
|
|
@@ -1512,6 +1802,7 @@ function normalizeError(err) {
|
|
|
1512
1802
|
// src/consent.ts
|
|
1513
1803
|
var toolSummaries = {
|
|
1514
1804
|
haven_sign: "signs a Haven-prepared payment, task-budget or sub-budget open or close (a UserOp from your own account redeeming a delegation made to it, directly or via a task-budget child; a sub-budget child narrowing your budget for another agent of yours; or an x402 funding intent against a Haven-signed context)",
|
|
1805
|
+
haven_sign_siwx: "signs a merchant x402 Sign-In-With-X sign-in challenge as the delegate wallet \u2014 proves the wallet paid that merchant, moves no funds and grants no spend authority",
|
|
1515
1806
|
haven_x402_sign_header: "builds and signs the EIP-3009 merchant payment header for an x402 payment",
|
|
1516
1807
|
haven_sign_x402: "the two above in one call: funding payload + merchant header",
|
|
1517
1808
|
haven_sign_sweep_delegate: "signs a Haven-prepared gasless sweep of stranded funds back to your own account"
|
|
@@ -1551,7 +1842,10 @@ function renderSignerConsentBlock(input, hash) {
|
|
|
1551
1842
|
if (input.network) lines.push(`Network: ${input.network}`);
|
|
1552
1843
|
lines.push("");
|
|
1553
1844
|
lines.push("This local signer holds the delegate key on this machine and signs");
|
|
1554
|
-
lines.push("payment payloads
|
|
1845
|
+
lines.push("payment payloads, x402 merchant headers, or x402 Sign-In-With-X sign-in");
|
|
1846
|
+
lines.push("challenges for the delegate address above. A sign-in challenge proves to a");
|
|
1847
|
+
lines.push("merchant that this wallet paid it before; it moves no funds and grants no");
|
|
1848
|
+
lines.push("spend authority.");
|
|
1555
1849
|
lines.push("Its one network use is a read-only fetch of a pending payment's signing");
|
|
1556
1850
|
lines.push("payload from Haven, authenticated with the agent credential stored next to");
|
|
1557
1851
|
lines.push("this signer's key file - it never sends the key, a signature, or anything");
|
|
@@ -1567,7 +1861,7 @@ function renderSignerConsentBlock(input, hash) {
|
|
|
1567
1861
|
lines.push("");
|
|
1568
1862
|
lines.push("A local audit entry is appended for every signing operation. Audit entries");
|
|
1569
1863
|
lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
|
|
1570
|
-
lines.push("signature,
|
|
1864
|
+
lines.push("signature, x402 payment header, or sign-in message.");
|
|
1571
1865
|
lines.push("");
|
|
1572
1866
|
lines.push(`Consent hash: ${hash}`);
|
|
1573
1867
|
lines.push("");
|
|
@@ -1883,6 +2177,10 @@ function createEdgeSigner(delegateKey, options = {}) {
|
|
|
1883
2177
|
);
|
|
1884
2178
|
}
|
|
1885
2179
|
return { signature };
|
|
2180
|
+
},
|
|
2181
|
+
async signSiwxMessage(message) {
|
|
2182
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
2183
|
+
return account.signMessage({ message });
|
|
1886
2184
|
}
|
|
1887
2185
|
};
|
|
1888
2186
|
}
|
|
@@ -2060,6 +2358,13 @@ function signerInstructions() {
|
|
|
2060
2358
|
"#3271, and delegation-rail x402) or haven_sign_x402 (x402 only) instead of relaying bulky",
|
|
2061
2359
|
"typed-data payloads yourself.",
|
|
2062
2360
|
"",
|
|
2361
|
+
"Sign-In-With-X (#3728): when a merchant 402 carries a sign-in-with-x extension (or the",
|
|
2362
|
+
"merchant documents a CAIP-122 sign-in route), call haven_sign_siwx with { url, challenge }.",
|
|
2363
|
+
"The signer composes the EIP-4361 message itself from the validated challenge and signs it with",
|
|
2364
|
+
"the delegate key \u2014 it returns { sign_in_with_x_header }; retry the merchant with the",
|
|
2365
|
+
"SIGN-IN-WITH-X header set to that value. This proves the delegate wallet paid the merchant; it",
|
|
2366
|
+
"moves no funds and cannot approve any payment.",
|
|
2367
|
+
"",
|
|
2063
2368
|
"Version compatibility (check this BEFORE signing, not after):",
|
|
2064
2369
|
`- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
|
|
2065
2370
|
`- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
|
|
@@ -2090,16 +2395,16 @@ function signerInstructions() {
|
|
|
2090
2395
|
}
|
|
2091
2396
|
async function loadSignerCredentials(path = process.env.HAVEN_CREDENTIALS) {
|
|
2092
2397
|
if (path) return loadFromFile(path);
|
|
2093
|
-
const envKey =
|
|
2398
|
+
const envKey = stringField2(process.env.HAVEN_DELEGATE_KEY);
|
|
2094
2399
|
if (envKey) {
|
|
2095
2400
|
const accountAddress = readAccountAddressEnv(process.env);
|
|
2096
2401
|
return {
|
|
2097
2402
|
delegateKey: envKey,
|
|
2098
|
-
agentId:
|
|
2403
|
+
agentId: stringField2(process.env.HAVEN_AGENT_ID),
|
|
2099
2404
|
accountAddress,
|
|
2100
2405
|
chainId: chainIdField(process.env.HAVEN_CHAIN_ID, "HAVEN_CHAIN_ID"),
|
|
2101
|
-
network:
|
|
2102
|
-
x402BindingSigner:
|
|
2406
|
+
network: stringField2(process.env.HAVEN_NETWORK),
|
|
2407
|
+
x402BindingSigner: stringField2(process.env.HAVEN_X402_BINDING_SIGNER)
|
|
2103
2408
|
};
|
|
2104
2409
|
}
|
|
2105
2410
|
throw new Error(
|
|
@@ -2122,30 +2427,30 @@ async function loadFromFile(path) {
|
|
|
2122
2427
|
} catch {
|
|
2123
2428
|
throw new Error("Haven credentials must be JSON with a delegate_key field.");
|
|
2124
2429
|
}
|
|
2125
|
-
const delegateKey =
|
|
2430
|
+
const delegateKey = stringField2(raw.delegate_key ?? raw.delegateKey);
|
|
2126
2431
|
if (!delegateKey) {
|
|
2127
2432
|
throw new Error("Haven credentials are missing delegate_key \u2014 the edge signer needs it to sign.");
|
|
2128
2433
|
}
|
|
2129
2434
|
const accountAddress = readAccountAddressField(raw);
|
|
2130
2435
|
return {
|
|
2131
2436
|
delegateKey,
|
|
2132
|
-
agentId:
|
|
2437
|
+
agentId: stringField2(raw.agent_id ?? raw.agentId),
|
|
2133
2438
|
accountAddress,
|
|
2134
2439
|
chainId: chainIdField(raw.chain_id ?? raw.chainId, "chain_id"),
|
|
2135
|
-
network:
|
|
2136
|
-
x402BindingSigner:
|
|
2440
|
+
network: stringField2(raw.network),
|
|
2441
|
+
x402BindingSigner: stringField2(
|
|
2137
2442
|
raw.x402_binding_signer ?? raw.x402BindingSigner ?? process.env.HAVEN_X402_BINDING_SIGNER
|
|
2138
2443
|
),
|
|
2139
2444
|
sourcePath: path
|
|
2140
2445
|
};
|
|
2141
2446
|
}
|
|
2142
2447
|
function readAccountAddressField(raw) {
|
|
2143
|
-
return
|
|
2448
|
+
return stringField2(raw.account_address ?? raw.safe_address ?? raw.safeAddress);
|
|
2144
2449
|
}
|
|
2145
2450
|
function readAccountAddressEnv(env) {
|
|
2146
|
-
const current =
|
|
2451
|
+
const current = stringField2(env.HAVEN_ACCOUNT_ADDRESS);
|
|
2147
2452
|
for (const name of ["HAVEN_WALLET_ADDRESS", "HAVEN_SAFE_ADDRESS"]) {
|
|
2148
|
-
const retired =
|
|
2453
|
+
const retired = stringField2(env[name]);
|
|
2149
2454
|
if (!retired) continue;
|
|
2150
2455
|
if (!current) {
|
|
2151
2456
|
throw new Error(
|
|
@@ -2160,7 +2465,7 @@ function readAccountAddressEnv(env) {
|
|
|
2160
2465
|
}
|
|
2161
2466
|
return current;
|
|
2162
2467
|
}
|
|
2163
|
-
function
|
|
2468
|
+
function stringField2(value) {
|
|
2164
2469
|
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
2165
2470
|
}
|
|
2166
2471
|
function chainIdField(value, label) {
|