thaifi-wallet-cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ Set `THAIFI_WALLET_URL` / `THAIFI_RPC_URL` to target other deployments.
44
44
  | `tokens` | List supported TIP-20 tokens (symbol, decimals, address) |
45
45
  | `balance [--token <symbol\|addr>]` | Balances (default: all supported tokens) |
46
46
  | `transfer <to> <amount> [--token <symbol\|addr>]` | Send tokens (default: pathUSD; limit enforced on-chain) |
47
- | `request <url> [-X -H -d --json --dry-run --max-spend]` | HTTP with automatic x402/MPP payment |
47
+ | `request <url> [-X -H -d --json --dry-run --max-spend]` | HTTP with automatic x402/MPP payment **and ThaiFiAgent identity auth (401/403 answered by signing the challenge)** |
48
48
  | `services [--search q]` | Discover services from the MPP registry (`THAIFI_MPP_REGISTRY`) |
49
49
  | `logout` | Remove the local key (revoke on-chain from the web wallet) |
50
50
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * ThaiFi Agent Auth (TAA) — client-side helper.
3
+ *
4
+ * Signs a server challenge with the agent access key (P256, keychain
5
+ * envelope binding the wallet account) so the server can verify on-chain
6
+ * that this key is authorized (AccountKeychain, TIP-1011). Server-side
7
+ * reference implementation: thaifi/mpp repo, src/agentAuth.ts.
8
+ *
9
+ * Signed hash (must match the server byte-for-byte):
10
+ * keccak256(tag4("thaifi-agent-auth") ‖ 0x01 ‖ keccak256(realm)[0:10]
11
+ * ‖ nonceBytes ‖ utf8(METHOD) ‖ utf8(pathWithQuery)
12
+ * ‖ sha256(body) | 0x00*32 ‖ uint64be(timestamp))
13
+ */
14
+ import { concatHex, keccak256, sha256, slice, stringToBytes, stringToHex, toHex } from "viem";
15
+ import { Account } from "viem/tempo";
16
+ const AUTH_TAG = slice(keccak256(stringToBytes("thaifi-agent-auth")), 0, 4);
17
+ const ZERO_BODY_HASH = `0x${"00".repeat(32)}`;
18
+ function b64uDecodeBytes(value) {
19
+ return new Uint8Array(Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
20
+ }
21
+ export function agentAuthHash(params) {
22
+ const bodyHash = params.body && params.body.length > 0 ? sha256(stringToBytes(params.body)) : ZERO_BODY_HASH;
23
+ return keccak256(concatHex([
24
+ AUTH_TAG,
25
+ "0x01",
26
+ slice(keccak256(stringToBytes(params.realm)), 0, 10),
27
+ params.nonce,
28
+ stringToHex(params.method.toUpperCase()),
29
+ stringToHex(params.path),
30
+ bodyHash,
31
+ toHex(BigInt(params.ts), { size: 8 }),
32
+ ]));
33
+ }
34
+ /** Sign the ThaiFiAgent challenge → the b64u credential for the retry header. */
35
+ export async function buildAgentAuthCredential(store, challenge, req) {
36
+ if (!store.userAddress)
37
+ throw new Error("Not paired.");
38
+ const url = new URL(req.url);
39
+ const path = url.pathname + url.search;
40
+ const ts = Math.floor(Date.now() / 1000);
41
+ const nonce = toHex(b64uDecodeBytes(challenge.nonce));
42
+ const hash = agentAuthHash({
43
+ realm: challenge.realm,
44
+ nonce,
45
+ method: req.method,
46
+ path,
47
+ body: req.body ?? null,
48
+ ts,
49
+ });
50
+ const account = Account.fromP256(store.privateKey, {
51
+ access: store.userAddress,
52
+ });
53
+ const signature = await account.sign({ hash });
54
+ return { v: 1, ts, nonce: challenge.nonce, signature };
55
+ }
@@ -2,6 +2,7 @@
2
2
  import { concatHex, keccak256, slice, stringToBytes } from "viem";
3
3
  import { loadStore } from "../store.js";
4
4
  import { createAgentClient } from "../chain.js";
5
+ import { buildAgentAuthCredential } from "../agentAuth.js";
5
6
  import { CONFIG, PATHUSD_DECIMALS } from "../config.js";
6
7
  function b64uDecode(s) {
7
8
  return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
@@ -51,6 +52,26 @@ export function parseMppChallenge(header) {
51
52
  challenge.expires = params.expires;
52
53
  return challenge;
53
54
  }
55
+ /**
56
+ * Parse a `WWW-Authenticate: ThaiFiAgent realm="…", challenge="<b64u>"`
57
+ * header — ThaiFi Agent Auth (identity proof, no payment). The server
58
+ * verifies our signature against the AccountKeychain precompile.
59
+ */
60
+ function parseAgentChallenge(header) {
61
+ const m = /^ThaiFiAgent\s+(.+)$/i.exec(header.trim());
62
+ if (!m)
63
+ return null;
64
+ const params = {};
65
+ const re = /([a-zA-Z]+)=((?:"((?:[^"\\]|\\.)*)")|[^\s,]+)/g;
66
+ let pm;
67
+ while ((pm = re.exec(m[1]))) {
68
+ const value = pm[3] !== undefined ? pm[3].replace(/\\(.)/g, "$1") : pm[2];
69
+ params[pm[1].toLowerCase()] = value;
70
+ }
71
+ if (!params.realm || !params.challenge)
72
+ return null;
73
+ return { realm: params.realm, nonce: params.challenge };
74
+ }
54
75
  /** MPP tempo charge → on-chain TIP-20 transfer (push mode: we broadcast, then send the hash). */
55
76
  function mppTempoChallenge(challenge) {
56
77
  const req = challenge.request;
@@ -190,6 +211,28 @@ export async function request(url, options) {
190
211
  headers: options.headers,
191
212
  body: options.body,
192
213
  });
214
+ // 401/403 → ThaiFi Agent Auth: sign the challenge with the agent access key
215
+ // (identity proof, no payment) → retry once. The server verifies the key
216
+ // against the AccountKeychain precompile.
217
+ if (res.status === 401 || res.status === 403) {
218
+ const agentChallenge = parseAgentChallenge(res.headers.get("www-authenticate") ?? "");
219
+ if (agentChallenge) {
220
+ console.log("401 — signing agent auth challenge …");
221
+ const credential = await buildAgentAuthCredential(store, agentChallenge, {
222
+ method: options.method,
223
+ url,
224
+ body: options.body,
225
+ });
226
+ res = await fetch(url, {
227
+ method: options.method,
228
+ headers: {
229
+ ...options.headers,
230
+ Authorization: "ThaiFiAgent " + b64uEncode(JSON.stringify(credential)),
231
+ },
232
+ body: options.body,
233
+ });
234
+ }
235
+ }
193
236
  // 402 → pay on-chain (TIP-20 transfer from the agent's spend limit) → retry once.
194
237
  if (res.status === 402) {
195
238
  const challenge = await parseChallenge(res, CONFIG.pathUsd);
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ const program = new Command();
14
14
  program
15
15
  .name("thaifi")
16
16
  .description("ThaiFi Wallet CLI — pair with wallet.thaifi.com and sign with on-chain access keys")
17
- .version("0.2.1");
17
+ .version("0.3.0");
18
18
  program
19
19
  .command("login")
20
20
  .description("Pair this CLI with your ThaiFi Wallet (opens the browser to approve)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaifi-wallet-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "ThaiFi Wallet CLI for AI agents — pair with wallet.thaifi.com, sign with on-chain access keys (spending limits enforced on-chain), x402/MPP payments",
5
5
  "type": "module",
6
6
  "bin": {