fix(encryptid): harden wallet link flow + add device_registration type
- Atomic nonce consumption prevents TOCTOU races - SIWE domain validation against allowlist - Unique constraint on linked_wallets(user_id, address_hash) - Add device_registration to challenge type enum Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c789481d91
commit
d861c0ad99
|
|
@ -431,6 +431,9 @@ routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => {
|
||||||
if (!newOwner || !signature || !sender) {
|
if (!newOwner || !signature || !sender) {
|
||||||
return c.json({ error: "Missing required fields: newOwner, signature, sender" }, 400);
|
return c.json({ error: "Missing required fields: newOwner, signature, sender" }, 400);
|
||||||
}
|
}
|
||||||
|
if (!/^0x[0-9a-fA-F]{40}$/.test(newOwner)) {
|
||||||
|
return c.json({ error: "Invalid newOwner address" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Encode addOwnerWithThreshold(address owner, uint256 _threshold)
|
// Encode addOwnerWithThreshold(address owner, uint256 _threshold)
|
||||||
// Function selector: 0x0d582f13
|
// Function selector: 0x0d582f13
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ export async function getChallenge(challenge: string): Promise<StoredChallenge |
|
||||||
return {
|
return {
|
||||||
challenge: row.challenge,
|
challenge: row.challenge,
|
||||||
userId: row.user_id || undefined,
|
userId: row.user_id || undefined,
|
||||||
type: row.type as 'registration' | 'authentication',
|
type: row.type as StoredChallenge['type'],
|
||||||
createdAt: new Date(row.created_at).getTime(),
|
createdAt: new Date(row.created_at).getTime(),
|
||||||
expiresAt: new Date(row.expires_at).getTime(),
|
expiresAt: new Date(row.expires_at).getTime(),
|
||||||
};
|
};
|
||||||
|
|
@ -189,6 +189,30 @@ export async function deleteChallenge(challenge: string): Promise<void> {
|
||||||
await sql`DELETE FROM challenges WHERE challenge = ${challenge}`;
|
await sql`DELETE FROM challenges WHERE challenge = ${challenge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function consumeChallenge(
|
||||||
|
challenge: string,
|
||||||
|
userId: string,
|
||||||
|
expectedType: StoredChallenge['type'],
|
||||||
|
): Promise<StoredChallenge | null> {
|
||||||
|
const rows = await sql`
|
||||||
|
DELETE FROM challenges
|
||||||
|
WHERE challenge = ${challenge}
|
||||||
|
AND user_id = ${userId}
|
||||||
|
AND type = ${expectedType}
|
||||||
|
AND expires_at > NOW()
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const row = rows[0];
|
||||||
|
return {
|
||||||
|
challenge: row.challenge,
|
||||||
|
userId: row.user_id || undefined,
|
||||||
|
type: row.type as StoredChallenge['type'],
|
||||||
|
createdAt: new Date(row.created_at).getTime(),
|
||||||
|
expiresAt: new Date(row.expires_at).getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function cleanExpiredChallenges(): Promise<number> {
|
export async function cleanExpiredChallenges(): Promise<number> {
|
||||||
const result = await sql`DELETE FROM challenges WHERE expires_at < NOW()`;
|
const result = await sql`DELETE FROM challenges WHERE expires_at < NOW()`;
|
||||||
return result.count;
|
return result.count;
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id);
|
||||||
CREATE TABLE IF NOT EXISTS challenges (
|
CREATE TABLE IF NOT EXISTS challenges (
|
||||||
challenge TEXT PRIMARY KEY,
|
challenge TEXT PRIMARY KEY,
|
||||||
user_id TEXT,
|
user_id TEXT,
|
||||||
type TEXT NOT NULL CHECK (type IN ('registration', 'authentication', 'wallet_link')),
|
type TEXT NOT NULL CHECK (type IN ('registration', 'authentication', 'device_registration', 'wallet_link')),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
expires_at TIMESTAMPTZ NOT NULL
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
@ -319,7 +319,7 @@ CREATE INDEX IF NOT EXISTS idx_identity_invites_client_id ON identity_invites(cl
|
||||||
|
|
||||||
ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_type_check;
|
ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_type_check;
|
||||||
ALTER TABLE challenges ADD CONSTRAINT challenges_type_check
|
ALTER TABLE challenges ADD CONSTRAINT challenges_type_check
|
||||||
CHECK (type IN ('registration', 'authentication', 'wallet_link'));
|
CHECK (type IN ('registration', 'authentication', 'device_registration', 'wallet_link'));
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- LINKED EXTERNAL WALLETS (SIWE-verified wallet associations)
|
-- LINKED EXTERNAL WALLETS (SIWE-verified wallet associations)
|
||||||
|
|
@ -341,3 +341,10 @@ CREATE TABLE IF NOT EXISTS linked_wallets (
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user_id ON linked_wallets(user_id);
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user_id ON linked_wallets(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address_hash ON linked_wallets(address_hash);
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address_hash ON linked_wallets(address_hash);
|
||||||
|
|
||||||
|
-- Prevent duplicate wallet links per user (application-level check + DB enforcement)
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE linked_wallets ADD CONSTRAINT linked_wallets_user_address_unique
|
||||||
|
UNIQUE (user_id, address_hash);
|
||||||
|
EXCEPTION WHEN duplicate_table THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ import {
|
||||||
getLinkedWallets,
|
getLinkedWallets,
|
||||||
deleteLinkedWallet,
|
deleteLinkedWallet,
|
||||||
linkedWalletExists,
|
linkedWalletExists,
|
||||||
|
consumeChallenge,
|
||||||
sql,
|
sql,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -2754,30 +2755,28 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => {
|
||||||
return c.json({ error: 'Invalid SIWE message format' }, 400);
|
return c.json({ error: 'Invalid SIWE message format' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate nonce exists and belongs to this user
|
// Atomically consume the nonce (DELETE...RETURNING prevents TOCTOU races)
|
||||||
const challenge = await getChallenge(parsed.nonce);
|
const challenge = await consumeChallenge(parsed.nonce, claims.sub, 'wallet_link');
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
return c.json({ error: 'Invalid or expired nonce' }, 400);
|
return c.json({ error: 'Invalid, expired, or already-used nonce' }, 400);
|
||||||
}
|
|
||||||
if (challenge.userId !== claims.sub) {
|
|
||||||
return c.json({ error: 'Nonce does not belong to this user' }, 403);
|
|
||||||
}
|
|
||||||
if (Date.now() > challenge.expiresAt) {
|
|
||||||
await deleteChallenge(parsed.nonce);
|
|
||||||
return c.json({ error: 'Nonce expired' }, 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate SIWE message fields
|
// Validate SIWE message fields against server-known domains
|
||||||
|
const allowedDomains = [CONFIG.rpId || 'rspace.online', 'rwallet.online'];
|
||||||
|
const messageDomain = parsed.domain || '';
|
||||||
|
if (!allowedDomains.some(d => messageDomain === d || messageDomain.endsWith(`.${d}`))) {
|
||||||
|
return c.json({ error: 'SIWE domain not recognized' }, 400);
|
||||||
|
}
|
||||||
const isValid = validateSiweMessage({
|
const isValid = validateSiweMessage({
|
||||||
message: parsed,
|
message: parsed,
|
||||||
domain: parsed.domain || '',
|
domain: messageDomain,
|
||||||
nonce: parsed.nonce,
|
nonce: parsed.nonce,
|
||||||
});
|
});
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return c.json({ error: 'SIWE message validation failed' }, 400);
|
return c.json({ error: 'SIWE message validation failed' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the signature
|
// Verify the signature cryptographically
|
||||||
const recovered = await verifyMessage({
|
const recovered = await verifyMessage({
|
||||||
address: parsed.address as `0x${string}`,
|
address: parsed.address as `0x${string}`,
|
||||||
message,
|
message,
|
||||||
|
|
@ -2787,9 +2786,6 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => {
|
||||||
return c.json({ error: 'Signature verification failed' }, 400);
|
return c.json({ error: 'Signature verification failed' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up nonce
|
|
||||||
await deleteChallenge(parsed.nonce);
|
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate
|
||||||
const exists = await linkedWalletExists(claims.sub, addressHash);
|
const exists = await linkedWalletExists(claims.sub, addressHash);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue