forked from Deen-Bridge/dnb-backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsep10Service.js
More file actions
198 lines (180 loc) · 6.84 KB
/
Copy pathsep10Service.js
File metadata and controls
198 lines (180 loc) · 6.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
// services/stellar/sep10Service.js
//
// SEP-10 Web Authentication (https://stellar.org/protocol/sep-10).
//
// Two responsibilities:
// 1. buildChallenge(account) → a server-signed challenge transaction (base64 XDR)
// 2. verifyChallenge(signed) → validates the client's signature and returns the
// proven account id, so the caller can issue a JWT.
//
// The signing keypair is dedicated to auth and NEVER holds funds, so this does not
// change DeenBridge's non-custodial model for user money. The feature stays fully
// optional at boot: when SEP10_SIGNING_SECRET (or the domains) are unset, the
// endpoints report "not configured" and return 503 instead of crashing.
import { Keypair, WebAuth } from "@stellar/stellar-sdk";
import {
server,
networkPassphrase,
isValidPublicKey,
} from "./stellarService.js";
import logger from "../../config/logger.js";
const CHALLENGE_TIMEOUT = Number(process.env.SEP10_CHALLENGE_TIMEOUT) || 300;
const HOME_DOMAIN = process.env.SEP10_HOME_DOMAIN || "";
const WEB_AUTH_DOMAIN = process.env.SEP10_WEB_AUTH_DOMAIN || HOME_DOMAIN;
let serverKeypair = null;
if (process.env.SEP10_SIGNING_SECRET) {
try {
serverKeypair = Keypair.fromSecret(process.env.SEP10_SIGNING_SECRET.trim());
logger.info(
`🔐 SEP-10 auth enabled — signing key ${serverKeypair
.publicKey()
.slice(0, 6)}… home=${HOME_DOMAIN || "(unset)"}`
);
} catch (err) {
// Bad secret is a config error, not a crash: stay disabled and surface 503.
logger.error(`SEP-10 SEP10_SIGNING_SECRET is invalid: ${err.message}`);
serverKeypair = null;
}
}
/**
* True only when the feature has everything it needs to serve real challenges:
* a valid signing keypair and the two domains that anchor the challenge.
*/
export const isSep10Configured = () =>
Boolean(serverKeypair && HOME_DOMAIN && WEB_AUTH_DOMAIN);
/** Public signing key, or null when unconfigured (used for stellar.toml SIGNING_KEY). */
export const getSigningPublicKey = () =>
serverKeypair ? serverKeypair.publicKey() : null;
// ── Replay protection ──────────────────────────────────────────────────────
// Challenges are single-use. We remember the hash of every challenge we accept
// until its time bound expires and reject any reuse. In-memory is sufficient for
// a single instance (the issue's stated bar); a Redis-backed store can replace
// this Map without changing the call sites.
const consumedChallenges = new Map(); // hash(hex) → expiry epoch ms
const pruneExpired = (now) => {
for (const [hash, expiry] of consumedChallenges) {
if (expiry <= now) consumedChallenges.delete(hash);
}
};
/**
* Build a SEP-10 challenge transaction for `clientAccountId`.
* @returns {{ transaction: string, network_passphrase: string }}
* @throws {Error} code "SEP10_NOT_CONFIGURED" | "INVALID_ACCOUNT"
*/
export const buildChallenge = (clientAccountId) => {
if (!isSep10Configured()) {
const err = new Error("Sign in with Stellar is not configured.");
err.code = "SEP10_NOT_CONFIGURED";
throw err;
}
if (!clientAccountId || !isValidPublicKey(clientAccountId)) {
const err = new Error("A valid Stellar account (G…) is required.");
err.code = "INVALID_ACCOUNT";
throw err;
}
const transaction = WebAuth.buildChallengeTx(
serverKeypair,
clientAccountId,
HOME_DOMAIN,
CHALLENGE_TIMEOUT,
networkPassphrase,
WEB_AUTH_DOMAIN
);
return { transaction, network_passphrase: networkPassphrase };
};
/**
* Verify a signed SEP-10 challenge and return the proven account id.
*
* `readChallengeTx` validates the server signature, time bounds, home domain and
* the manage-data op shape. We then prove the *client* controls the account:
* • account exists on-chain → threshold check against its real signers/weights
* • account not yet created (valid per SEP-10) → master-key signer check
* Finally we enforce single-use via the challenge hash.
*
* @returns {Promise<string>} the proven client account id (G…)
* @throws {Error} code "SEP10_NOT_CONFIGURED" | "INVALID_CHALLENGE" | "CHALLENGE_REPLAYED"
*/
export const verifyChallenge = async (signedXdr, now = Date.now()) => {
if (!isSep10Configured()) {
const err = new Error("Sign in with Stellar is not configured.");
err.code = "SEP10_NOT_CONFIGURED";
throw err;
}
if (!signedXdr || typeof signedXdr !== "string") {
const err = new Error("A signed challenge transaction is required.");
err.code = "INVALID_CHALLENGE";
throw err;
}
const serverAccountId = serverKeypair.publicKey();
let tx;
let clientAccountID;
try {
({ tx, clientAccountID } = WebAuth.readChallengeTx(
signedXdr,
serverAccountId,
networkPassphrase,
HOME_DOMAIN,
WEB_AUTH_DOMAIN
));
} catch (err) {
// Wrong server sig, expired time bounds, tampered domain, bad structure.
const wrapped = new Error(err.message || "Invalid challenge transaction.");
wrapped.code = "INVALID_CHALLENGE";
throw wrapped;
}
// Replay guard — reject a challenge we've already consumed.
pruneExpired(now);
const challengeHash = tx.hash().toString("hex");
if (consumedChallenges.has(challengeHash)) {
const err = new Error("This challenge has already been used.");
err.code = "CHALLENGE_REPLAYED";
throw err;
}
// Does the account exist on-chain? Decides threshold vs master-key verification.
let account = null;
try {
account = await server.loadAccount(clientAccountID);
} catch {
account = null; // Not found (or Horizon hiccup) → treat as not-yet-created.
}
try {
if (account) {
const signerSummary = account.signers.map((s) => ({
key: s.key,
weight: s.weight,
}));
const medThreshold = account.thresholds?.med_threshold || 1;
WebAuth.verifyChallengeTxThreshold(
signedXdr,
serverAccountId,
networkPassphrase,
medThreshold,
signerSummary,
HOME_DOMAIN,
WEB_AUTH_DOMAIN
);
} else {
WebAuth.verifyChallengeTxSigners(
signedXdr,
serverAccountId,
networkPassphrase,
[clientAccountID],
HOME_DOMAIN,
WEB_AUTH_DOMAIN
);
}
} catch (err) {
const wrapped = new Error(
err.message || "Challenge signature verification failed."
);
wrapped.code = "INVALID_CHALLENGE";
throw wrapped;
}
// Consume the challenge until its time bound lapses.
const maxTime = Number(tx.timeBounds?.maxTime) || 0;
const expiry = maxTime > 0 ? maxTime * 1000 : now + CHALLENGE_TIMEOUT * 1000;
consumedChallenges.set(challengeHash, expiry);
return clientAccountID;
};
// Exposed for tests to assert single-use semantics deterministically.
export const _clearConsumedChallenges = () => consumedChallenges.clear();