Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions backend/routes/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,12 +649,14 @@ def post_stroke(roomId):
try:
import nacl.signing, nacl.encoding
vk = nacl.signing.VerifyKey(spk, encoder=nacl.encoding.HexEncoder)
msg = json.dumps({
msg_data = {
"roomId": roomId, "user": stroke["user"], "color": stroke["color"],
"lineWidth": stroke["lineWidth"], "pathData": stroke["pathData"], "timestamp": stroke.get("timestamp", stroke["ts"])
}, separators=(',', ':'), sort_keys=True).encode()
}
msg = json.dumps(msg_data, separators=(',', ':'), sort_keys=True).encode()
vk.verify(msg, bytes.fromhex(sig))
except Exception:
except Exception as e:
logger.error(f"Signature verification failed for room {roomId}: {str(e)}")
return jsonify({"status":"error","message":"Bad signature"}), 400
stroke["walletSignature"] = sig
stroke["walletPubKey"] = spk
Expand Down
89 changes: 36 additions & 53 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"bs58": "^6.0.0",
"clipper-lib": "^6.4.2",
"react": "^17.0.0 || ^18.0.0",
"react-color": "^2.19.3",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ function Canvas({
selectionRect, setSelectionRect,
cutImageData, setCutImageData,
handleCutSelection,
} = useCanvasSelection(canvasRef, currentUser, userData, generateId, drawAllDrawings, currentRoomId, setUndoAvailable, setRedoAvailable, auth);
} = useCanvasSelection(canvasRef, currentUser, userData, generateId, drawAllDrawings, currentRoomId, setUndoAvailable, setRedoAvailable, auth, roomType);

// Draw a preview of a shape (for shape mode)
const drawShapePreview = (start, end, shape, color, lineWidth) => {
Expand Down
34 changes: 32 additions & 2 deletions frontend/src/components/WalletConnector.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,20 @@ export default function WalletConnector({ roomType, onConnected, onDisconnected
setStatus({ connected: true, publicKey: pubKey });
} catch (err) {
console.error('Wallet connection failed:', err);
setError(err.message || 'Failed to connect wallet');

// Provide helpful error message
let errorMsg = err.message || 'Failed to connect wallet';

if (errorMsg.includes('not connected') || errorMsg.includes('No keys found')) {
errorMsg = 'Please connect your wallet to this site first:\n\n' +
'1. Click the ResVault extension icon in your browser\n' +
'2. Make sure you are logged in to ResVault\n' +
'3. In the ResVault dashboard, select the network you want to use\n' +
'4. Click the connection icon to connect to this site\n' +
'5. Then try connecting again here';
}

setError(errorMsg);
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -144,9 +157,26 @@ export default function WalletConnector({ roomType, onConnected, onDisconnected
sx={{ mt: 2 }}
onClose={() => setError(null)}
>
<Typography variant="body2">
<Typography variant="body2" component="div">
<strong>Wallet Connection Failed</strong>
</Typography>
<Typography variant="body2" component="div" sx={{ mt: 1, whiteSpace: 'pre-line' }}>
{error}
</Typography>
{error.includes('WALLET_NOT_CONNECTED') && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" component="div">
<strong>How to connect:</strong>
</Typography>
<Typography variant="body2" component="ol" sx={{ pl: 2, mt: 1 }}>
<li>Click the <strong>ResVault extension icon</strong> in your browser toolbar</li>
<li>Make sure you're <strong>logged in</strong> to ResVault</li>
<li>Select your desired <strong>network</strong> (e.g., ResilientDB Mainnet)</li>
<li>Click the <strong>globe/connection icon</strong> to connect to this site</li>
<li>Return here and click <strong>"Connect Wallet"</strong> again</li>
</Typography>
</Box>
)}
{error.includes('extension') && (
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
Please install the ResVault Chrome extension from:{' '}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/hooks/useCanvasSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ClipperLib from 'clipper-lib';
import { submitToDatabase } from '../services/canvasBackendJWT';
import { Drawing } from '../lib/drawing';

export function useCanvasSelection(canvasRef, currentUser, userData, generateId, drawAllDrawings, currentRoomId, setUndoAvailable, setRedoAvailable, auth) {
export function useCanvasSelection(canvasRef, currentUser, userData, generateId, drawAllDrawings, currentRoomId, setUndoAvailable, setRedoAvailable, auth, roomType) {
const [selectionStart, setSelectionStart] = useState(null);
const [selectionRect, setSelectionRect] = useState(null);
const [cutImageData, setCutImageData] = useState(null);
Expand Down Expand Up @@ -420,7 +420,7 @@ export function useCanvasSelection(canvasRef, currentUser, userData, generateId,

for (const segment of allReplacementSegments) {
try {
await submitToDatabase(segment, auth, { roomId: currentRoomId, skipUndoCheck: true, skipUndoStack: true }, setUndoAvailable, setRedoAvailable);
await submitToDatabase(segment, auth, { roomId: currentRoomId, roomType, skipUndoCheck: true, skipUndoStack: true }, setUndoAvailable, setRedoAvailable);
} catch (error) {
console.error("Failed to submit replacement segment:", segment, error);
}
Expand All @@ -444,7 +444,7 @@ export function useCanvasSelection(canvasRef, currentUser, userData, generateId,
);

userData.addDrawing(cutRecord);
await submitToDatabase(cutRecord, auth, { roomId: currentRoomId }, setUndoAvailable, setRedoAvailable);
await submitToDatabase(cutRecord, auth, { roomId: currentRoomId, roomType }, setUndoAvailable, setRedoAvailable);
drawAllDrawings();

// Only 1 backend undo operation: the cut record itself
Expand Down
121 changes: 103 additions & 18 deletions frontend/src/wallet/resvault.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import ResVaultSDK from 'resvault-sdk';
import nacl from 'tweetnacl';
import bs58 from 'bs58';

/**
* Lightweight wrapper around ResVault postMessage API.
Expand Down Expand Up @@ -145,6 +147,8 @@ export async function getWalletPublicKey() {
*/
export async function signMessageHex(messageUint8Array) {
return new Promise((resolve, reject) => {
let keysHandler = null;

const handler = (event) => {
try {
const d = (event && event.data) || {};
Expand All @@ -153,10 +157,51 @@ export async function signMessageHex(messageUint8Array) {
const wrapped = (d && d.type === 'FROM_CONTENT_SCRIPT' && d.data) ? d.data : d;
const payload = wrapped.resvault || wrapped.payload || wrapped.data || wrapped;

// Check if we received keys for signing
if (payload.type === 'signWithKeys' && payload.direction === 'request') {
if (VERBOSE_LOG) console.debug('[resvault] received keys for signing, performing local signature');

// Remove this handler since we got the keys
sdk.removeMessageListener(handler);
if (keysHandler) sdk.removeMessageListener(keysHandler);
clearTimeout(timeoutId);

try {
// Decode the Base58-encoded private key
const privateKeyBytes = bs58.decode(payload.privateKey);

// Generate keypair from the seed (first 32 bytes)
let keyPair;
if (privateKeyBytes.length === 32) {
keyPair = nacl.sign.keyPair.fromSeed(privateKeyBytes);
} else if (privateKeyBytes.length === 64) {
keyPair = nacl.sign.keyPair.fromSecretKey(privateKeyBytes);
} else {
throw new Error('Invalid private key length: ' + privateKeyBytes.length);
}

// Sign the message
const signature = nacl.sign.detached(messageUint8Array, keyPair.secretKey);

// Convert to hex
const signatureHex = Array.from(signature)
.map(b => b.toString(16).padStart(2, '0'))
.join('');

if (VERBOSE_LOG) console.debug('[resvault] signature generated:', signatureHex);
resolve(signatureHex);
} catch (error) {
console.error('[resvault] error during local signing:', error);
reject(new Error('Local signing failed: ' + error.message));
}
return;
}

const signature = payload.signature || payload.sig || (payload.data && payload.data.signature);

if ((payload.type === 'sign' && payload.direction === 'response') || signature) {
sdk.removeMessageListener(handler);
if (keysHandler) sdk.removeMessageListener(keysHandler);
clearTimeout(timeoutId);
if (signature) {
resolve(signature);
Expand All @@ -169,6 +214,7 @@ export async function signMessageHex(messageUint8Array) {
// If wrapper reported a failure for signing, surface it
if (typeof payload.success !== 'undefined' && payload.success === false) {
sdk.removeMessageListener(handler);
if (keysHandler) sdk.removeMessageListener(keysHandler);
clearTimeout(timeoutId);
const errMsg = payload.error || payload.message || 'Wallet signing failed';
reject(new Error(errMsg));
Expand Down Expand Up @@ -201,32 +247,61 @@ export async function signMessageHex(messageUint8Array) {
*/
export async function signStrokeForSecureRoom(roomId, stroke) {
try {
const publicKey = await getWalletPublicKey();

const canonical = JSON.stringify({
const publicKeyBase58 = await getWalletPublicKey();

// Convert Base58 public key to hex for backend
const publicKeyBytes = bs58.decode(publicKeyBase58);
const publicKeyHex = Array.from(publicKeyBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');

// Create canonical JSON to match backend's exact format
// Backend uses: json.dumps({...}, separators=(',', ':'), sort_keys=True)
// This creates compact JSON with ALL keys sorted (including nested objects)
const dataToSign = {
roomId: roomId,
user: stroke.user,
color: stroke.color,
lineWidth: stroke.lineWidth,
pathData: stroke.pathData,
timestamp: stroke.timestamp || stroke.ts
}, Object.keys({
color: null,
lineWidth: null,
pathData: null,
roomId: null,
timestamp: null,
user: null
}).sort());
};

// Deep sort all keys to match Python's sort_keys=True behavior
function sortKeysDeep(obj) {
if (Array.isArray(obj)) {
return obj.map(item => sortKeysDeep(item));
} else if (obj !== null && typeof obj === 'object') {
return Object.keys(obj).sort().reduce((result, key) => {
result[key] = sortKeysDeep(obj[key]);
return result;
}, {});
}
return obj;
}

const sortedData = sortKeysDeep(dataToSign);
const canonical = JSON.stringify(sortedData);

if (VERBOSE_LOG) {
console.log('[resvault] Data to sign:', dataToSign);
console.log('[resvault] Canonical JSON:', canonical);
}

const encoder = new TextEncoder();
const messageBytes = encoder.encode(canonical);

const signature = await signMessageHex(messageBytes);

if (VERBOSE_LOG) {
console.log('[resvault] Signature generated:', signature.substring(0, 32) + '...');
console.log('[resvault] Public key (Base58):', publicKeyBase58);
console.log('[resvault] Public key (Hex):', publicKeyHex.substring(0, 32) + '...');
}

return {
signature,
signerPubKey: publicKey
signerPubKey: publicKeyHex // Send hex format to backend
};
} catch (error) {
console.error('Failed to sign stroke:', error);
Expand All @@ -236,19 +311,19 @@ export async function signStrokeForSecureRoom(roomId, stroke) {

/**
* Connect wallet for secure room usage
* This checks if the wallet is already connected to this domain
* If not, user must manually connect via the ResVault extension popup
* @returns {Promise<string>} Connected wallet public key
*/
export async function connectWalletForSecureRoom() {
try {
await walletLogin();
if (VERBOSE_LOG) console.log('[resvault] Checking wallet connection...');

// Try to get the public key - this will work if user has already
// connected their wallet to this domain via the ResVault extension
const pubKey = await getWalletPublicKey();

// After obtaining the public key, inform any content-script/extension wrapper
// that may rely on the site's signer public key so it can include it in
// PrepareAsset payloads. Some ResVault wrappers listen for a message with
// type: 'siteSignerInfo' or similar — include a permissive message so
// content scripts can pick it up.
// Inform content script about the public key
try {
sdk.sendMessage({ type: 'siteSignerInfo', direction: 'info', signerPublicKey: pubKey });
} catch (err) {
Expand All @@ -258,10 +333,20 @@ export async function connectWalletForSecureRoom() {
isConnected = true;
currentPublicKey = pubKey;

if (VERBOSE_LOG) console.log('[resvault] Wallet connected successfully:', pubKey);
return pubKey;
} catch (error) {
isConnected = false;
currentPublicKey = null;

if (VERBOSE_LOG) console.error('[resvault] Wallet connection check failed:', error);

// Provide a clear error message for users
const errorMsg = error.message || 'Wallet connection failed';
if (errorMsg.includes('No keys found') || errorMsg.includes('not responding')) {
throw new Error('WALLET_NOT_CONNECTED: Please connect your ResVault wallet to this site first');
}

throw error;
}
}
Expand Down
Loading
Loading