@stacks-passkey/relay 0.1.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/dist/account-service.d.ts +39 -0
- package/dist/account-service.js +223 -0
- package/dist/account-service.test.d.ts +1 -0
- package/dist/account-service.test.js +44 -0
- package/dist/accounts.d.ts +20 -0
- package/dist/accounts.js +34 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +412 -0
- package/dist/catalog-service.d.ts +20 -0
- package/dist/catalog-service.js +165 -0
- package/dist/catalog.d.ts +21 -0
- package/dist/catalog.js +60 -0
- package/dist/catalog.test.d.ts +1 -0
- package/dist/catalog.test.js +39 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +41 -0
- package/dist/contract-id.d.ts +2 -0
- package/dist/contract-id.js +6 -0
- package/dist/contract-id.test.d.ts +1 -0
- package/dist/contract-id.test.js +25 -0
- package/dist/crypto.d.ts +9 -0
- package/dist/crypto.js +64 -0
- package/dist/gas-tank.d.ts +89 -0
- package/dist/gas-tank.js +286 -0
- package/dist/gas-tank.test.d.ts +1 -0
- package/dist/gas-tank.test.js +60 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/load-env.d.ts +4 -0
- package/dist/load-env.js +36 -0
- package/dist/on-chain-balance.d.ts +4 -0
- package/dist/on-chain-balance.js +60 -0
- package/dist/rate-limit.d.ts +9 -0
- package/dist/rate-limit.js +29 -0
- package/dist/rate-limit.test.d.ts +1 -0
- package/dist/rate-limit.test.js +21 -0
- package/dist/registrar-queue.d.ts +14 -0
- package/dist/registrar-queue.js +30 -0
- package/dist/registrar-queue.test.d.ts +1 -0
- package/dist/registrar-queue.test.js +22 -0
- package/dist/registrar.d.ts +4 -0
- package/dist/registrar.js +9 -0
- package/dist/secrets.d.ts +6 -0
- package/dist/secrets.js +59 -0
- package/dist/secrets.test.d.ts +1 -0
- package/dist/secrets.test.js +39 -0
- package/dist/sponsor-derivation.d.ts +3 -0
- package/dist/sponsor-derivation.js +25 -0
- package/dist/sponsor-derivation.test.d.ts +1 -0
- package/dist/sponsor-derivation.test.js +21 -0
- package/dist/sponsor-lock.d.ts +2 -0
- package/dist/sponsor-lock.js +8 -0
- package/dist/sponsor.d.ts +24 -0
- package/dist/sponsor.js +129 -0
- package/dist/tx-wait.d.ts +4 -0
- package/dist/tx-wait.js +18 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/dist/wallet-auth.d.ts +43 -0
- package/dist/wallet-auth.js +145 -0
- package/dist/wallet-auth.test.d.ts +1 -0
- package/dist/wallet-auth.test.js +30 -0
- package/package.json +54 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RelayConfig } from './types.js';
|
|
2
|
+
export declare function relayAuthDomain(network: RelayConfig['network']): import("@stacks/transactions").TupleCV<import("@stacks/transactions").TupleData<import("@stacks/transactions").UIntCV | import("@stacks/transactions").StringAsciiCV>>;
|
|
3
|
+
export declare function buildAuthMessage(address: string, nonce: string, expiresAt: number): import("@stacks/transactions").TupleCV<import("@stacks/transactions").TupleData<import("@stacks/transactions").UIntCV | import("@stacks/transactions").StringAsciiCV>>;
|
|
4
|
+
export declare function buildPlainAuthMessage(address: string, nonce: string, expiresAt: number): string;
|
|
5
|
+
export declare function issueAuthChallengeForNetwork(address: string, network: RelayConfig['network']): {
|
|
6
|
+
nonce: string;
|
|
7
|
+
expiresAt: number;
|
|
8
|
+
plainMessage: string;
|
|
9
|
+
domain: import("@stacks/transactions").TupleCV<import("@stacks/transactions").TupleData<import("@stacks/transactions").UIntCV | import("@stacks/transactions").StringAsciiCV>>;
|
|
10
|
+
message: import("@stacks/transactions").TupleCV<import("@stacks/transactions").TupleData<import("@stacks/transactions").UIntCV | import("@stacks/transactions").StringAsciiCV>>;
|
|
11
|
+
};
|
|
12
|
+
export type AuthVerifyFailure = 'missing_challenge' | 'nonce_mismatch' | 'expires_mismatch' | 'expired' | 'bad_signature';
|
|
13
|
+
export declare function verifyAuthSignatureWithReason(opts: {
|
|
14
|
+
address: string;
|
|
15
|
+
signature: string;
|
|
16
|
+
publicKey: string;
|
|
17
|
+
nonce: string;
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
network: RelayConfig['network'];
|
|
20
|
+
mode?: 'structured' | 'plain';
|
|
21
|
+
plainMessage?: string;
|
|
22
|
+
}): {
|
|
23
|
+
ok: true;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: AuthVerifyFailure;
|
|
27
|
+
};
|
|
28
|
+
export declare function verifyAuthSignature(opts: {
|
|
29
|
+
address: string;
|
|
30
|
+
signature: string;
|
|
31
|
+
publicKey: string;
|
|
32
|
+
nonce: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
network: RelayConfig['network'];
|
|
35
|
+
mode?: 'structured' | 'plain';
|
|
36
|
+
plainMessage?: string;
|
|
37
|
+
}): boolean;
|
|
38
|
+
export declare function createWalletSession(address: string, sessionSecret: string): {
|
|
39
|
+
sessionToken: string;
|
|
40
|
+
expiresAt: number;
|
|
41
|
+
};
|
|
42
|
+
/** @internal test helper */
|
|
43
|
+
export declare function clearAuthChallenges(): void;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { Cl, encodeStructuredDataBytes, getAddressFromPublicKey, publicKeyFromSignatureRsv, } from '@stacks/transactions';
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
4
|
+
import { bytesToHex, concatBytes, utf8ToBytes } from '@stacks/common';
|
|
5
|
+
import { createSessionToken } from './crypto.js';
|
|
6
|
+
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
7
|
+
const pendingChallenges = new Map();
|
|
8
|
+
function chainIdForNetwork(network) {
|
|
9
|
+
return network === 'mainnet' ? 1 : 0x80000000;
|
|
10
|
+
}
|
|
11
|
+
export function relayAuthDomain(network) {
|
|
12
|
+
const domainName = process.env.RELAY_AUTH_DOMAIN ?? 'localhost';
|
|
13
|
+
return Cl.tuple({
|
|
14
|
+
name: Cl.stringAscii(domainName),
|
|
15
|
+
version: Cl.stringAscii('1.0.0'),
|
|
16
|
+
'chain-id': Cl.uint(chainIdForNetwork(network)),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function buildAuthMessage(address, nonce, expiresAt) {
|
|
20
|
+
return Cl.tuple({
|
|
21
|
+
action: Cl.stringAscii('authenticate'),
|
|
22
|
+
address: Cl.stringAscii(address),
|
|
23
|
+
nonce: Cl.stringAscii(nonce),
|
|
24
|
+
expires: Cl.uint(expiresAt),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function buildPlainAuthMessage(address, nonce, expiresAt) {
|
|
28
|
+
return [
|
|
29
|
+
'Stacks Passkey Relay Login',
|
|
30
|
+
`address=${address}`,
|
|
31
|
+
`nonce=${nonce}`,
|
|
32
|
+
`expires=${expiresAt}`,
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
export function issueAuthChallengeForNetwork(address, network) {
|
|
36
|
+
const nonce = randomBytes(16).toString('hex');
|
|
37
|
+
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
|
|
38
|
+
pendingChallenges.set(address, { nonce, expiresAt });
|
|
39
|
+
return {
|
|
40
|
+
nonce,
|
|
41
|
+
expiresAt,
|
|
42
|
+
plainMessage: buildPlainAuthMessage(address, nonce, expiresAt),
|
|
43
|
+
domain: relayAuthDomain(network),
|
|
44
|
+
message: buildAuthMessage(address, nonce, expiresAt),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function hashPlainMessage(message) {
|
|
48
|
+
const bytes = utf8ToBytes(message);
|
|
49
|
+
const hashes = [bytesToHex(sha256(bytes))];
|
|
50
|
+
// Leather / SIWS plain signing uses \x17Stacks Signed Message:\n
|
|
51
|
+
const siwsPrefix = '\x17Stacks Signed Message:\n';
|
|
52
|
+
const siwsPrefixed = concatBytes(utf8ToBytes(siwsPrefix), utf8ToBytes(String(bytes.length)), bytes);
|
|
53
|
+
hashes.push(bytesToHex(sha256(siwsPrefixed)));
|
|
54
|
+
// Legacy alternate prefix (keep for compatibility)
|
|
55
|
+
const legacyPrefix = '\x18Stacks Message Signing:\n';
|
|
56
|
+
const legacyPrefixed = concatBytes(utf8ToBytes(legacyPrefix), utf8ToBytes(String(bytes.length)), bytes);
|
|
57
|
+
hashes.push(bytesToHex(sha256(legacyPrefixed)));
|
|
58
|
+
return hashes;
|
|
59
|
+
}
|
|
60
|
+
function verifyPlainSignature(opts) {
|
|
61
|
+
const sig = opts.signature.startsWith('0x') ? opts.signature.slice(2) : opts.signature;
|
|
62
|
+
const networkName = opts.network === 'mainnet' ? 'mainnet' : 'testnet';
|
|
63
|
+
for (const messageHash of hashPlainMessage(opts.message)) {
|
|
64
|
+
try {
|
|
65
|
+
const recovered = publicKeyFromSignatureRsv(messageHash, sig);
|
|
66
|
+
const recoveredAddress = getAddressFromPublicKey(recovered, networkName);
|
|
67
|
+
if (recoveredAddress !== opts.address)
|
|
68
|
+
continue;
|
|
69
|
+
const providedAddress = getAddressFromPublicKey(opts.publicKey.startsWith('0x') ? opts.publicKey.slice(2) : opts.publicKey, networkName);
|
|
70
|
+
if (providedAddress === opts.address)
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// try next hash format
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
function verifyStructuredSignature(opts) {
|
|
80
|
+
const structuredHash = bytesToHex(sha256(encodeStructuredDataBytes({ message: opts.message, domain: opts.domain })));
|
|
81
|
+
const sig = opts.signature.startsWith('0x') ? opts.signature.slice(2) : opts.signature;
|
|
82
|
+
const recovered = publicKeyFromSignatureRsv(structuredHash, sig);
|
|
83
|
+
const recoveredAddress = getAddressFromPublicKey(recovered, opts.network === 'mainnet' ? 'mainnet' : 'testnet');
|
|
84
|
+
if (recoveredAddress !== opts.address)
|
|
85
|
+
return false;
|
|
86
|
+
const providedAddress = getAddressFromPublicKey(opts.publicKey.startsWith('0x') ? opts.publicKey.slice(2) : opts.publicKey, opts.network === 'mainnet' ? 'mainnet' : 'testnet');
|
|
87
|
+
return providedAddress === opts.address;
|
|
88
|
+
}
|
|
89
|
+
export function verifyAuthSignatureWithReason(opts) {
|
|
90
|
+
const pending = pendingChallenges.get(opts.address);
|
|
91
|
+
if (!pending)
|
|
92
|
+
return { ok: false, reason: 'missing_challenge' };
|
|
93
|
+
if (pending.nonce !== opts.nonce)
|
|
94
|
+
return { ok: false, reason: 'nonce_mismatch' };
|
|
95
|
+
if (pending.expiresAt !== opts.expiresAt)
|
|
96
|
+
return { ok: false, reason: 'expires_mismatch' };
|
|
97
|
+
if (Date.now() > pending.expiresAt) {
|
|
98
|
+
pendingChallenges.delete(opts.address);
|
|
99
|
+
return { ok: false, reason: 'expired' };
|
|
100
|
+
}
|
|
101
|
+
const mode = opts.mode ?? 'structured';
|
|
102
|
+
let ok = false;
|
|
103
|
+
if (mode === 'plain') {
|
|
104
|
+
const message = opts.plainMessage ?? buildPlainAuthMessage(opts.address, opts.nonce, opts.expiresAt);
|
|
105
|
+
ok = verifyPlainSignature({
|
|
106
|
+
address: opts.address,
|
|
107
|
+
signature: opts.signature,
|
|
108
|
+
publicKey: opts.publicKey,
|
|
109
|
+
message,
|
|
110
|
+
network: opts.network,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const domain = relayAuthDomain(opts.network);
|
|
115
|
+
const message = buildAuthMessage(opts.address, opts.nonce, opts.expiresAt);
|
|
116
|
+
ok = verifyStructuredSignature({
|
|
117
|
+
address: opts.address,
|
|
118
|
+
signature: opts.signature,
|
|
119
|
+
publicKey: opts.publicKey,
|
|
120
|
+
message,
|
|
121
|
+
domain,
|
|
122
|
+
network: opts.network,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (ok) {
|
|
126
|
+
pendingChallenges.delete(opts.address);
|
|
127
|
+
return { ok: true };
|
|
128
|
+
}
|
|
129
|
+
return { ok: false, reason: 'bad_signature' };
|
|
130
|
+
}
|
|
131
|
+
export function verifyAuthSignature(opts) {
|
|
132
|
+
return verifyAuthSignatureWithReason(opts).ok;
|
|
133
|
+
}
|
|
134
|
+
export function createWalletSession(address, sessionSecret) {
|
|
135
|
+
const ttlMs = Number(process.env.RELAY_SESSION_TTL_MS ?? `${24 * 60 * 60 * 1000}`);
|
|
136
|
+
const expiresAt = Date.now() + ttlMs;
|
|
137
|
+
return {
|
|
138
|
+
sessionToken: createSessionToken(address, sessionSecret, ttlMs),
|
|
139
|
+
expiresAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** @internal test helper */
|
|
143
|
+
export function clearAuthChallenges() {
|
|
144
|
+
pendingChallenges.clear();
|
|
145
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildPlainAuthMessage, clearAuthChallenges, issueAuthChallengeForNetwork, verifyAuthSignature, } from './wallet-auth.js';
|
|
3
|
+
import { privateKeyToAddress, privateKeyToPublic, signMessageHashRsv } from '@stacks/transactions';
|
|
4
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
5
|
+
import { bytesToHex, concatBytes, utf8ToBytes } from '@stacks/common';
|
|
6
|
+
describe('wallet-auth plain signatures', () => {
|
|
7
|
+
it('accepts SIWS-style plain message signatures (Leather stx_signMessage)', () => {
|
|
8
|
+
clearAuthChallenges();
|
|
9
|
+
const privateKey = 'f5a31c1268a1e37d4edaa05c7d11183c5fbfdcdc48aae36ea4d8cd5cb709932801';
|
|
10
|
+
const publicKey = privateKeyToPublic(privateKey);
|
|
11
|
+
const address = privateKeyToAddress(privateKey, 'testnet');
|
|
12
|
+
const { nonce, expiresAt } = issueAuthChallengeForNetwork(address, 'testnet');
|
|
13
|
+
const plainMessage = buildPlainAuthMessage(address, nonce, expiresAt);
|
|
14
|
+
const bytes = utf8ToBytes(plainMessage);
|
|
15
|
+
const prefixed = concatBytes(utf8ToBytes('\x17Stacks Signed Message:\n'), utf8ToBytes(String(bytes.length)), bytes);
|
|
16
|
+
const messageHash = bytesToHex(sha256(prefixed));
|
|
17
|
+
const signature = signMessageHashRsv({ messageHash, privateKey });
|
|
18
|
+
const ok = verifyAuthSignature({
|
|
19
|
+
address,
|
|
20
|
+
signature,
|
|
21
|
+
publicKey,
|
|
22
|
+
nonce,
|
|
23
|
+
expiresAt,
|
|
24
|
+
network: 'testnet',
|
|
25
|
+
mode: 'plain',
|
|
26
|
+
plainMessage,
|
|
27
|
+
});
|
|
28
|
+
expect(ok).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stacks-passkey/relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Self-hostable sponsored transaction relay for Stacks Passkey SDK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"stacks-passkey-relay": "./dist/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "tsx watch src/cli.ts",
|
|
23
|
+
"start": "node dist/cli.js",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@stacks/network": "^7.0.2",
|
|
29
|
+
"@stacks/transactions": "^7.0.2",
|
|
30
|
+
"hono": "^4.6.14"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@hono/node-server": "^1.13.7",
|
|
34
|
+
"@types/node": "^22.10.0",
|
|
35
|
+
"tsx": "^4.19.2",
|
|
36
|
+
"typescript": "^5.7.2",
|
|
37
|
+
"vitest": "^3.0.5"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"stacks",
|
|
41
|
+
"passkey",
|
|
42
|
+
"relay",
|
|
43
|
+
"sponsor"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/Stacks-utils/stacks-passkey-sdk.git",
|
|
49
|
+
"directory": "packages/relay"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|