diff --git a/app/marketplace/layout.tsx b/app/marketplace/layout.tsx index 64760ca..b41b070 100644 --- a/app/marketplace/layout.tsx +++ b/app/marketplace/layout.tsx @@ -12,7 +12,7 @@ export default function MarketplaceLayout({
-
+
{children}
diff --git a/app/marketplace/my-data/page.tsx b/app/marketplace/my-data/page.tsx index 64a1cf0..d8488eb 100644 --- a/app/marketplace/my-data/page.tsx +++ b/app/marketplace/my-data/page.tsx @@ -10,6 +10,7 @@ import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'; import { Transaction } from '@mysten/sui/transactions'; import { marketplaceConfig, MIST_PER_SUI } from '@/config/marketplace'; import { getMarketplaceTarget } from '@/lib/marketplace'; +import { encryptFile, isSealAvailable, uint8ArrayToBase64 } from '@/lib/seal'; // Type for Walrus write files flow interface WalrusWriteFlow { @@ -81,6 +82,7 @@ export default function MyDataPage() { previewSizeBytes: 1024 * 1024, }); + const flowRef = useRef> | null>(null); const addLog = (step: string, status: TransactionLog['status'], message: string, details?: string) => { @@ -151,20 +153,49 @@ export default function MyDataPage() { return; } - addLog('encode', 'processing', 'Encoding file...'); - - const fileBytes = await file.arrayBuffer(); - const uint8Array = new Uint8Array(fileBytes); + // Step 0: Encrypt with Seal (if available) + let dataToUpload: Uint8Array; + let encryptedObj: string; + + if (isSealAvailable()) { + addLog('encode', 'processing', 'Encrypting with Seal (purchase-based access)...'); + + // Use seller address as policy ID - only buyers with PurchaseReceipt can decrypt + console.log('[Seal] Encrypting file with purchase-based access'); + + try { + const { encryptedBlob, policyId } = await encryptFile(file, account.address); + const encryptedArrayBuffer = await encryptedBlob.arrayBuffer(); + dataToUpload = new Uint8Array(encryptedArrayBuffer); + + // Store policy ID as the encrypted object reference + encryptedObj = policyId; + setEncryptedObject(encryptedObj); + + addLog('encode', 'success', 'File encrypted (only buyers can decrypt)'); + console.log('[Seal] Encryption complete, encrypted size:', dataToUpload.length); + } catch (sealError) { + console.error('[Seal] Encryption failed:', sealError); + addLog('encode', 'error', `Seal encryption failed: ${sealError instanceof Error ? sealError.message : 'Unknown error'}`); + setUploadError('Failed to encrypt with Seal. Check console for details.'); + setIsProcessing(false); + return; + } + } else { + // Fallback: no encryption (store raw data) + addLog('encode', 'processing', 'Encoding file (Seal not configured)...'); + const fileBytes = await file.arrayBuffer(); + dataToUpload = new Uint8Array(fileBytes); + encryptedObj = bytesToHex(dataToUpload).slice(0, 128); + setEncryptedObject(encryptedObj); + addLog('encode', 'success', 'File encoded (no encryption)'); + } - // Step 1: Create flow and encode - const flow = await createFlow(uint8Array, file.name); + // Step 1: Create flow and encode with encrypted data + const flow = await createFlow(dataToUpload, file.name); flowRef.current = flow; await flow.encode(); - addLog('encode', 'success', 'File encoded'); - - // Generate encrypted object from file content (first 64 bytes as hex) - const encryptedObj = bytesToHex(uint8Array).slice(0, 128); - setEncryptedObject(encryptedObj); + addLog('encode', 'success', 'Walrus encoding complete'); // Step 2: Register blob on-chain setCurrentStep('register'); @@ -471,7 +502,7 @@ export default function MyDataPage() { dataset ACTIVE - + {/* Explorer Links */}
- - - - + + + + - - + +
- + {/* Content */}

{listing.name}

- + {/* Object ID */}

ID: {listing.id.slice(0, 8)}...{listing.id.slice(-6)}

- + {/* Blob ID with Walrus link */}

@@ -525,15 +556,15 @@ export default function MyDataPage() { title="View on WalrusScan" > - - - - - + + + + +

- + {/* Stats */}
@@ -545,7 +576,7 @@ export default function MyDataPage() {

{formatSize(Number(listing.totalSize))}

- + {/* Actions */}
+ + {/* Seal Encryption Notice */} +
+ +

+ Data is encrypted with Seal threshold encryption. Only users who purchase this dataset will be able to decrypt it. +

+ {!isSealAvailable() && ( +

+ ⚠️ Seal not configured. Set NEXT_PUBLIC_PACKAGE_ID to enable encryption. +

+ )} + {isSealAvailable() && ( +

+ check_circle + Seal encryption enabled - data will be protected +

+ )} +
diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index 9d2ac84..a909356 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -140,7 +140,7 @@ export default function MarketplacePage() {
-
+

All Listings ({validListings.length} items) @@ -193,9 +193,8 @@ export default function MarketplacePage() {
handleViewListing(listing)} - className={`flex flex-col rounded-xl border-2 border-ink bg-white overflow-hidden cursor-pointer hover:shadow-hard transition-all ${ - isOwner(listing) ? 'ring-2 ring-primary ring-offset-2' : '' - }`} + className={`flex flex-col rounded-xl border-2 border-ink bg-white overflow-hidden cursor-pointer hover:shadow-hard transition-all ${isOwner(listing) ? 'ring-2 ring-primary ring-offset-2' : '' + }`} > {/* Image/Preview Area */}
@@ -206,14 +205,14 @@ export default function MarketplacePage() {
dataset - +
ACTIVE {isOwner(listing) && ( YOUR LISTING )}
- + {/* Explorer Links */}
e.stopPropagation()}> - - - - + + + + - - + +

- + {/* Content */}

{listing.name}

- + {/* Object ID */}

ID: {listing.id.slice(0, 8)}...{listing.id.slice(-6)}

- + {/* Blob ID with Walrus link */}
e.stopPropagation()}>

@@ -267,15 +266,15 @@ export default function MarketplacePage() { title="View on WalrusScan" > - - - - - + + + + +

- + {/* Stats */}
@@ -287,7 +286,7 @@ export default function MarketplacePage() {

{formatSize(Number(listing.totalSize))}

- + {/* Seller */}
person diff --git a/bun.lock b/bun.lock index 594a39f..690c1b5 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "datex", @@ -9,6 +10,7 @@ "@mysten/enoki": "^0.13.0", "@mysten/kiosk": "^0.14.6", "@mysten/payment-kit": "^0.0.20", + "@mysten/seal": "^0.9.6", "@mysten/sui": "^1.45.2", "@mysten/walrus": "^0.9.0", "@mysten/zksend": "^0.14.12", @@ -231,6 +233,8 @@ "@mysten/payment-kit": ["@mysten/payment-kit@0.0.20", "", { "dependencies": { "@mysten/bcs": "1.9.2", "@mysten/sui": "1.45.2" } }, "sha512-nouYDzjRCVm1w4SByJHe/3F6/UGXznPfVM66mZQ/aPPyx7S7pFECqKz/66pWc9a2V/iDf5W4NGjC+Tkk/wzxAQ=="], + "@mysten/seal": ["@mysten/seal@0.9.6", "", { "dependencies": { "@mysten/bcs": "1.9.2", "@mysten/sui": "1.45.2", "@noble/curves": "=1.9.4", "@noble/hashes": "^1.8.0" } }, "sha512-qw4JiEL8NibuAASWTbYC9xbtMMIAgbfNNopZomiCZqIgJFA/q6lWCzBqVhzU5POB7UCwLZjskwZJ+kME80wsCg=="], + "@mysten/signers": ["@mysten/signers@0.6.2", "", { "dependencies": { "@google-cloud/kms": "^4.5.0", "@mysten/ledgerjs-hw-app-sui": "0.7.0", "@mysten/sui": "1.45.2", "@noble/curves": "=1.9.4", "@noble/hashes": "^1.8.0", "asn1-ts": "^8.0.2" } }, "sha512-TFdsoixjly/PHSRDF93wAl/6+cYGS/4J2PCg8lLoxmrLQZCDqIKYwP/dy1243qkXzyeN9d0Ri5IJvozcDpoY/g=="], "@mysten/slush-wallet": ["@mysten/slush-wallet@0.3.0", "", { "dependencies": { "@mysten/sui": "1.45.2", "@mysten/utils": "0.2.0", "@mysten/wallet-standard": "0.19.9", "@mysten/window-wallet-core": "0.1.1", "mitt": "^3.0.1", "valibot": "^1.2.0" } }, "sha512-XxEFDpJrrlgEt6cayOtFEEqsbI/2lqMl+QATvsT4TYQJ7B3FkBHPeKsyG+GVuvhVDhqXaVlGXwWrk66ri0uzhQ=="], diff --git a/lib/seal.ts b/lib/seal.ts new file mode 100644 index 0000000..dbd2e14 --- /dev/null +++ b/lib/seal.ts @@ -0,0 +1,309 @@ +// Seal SDK integration for threshold encryption with purchase-based access control +// Uses @mysten/seal for real threshold encryption on Sui testnet +// Pattern: Allowlist Encryption - only users with PurchaseReceipt can decrypt + +import { SealClient, SessionKey, EncryptedObject } from '@mysten/seal' +import { SuiClient, getFullnodeUrl } from '@mysten/sui/client' +import { Transaction } from '@mysten/sui/transactions' +import { fromBase64, toBase64 } from '@mysten/sui/utils' + +// === Configuration === +const NETWORK = (process.env.NEXT_PUBLIC_SUI_NETWORK as 'testnet' | 'mainnet') || 'testnet' +const SEAL_POLICY_PACKAGE_ID = process.env.NEXT_PUBLIC_SEAL_POLICY_PACKAGE_ID || '' + +// Seal Key Servers for Testnet +const SEAL_KEY_SERVER_CONFIGS = [ + { + objectId: '0x73d05d62c18d9374e3ea529e8e0ed6161da1a141a94d3f76ae3fe4e99356db75', + weight: 1, + }, + { + objectId: '0xf5d14a81a982144ae441cd7d64b09027f116a468bd36e7eca494f750591623c8', + weight: 1, + }, + { + objectId: '0x6a0726a1ea3d62ba2f2ae51104f2c3633c003fb75621d06fde47f04dc930ba06', + weight: 1, + }, +] + +let sealClient: SealClient | null = null +let suiClient: SuiClient | null = null + +// === Client Initialization === + +function getSuiClient(): SuiClient { + if (!suiClient) { + suiClient = new SuiClient({ url: getFullnodeUrl(NETWORK) }) + } + return suiClient +} + +function getSealClient(): SealClient { + if (!sealClient) { + sealClient = new SealClient({ + suiClient: getSuiClient(), + serverConfigs: SEAL_KEY_SERVER_CONFIGS, + verifyKeyServers: false, + }) + } + return sealClient +} + +// === Helper Functions === + +export function uint8ArrayToBase64(bytes: Uint8Array): string { + return toBase64(bytes) +} + +export function base64ToUint8Array(base64: string): Uint8Array { + return fromBase64(base64) +} + +// === Seal Threshold Encryption === + +// Check if Seal is available +export function isSealAvailable(): boolean { + return !!SEAL_POLICY_PACKAGE_ID && SEAL_POLICY_PACKAGE_ID !== '0x0' +} + +/** + * Encrypt data using Seal threshold encryption + * Only users with valid access (via seal_approve) can decrypt + * + * @param data - Raw data to encrypt + * @param policyObjectId - ID of the policy object that controls access (e.g., listing ID) + */ +export async function encryptWithSeal( + data: Uint8Array, + policyObjectId: string +): Promise { + const client = getSealClient() + + console.log('[Seal] Encrypting...', { + dataSize: data.length, + policyObjectId, + packageId: SEAL_POLICY_PACKAGE_ID, + }) + + const { encryptedObject } = await client.encrypt({ + threshold: 2, + packageId: SEAL_POLICY_PACKAGE_ID, + id: policyObjectId, // Policy object ID that controls decryption access + data, + }) + + // encryptedObject is already a Uint8Array + const encryptedBytes = encryptedObject + + console.log('[Seal] Encrypted:', { + originalSize: data.length, + encryptedSize: encryptedBytes.length + }) + + return encryptedBytes +} + +/** + * Decrypt data using Seal threshold encryption + * Requires a valid session key and transaction that proves access + * + * @param encryptedBytes - The encrypted data (serialized EncryptedObject) + * @param sessionKey - Session key for decryption + * @param txBytes - Transaction bytes that call seal_approve + */ +export async function decryptWithSeal( + encryptedBytes: Uint8Array, + sessionKey: SessionKey, + txBytes: Uint8Array +): Promise { + const client = getSealClient() + + console.log('[Seal] Decrypting...', { + encryptedSize: encryptedBytes.length, + }) + + try { + const decrypted = await client.decrypt({ + data: encryptedBytes, + sessionKey, + txBytes, + }) + + console.log('[Seal] Decrypted:', { decryptedSize: decrypted.length }) + return decrypted + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + console.error('[Seal] Decrypt error:', errorMsg) + if (errorMsg.includes('ENoAccess') || errorMsg.includes('assert')) { + throw new Error('Access denied: You do not have permission to decrypt this data') + } + throw error + } +} + +// === High-Level API === + +/** + * Encrypt a file for upload to Walrus + * + * @param file - The file to encrypt + * @param policyObjectId - Policy object ID for access control + */ +export async function encryptFile( + file: File, + policyObjectId: string +): Promise<{ encryptedBlob: Blob; policyId: string }> { + const arrayBuffer = await file.arrayBuffer() + const data = new Uint8Array(arrayBuffer) + + if (!isSealAvailable()) { + throw new Error('Seal is not configured. Please set NEXT_PUBLIC_SEAL_POLICY_PACKAGE_ID') + } + + console.log('[Seal] Encrypting file...', { + fileName: file.name, + size: data.length, + policyObjectId, + }) + + const encryptedBytes = await encryptWithSeal(data, policyObjectId) + + return { + encryptedBlob: new Blob([encryptedBytes.slice()], { type: 'application/octet-stream' }), + policyId: policyObjectId, + } +} + +/** + * Encrypt raw data for upload + */ +export async function encryptData( + data: Uint8Array, + policyObjectId: string +): Promise<{ encryptedData: Uint8Array; policyId: string }> { + if (!isSealAvailable()) { + throw new Error('Seal is not configured. Please set NEXT_PUBLIC_SEAL_POLICY_PACKAGE_ID') + } + + const encryptedData = await encryptWithSeal(data, policyObjectId) + + return { + encryptedData, + policyId: policyObjectId, + } +} + +/** + * Create a session key for decryption + * Used by buyers to decrypt purchased data + * + * Uses the two-step flow since wallet adapters provide signPersonalMessage + * rather than a full Signer object + */ +export async function createSessionKey( + userAddress: string, + signPersonalMessage: (msg: { message: Uint8Array }) => Promise<{ signature: string }> +): Promise { + const suiClient = getSuiClient() + + // Step 1: Create session key without signer + const sessionKey = await SessionKey.create({ + address: userAddress, + packageId: SEAL_POLICY_PACKAGE_ID, + ttlMin: 10, + suiClient, + }) + + // Step 2: Get the personal message that needs to be signed + const personalMessage = sessionKey.getPersonalMessage() + + // Step 3: Sign the message using the wallet adapter's signPersonalMessage + const { signature } = await signPersonalMessage({ message: personalMessage }) + + // Step 4: Set the signature on the session key + await sessionKey.setPersonalMessageSignature(signature) + + return sessionKey +} + +/** + * Build seal_approve transaction for marketplace purchase verification + * The Move function should verify the caller owns a PurchaseReceipt + */ +export function buildSealApproveTransaction( + policyObjectId: string, + purchaseReceiptId: string +): Transaction { + const tx = new Transaction() + + // Call the seal_approve function in your marketplace module + // This should verify the caller has a valid PurchaseReceipt for this listing + tx.moveCall({ + target: `${SEAL_POLICY_PACKAGE_ID}::marketplace::seal_approve`, + arguments: [ + tx.pure.id(policyObjectId), // The listing/policy ID + tx.object(purchaseReceiptId), // User's PurchaseReceipt + ], + }) + + return tx +} + +/** + * Decrypt a file after purchase + * Requires valid PurchaseReceipt + */ +export async function decryptFile( + encryptedBlob: Blob, + policyObjectId: string, + purchaseReceiptId: string, + userAddress: string, + signPersonalMessage: (msg: { message: Uint8Array }) => Promise<{ signature: string }>, + originalType: string = 'application/octet-stream' +): Promise { + const arrayBuffer = await encryptedBlob.arrayBuffer() + const encryptedBytes = new Uint8Array(arrayBuffer) + + // Create session key + const sessionKey = await createSessionKey(userAddress, signPersonalMessage) + + // Build seal_approve transaction + const tx = buildSealApproveTransaction(policyObjectId, purchaseReceiptId) + const txBytes = await tx.build({ client: getSuiClient(), onlyTransactionKind: true }) + + // Decrypt + const decrypted = await decryptWithSeal(encryptedBytes, sessionKey, txBytes) + + return new Blob([decrypted.slice()], { type: originalType }) +} + +/** + * Decrypt raw data after purchase + */ +export async function decryptData( + encryptedData: Uint8Array, + policyObjectId: string, + purchaseReceiptId: string, + userAddress: string, + signPersonalMessage: (msg: { message: Uint8Array }) => Promise<{ signature: string }> +): Promise { + const sessionKey = await createSessionKey(userAddress, signPersonalMessage) + const tx = buildSealApproveTransaction(policyObjectId, purchaseReceiptId) + const txBytes = await tx.build({ client: getSuiClient(), onlyTransactionKind: true }) + + return decryptWithSeal(encryptedData, sessionKey, txBytes) +} + +// === Type Exports === + +export interface EncryptionPolicy { + type: 'purchase' // Only purchase-based access for marketplace + policyObjectId: string +} + +export interface PurchaseAccess { + listingId: string + purchaseReceiptId: string + buyer: string +} \ No newline at end of file diff --git a/package.json b/package.json index effe33a..c4d6b11 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@mysten/enoki": "^0.13.0", "@mysten/kiosk": "^0.14.6", "@mysten/payment-kit": "^0.0.20", + "@mysten/seal": "^0.9.6", "@mysten/sui": "^1.45.2", "@mysten/walrus": "^0.9.0", "@mysten/zksend": "^0.14.12", diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index af4a34c..0000000 --- a/public/sw.js +++ /dev/null @@ -1,10 +0,0 @@ -// Service Worker placeholder -// This file prevents 404 errors when libraries attempt to register a service worker - -self.addEventListener('install', () => { - self.skipWaiting(); -}); - -self.addEventListener('activate', (event) => { - event.waitUntil(self.clients.claim()); -});