Skip to content
Draft
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
44 changes: 32 additions & 12 deletions python/numbersprotocol_capture/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
69 changes: 56 additions & 13 deletions python/numbersprotocol_capture/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
)

Expand All @@ -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
25 changes: 20 additions & 5 deletions python/numbersprotocol_capture/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions ts/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down Expand Up @@ -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<AssetApiResponse>(
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 31 additions & 8 deletions ts/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<AssetSignature> {
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,
}
}
Expand Down
Loading