diff --git a/python/numbersprotocol_capture/client.py b/python/numbersprotocol_capture/client.py index 529391a..f211784 100644 --- a/python/numbersprotocol_capture/client.py +++ b/python/numbersprotocol_capture/client.py @@ -5,10 +5,11 @@ from __future__ import annotations import json +import logging import mimetypes from pathlib import Path from typing import Any -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse import httpx @@ -30,6 +31,8 @@ UpdateOptions, ) +logger = logging.getLogger(__name__) + DEFAULT_BASE_URL = "https://api.numbersprotocol.io/api/v3" HISTORY_API_URL = "https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near" MERGE_TREE_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/get-full-asset-tree" @@ -164,6 +167,15 @@ def __enter__(self) -> Capture: def __exit__(self, *args: Any) -> None: self.close() + def __repr__(self) -> str: + if not self._token: + masked = "***" + elif len(self._token) < 4: + masked = "*" * len(self._token) + else: + masked = f"{'*' * (len(self._token) - 4)}{self._token[-4:]}" + return f"Capture(token='{masked}', base_url='{self._base_url}')" + def close(self) -> None: """Close the HTTP client.""" self._client.close() @@ -213,8 +225,8 @@ def _request( try: error_data = response.json() message = error_data.get("detail") or error_data.get("message") or message - except Exception: - pass + except Exception as exc: + logger.debug("Could not parse error response body: %s", exc) raise create_api_error(response.status_code, message, nid) result: dict[str, Any] = response.json() @@ -296,11 +308,11 @@ def register( if options.headline: form_data["headline"] = options.headline - # Handle signing if private key provided - if options.sign and options.sign.private_key: + # Handle signing if sign options provided (private_key or custom signer) + if options.sign and (options.sign.private_key or options.sign.signer): proof_hash = sha256(data) proof = create_integrity_proof(proof_hash, mime_type) - signature = sign_integrity_proof(proof, options.sign.private_key) + signature = sign_integrity_proof(proof, options.sign) proof_dict = { "proof_hash": proof.proof_hash, @@ -336,7 +348,7 @@ def update( caption: str | None = None, headline: str | None = None, commit_message: str | None = None, - custom_metadata: dict[str, Any] | None = None, + custom_metadata: dict[str, str | int | float | bool] | None = None, options: UpdateOptions | None = None, ) -> Asset: """ @@ -384,7 +396,12 @@ def update( if options.commit_message: form_data["commit_message"] = options.commit_message if options.custom_metadata: - form_data["nit_commit_custom"] = json.dumps(options.custom_metadata) + serialized = json.dumps(options.custom_metadata) + if len(serialized.encode("utf-8")) > 10 * 1024: + raise ValidationError( + "custom_metadata must not exceed 10 KB when serialized" + ) + form_data["nit_commit_custom"] = serialized response = self._request( "PATCH", @@ -661,6 +678,9 @@ def search_asset( # Add input source files_data: dict[str, Any] | None = None if options.file_url: + parsed = urlparse(options.file_url) + if parsed.scheme not in ("http", "https"): + raise ValidationError("file_url must use http or https scheme") form_data["url"] = options.file_url elif options.nid: form_data["nid"] = options.nid @@ -703,8 +723,8 @@ def search_asset( or error_data.get("error") or message ) - except Exception: - pass + except Exception as exc: + logger.debug("Could not parse asset search error response body: %s", exc) raise create_api_error(response.status_code, message) data = response.json() @@ -763,8 +783,8 @@ def search_nft(self, nid: str) -> NftSearchResult: or error_data.get("error") or message ) - except Exception: - pass + except Exception as exc: + logger.debug("Could not parse NFT search error response body: %s", exc) raise create_api_error(response.status_code, message, nid) data = response.json() diff --git a/python/numbersprotocol_capture/crypto.py b/python/numbersprotocol_capture/crypto.py index 597523c..f20c816 100644 --- a/python/numbersprotocol_capture/crypto.py +++ b/python/numbersprotocol_capture/crypto.py @@ -4,13 +4,20 @@ import hashlib import json +import logging import time +from typing import TYPE_CHECKING from eth_account import Account from eth_account.messages import encode_defunct from .types import AssetSignature, IntegrityProof +if TYPE_CHECKING: + from .types import SignOptions + +logger = logging.getLogger(__name__) + def sha256(data: bytes | bytearray) -> str: """ @@ -45,22 +52,27 @@ def create_integrity_proof(proof_hash: str, mime_type: str) -> IntegrityProof: ) -def sign_integrity_proof(proof: IntegrityProof, private_key: str) -> AssetSignature: +def sign_integrity_proof( + proof: IntegrityProof, + private_key_or_options: "str | SignOptions", +) -> AssetSignature: """ Signs an integrity proof using EIP-191 standard. + Accepts either a raw private key string (legacy) or a :class:`SignOptions` + object. When a ``SignOptions.signer`` callback is provided the private key + never enters this process, reducing the window of key exposure in memory. + Args: proof: IntegrityProof object to sign. - private_key: Ethereum private key (hex string with or without 0x prefix). + private_key_or_options: Ethereum private key string **or** a + :class:`~numbersprotocol_capture.types.SignOptions` instance with + either ``private_key`` or ``signer`` + ``address``. Returns: AssetSignature containing the signature data. """ - # Ensure private key has 0x prefix - if not private_key.startswith("0x"): - private_key = f"0x{private_key}" - - account = Account.from_key(private_key) + from .types import SignOptions as _SignOptions # Compute integrity hash of the signed metadata JSON proof_dict = { @@ -71,15 +83,45 @@ def sign_integrity_proof(proof: IntegrityProof, private_key: str) -> AssetSignat proof_json = json.dumps(proof_dict, separators=(",", ":")) integrity_sha = sha256(proof_json.encode("utf-8")) - # Sign the integrity hash using EIP-191 - message = encode_defunct(text=integrity_sha) - signed = account.sign_message(message) + if isinstance(private_key_or_options, str): + # Legacy path: raw private key string + pk = private_key_or_options + if not pk.startswith("0x"): + pk = f"0x{pk}" + account = Account.from_key(pk) + message = encode_defunct(text=integrity_sha) + signed = account.sign_message(message) + sig_hex: str = signed.signature.hex() + public_key: str = account.address + elif isinstance(private_key_or_options, _SignOptions): + opts = private_key_or_options + if opts.signer is not None and opts.address is not None: + # Custom signer path – private key stays out of this process + sig_hex = opts.signer(integrity_sha) + public_key = opts.address + elif opts.private_key is not None: + pk = opts.private_key + if not pk.startswith("0x"): + pk = f"0x{pk}" + account = Account.from_key(pk) + message = encode_defunct(text=integrity_sha) + signed = account.sign_message(message) + sig_hex = signed.signature.hex() + public_key = account.address + else: + raise ValueError( + "sign_integrity_proof: provide either private_key or both signer and address" + ) + else: + raise TypeError( + f"sign_integrity_proof: unexpected argument type {type(private_key_or_options)}" + ) return AssetSignature( proof_hash=proof.proof_hash, provider="capture-sdk", - signature=signed.signature.hex(), - public_key=account.address, + signature=sig_hex, + public_key=public_key, integrity_sha=integrity_sha, ) @@ -104,5 +146,6 @@ def verify_signature(message: str, signature: str, expected_address: str) -> boo msg = encode_defunct(text=message) recovered: str = Account.recover_message(msg, signature=signature) return recovered.lower() == expected_address.lower() - except Exception: + except Exception as exc: + logger.debug("verify_signature failed: %s", exc, exc_info=True) return False diff --git a/python/numbersprotocol_capture/types.py b/python/numbersprotocol_capture/types.py index 0eff64d..692b291 100644 --- a/python/numbersprotocol_capture/types.py +++ b/python/numbersprotocol_capture/types.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -35,10 +36,24 @@ class CaptureOptions: @dataclass class SignOptions: - """Options for signing asset registration.""" + """Options for signing asset registration. - private_key: str - """Ethereum private key for EIP-191 signing.""" + Provide either a ``private_key`` (held in memory only for the duration of + the signing operation) **or** a ``signer`` callback together with an + ``address`` so that the private key never enters this process at all. + """ + + private_key: str | None = None + """Ethereum private key for EIP-191 signing (with or without 0x prefix).""" + + signer: Callable[[str], str] | None = None + """Custom signer callback – receives the hex-encoded integrity hash and + must return an EIP-191 signature hex string. Use this to keep the private + key entirely out of the SDK process.""" + + address: str | None = None + """Ethereum address that corresponds to the ``signer`` callback. + Required when ``signer`` is provided.""" @dataclass @@ -74,8 +89,8 @@ class UpdateOptions: commit_message: str | None = None """Description of the changes.""" - custom_metadata: dict[str, Any] | None = None - """Custom metadata fields.""" + custom_metadata: dict[str, str | int | float | bool] | None = None + """Custom metadata fields (values must be str, int, float, or bool; max 10 KB serialized).""" @dataclass diff --git a/ts/src/client.ts b/ts/src/client.ts index c86440e..20bdbcf 100644 --- a/ts/src/client.ts +++ b/ts/src/client.ts @@ -248,11 +248,11 @@ export class Capture { const publicAccess = options?.publicAccess ?? true formData.append('public_access', String(publicAccess)) - // Handle signing if private key provided - if (options?.sign?.privateKey) { + // Handle signing if sign options provided (privateKey or custom signer) + if (options?.sign && (options.sign.privateKey || options.sign.signer)) { const proofHash = await sha256(data) const proof = createIntegrityProof(proofHash, mimeType) - const signature = await signIntegrityProof(proof, options.sign.privateKey) + const signature = await signIntegrityProof(proof, options.sign) formData.append('signed_metadata', JSON.stringify(proof)) formData.append('signature', JSON.stringify([signature])) @@ -303,7 +303,13 @@ export class Capture { formData.append('commit_message', options.commitMessage) } if (options.customMetadata) { - formData.append('nit_commit_custom', JSON.stringify(options.customMetadata)) + const serialized = JSON.stringify(options.customMetadata) + if (new TextEncoder().encode(serialized).length > 10 * 1024) { + throw new ValidationError( + 'customMetadata must not exceed 10 KB when serialized' + ) + } + formData.append('nit_commit_custom', serialized) } const response = await this.request( @@ -497,6 +503,12 @@ export class Capture { // Add input source if (options.fileUrl) { + const parsed = new URL(options.fileUrl) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new ValidationError( + 'fileUrl must use http: or https: scheme' + ) + } formData.append('url', options.fileUrl) } else if (options.nid) { formData.append('nid', options.nid) diff --git a/ts/src/crypto.ts b/ts/src/crypto.ts index de214ab..1c4f809 100644 --- a/ts/src/crypto.ts +++ b/ts/src/crypto.ts @@ -1,5 +1,5 @@ import { Wallet, verifyMessage } from 'ethers' -import type { IntegrityProof, AssetSignature } from './types.js' +import type { IntegrityProof, AssetSignature, SignOptions } from './types.js' /** * Computes SHA-256 hash of data using Web Crypto API. @@ -32,27 +32,50 @@ export function createIntegrityProof( /** * Signs an integrity proof using EIP-191 standard. - * Returns the signature data required for asset registration. + * Accepts either a raw private key string or a custom signer callback (via SignOptions) + * to minimise the lifetime of key material in memory. + * + * @param proof - Integrity proof to sign. + * @param privateKeyOrOptions - Ethereum private key string **or** a SignOptions object + * with a `signer` callback and `address`. */ export async function signIntegrityProof( proof: IntegrityProof, - privateKey: string + privateKeyOrOptions: string | SignOptions ): Promise { - const wallet = new Wallet(privateKey) - // Compute integrity hash of the signed metadata JSON const proofJson = JSON.stringify(proof) const proofBytes = new TextEncoder().encode(proofJson) const integritySha = await sha256(proofBytes) - // Sign the integrity hash using EIP-191 - const signature = await wallet.signMessage(integritySha) + let signature: string + let address: string + + if (typeof privateKeyOrOptions === 'string') { + // Legacy path: private key passed directly + const wallet = new Wallet(privateKeyOrOptions) + signature = await wallet.signMessage(integritySha) + address = wallet.address + } else if (privateKeyOrOptions.signer && privateKeyOrOptions.address) { + // Custom signer path: private key never enters this process + signature = await privateKeyOrOptions.signer(integritySha) + address = privateKeyOrOptions.address + } else if (privateKeyOrOptions.privateKey) { + // SignOptions with privateKey field + const wallet = new Wallet(privateKeyOrOptions.privateKey) + signature = await wallet.signMessage(integritySha) + address = wallet.address + } else { + throw new Error( + 'signIntegrityProof: provide either privateKey or both signer and address' + ) + } return { proofHash: proof.proof_hash, provider: 'capture-sdk', signature, - publicKey: wallet.address, + publicKey: address, integritySha, } } diff --git a/ts/src/types.ts b/ts/src/types.ts index bc57312..7676d13 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -22,10 +22,22 @@ export interface CaptureOptions { /** * Options for signing asset registration. + * Provide either a `privateKey` (the key is held in memory only for the duration of signing) + * or a `signer` callback to keep the private key out of this process entirely. */ export interface SignOptions { /** Ethereum private key for EIP-191 signing */ - privateKey: string + privateKey?: string + /** + * Custom signer callback – use this to keep the private key out of the SDK process. + * The callback receives the hex-encoded integrity hash and must return an EIP-191 signature. + */ + signer?: (message: string) => Promise + /** + * Ethereum address corresponding to the `signer` callback. + * Required when using the `signer` callback. + */ + address?: string } /** @@ -54,8 +66,8 @@ export interface UpdateOptions { headline?: string /** Description of the changes */ commitMessage?: string - /** Custom metadata fields */ - customMetadata?: Record + /** Custom metadata fields (values must be string, number, or boolean; max 10 KB serialized) */ + customMetadata?: Record } /** diff --git a/ts/tsup.config.ts b/ts/tsup.config.ts index 01f694b..ad1ebb8 100644 --- a/ts/tsup.config.ts +++ b/ts/tsup.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, splitting: false, - sourcemap: true, + sourcemap: false, clean: true, treeshake: true, minify: false,