技术审计文档
本文档面向安全研究员、密码学审计人员和开发者,精确描述 Nephele Workshop 安全相关模块的实现细节、已知限制与威胁模型。
所有核心源码直接附于文中,白盒供审。敏感常数(如水印密码)已脱敏处理。
本文档对应 Nephele Workshop v0.6.1-beta.1 代码库状态。
提示
本文档中所有源码片段以 MIT License 公开。自由复制、修改、商用;保留版权声明即可。
1. 审计范围
1.1 受审文件清单
| 功能 | 文件 | 审计范围 |
|---|---|---|
| 数字存证 | tools/rights/logic.py | 文件哈希、批量存证、深度验证 |
tools/rights/utils.py | Merkle Tree | |
tools/rights/tsa_client.py | RFC 3161 TSA 客户端 | |
tools/rights/rights_packer.py | .nep 容器打包 | |
| 维权取证 | tools/rights/url_evidence.py | URL 取证主流程、TLS、CAPTCHA |
core/browser/session.py | Playwright 会话 / 截图 | |
| 盲水印 | tools/packer/watermark_protection.py | 定长编码、round-trip 验证、异常回退 |
tools/packer/logic.py / agent_api.py | 业务层调用 | |
core/workers/watermark_worker.py | 后台提取线程 | |
blind_watermark (PyPI) | DWT+DCT+SVD 底层算法 | |
| AI 凭据检测 | tools/validator/logic.py | 元数据读取、规则匹配、证据分级 |
tools/validator/c2pa_verifier.py | 官方 C2PA SDK 适配、信任状态解析 | |
core/workers/ai_detector_worker.py | 批量检测线程 |
1.2 产品边界
本审计不涉及:
- 付费 / 许可证模块(
core/license_manager.py、core/payment.py) - 认证 / JWT / CAPTCHA 对接(
core/auth/) - AI 对话 Agent 与云端推理(
core/agent_loop.py、nephele-api/) - 客户端更新与 SSL 固定(
core/updater.py、core/ssl_pinning.py)
这些模块各有独立的安全边界和威胁模型,不在本文档范围内。
2. 数字存证核心实现
2.1 文件哈希计算
# 添加说明文件
readme_content = f"""
维权证据包说明
生成时间:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
本证据包包含以下内容:
1. 时间戳认证文件 - 证明作品创作时间及权属
2. 证据材料 - 原始作品文件
3. 截图证据 - 侵权行为截图
4. 律师函 - 法律维权文件
请妥善保管本证据包,作为法律维权的有效证据。
---
Nephele Workshop - 维权中心
ArisFusion Studio
"""
zipf.writestr("说明.txt", readme_content.encode("utf-8"))
return output_path
except Exception as e:
raise RightsError(f"创建证据包失败: {str(e)}")
def calculate_file_hash(file_path: Path, algorithm: str = "sha256") -> str:
"""
计算文件的哈希值(极简版,仅用于非确权场景)
Args:
file_path: 文件路径
algorithm: 哈希算法,默认 'sha256'
Returns:
文件的十六进制哈希值
Raises:
RightsError: 文件不存在或读取失败
"""
if not file_path or not isinstance(file_path, Path):
raise RightsError(f"无效的文件路径: {file_path}")
if not file_path.exists():审计点:
- 算法:SHA-256,无盐值,无密钥(非 HMAC)
- 分块:8,192 字节,流式处理
- 上限:10 GB,超限拒绝
2.2 Merkle Tree 完整实现
class MerkleTree:
"""
Merkle Tree 实现,用于将多个文件的哈希值聚合成单个根哈希
优势:
- 支持 100+ 文件批量处理
- 单个根哈希可代表整个批次
- 节省 TSA 调用成本(1 次调用 vs N 次调用)
Known limitation (second-preimage resistance):
This implementation does NOT use domain separation prefixes for leaf vs
internal nodes (i.e. b'\\x00' for leaves, b'\\x01' for internal nodes as
recommended by RFC 6962 §2.1). Adding prefixes would change the root hash
computation and break backward compatibility with all existing .nep files
and the verification website (verify.arisfusion.com). A future tree_version
bump can introduce domain separation; the current version is safe for our
threat model (user-submitted files, not adversarial tree construction).
"""
def __init__(self, hash_algorithm: str = "sha256"):
"""
初始化 Merkle Tree
Args:
hash_algorithm: 哈希算法,默认 'sha256'
"""
self.hash_algorithm = hash_algorithm
self.leaves: List[str] = []
self.tree: List[List[str]] = []
self.root_hash: Optional[str] = None
def add_leaf(self, data: bytes) -> str:
"""
添加叶子节点(文件哈希)
Args:
data: 文件数据或哈希值(bytes)
Returns:
叶子节点的哈希值
"""
hash_obj = hashlib.new(self.hash_algorithm)
hash_obj.update(data)
leaf_hash = hash_obj.hexdigest()
self.leaves.append(leaf_hash)
return leaf_hash
def add_file_hash(self, file_hash: str) -> None:
"""
直接添加文件哈希值(已计算好的)
Args:
file_hash: 文件的十六进制哈希值
"""
self.leaves.append(file_hash)
def build(self) -> str:
"""
构建 Merkle Tree 并返回根哈希
Returns:
根哈希值(十六进制字符串)
"""
if not self.leaves:
raise ValueError("Merkle Tree 没有叶子节点")
# 如果只有一个叶子节点,直接返回
if len(self.leaves) == 1:
self.root_hash = self.leaves[0]
return self.root_hash
# 构建树:从叶子节点开始,逐层向上
current_level = self.leaves.copy()
self.tree = [current_level]
while len(current_level) > 1:
next_level = []
# 成对处理节点
for i in range(0, len(current_level), 2):
if i + 1 < len(current_level):
# 两个节点:合并哈希
combined = current_level[i] + current_level[i + 1]
else:
# 奇数个节点:最后一个节点复制后与自己合并
combined = current_level[i] + current_level[i]
# 计算父节点哈希
hash_obj = hashlib.new(self.hash_algorithm)
hash_obj.update(combined.encode("utf-8"))
parent_hash = hash_obj.hexdigest()
next_level.append(parent_hash)
self.tree.append(next_level)
current_level = next_level
# 根哈希是最后一层的唯一节点
self.root_hash = current_level[0]
return self.root_hash
def get_proof(self, leaf_index: int) -> List[Dict]:
"""
获取指定叶子节点的 Merkle Proof(用于验证)
Args:
leaf_index: 叶子节点索引
Returns:
Merkle Proof 路径,每个元素为 {'hash': str, 'position': 'left'|'right'}
position 表示兄弟节点在合并时的位置
"""
if not self.tree:
self.build()
if leaf_index >= len(self.leaves):
raise IndexError(f"叶子节点索引超出范围: {leaf_index}")
proof = []
current_index = leaf_index
current_level = 0
while current_level < len(self.tree) - 1:
level = self.tree[current_level]
# 找到兄弟节点并记录位置
if current_index % 2 == 0:
# 当前是左节点,兄弟在右侧
sibling_index = current_index + 1
if sibling_index < len(level):
proof.append({"hash": level[sibling_index], "position": "right"})
else:
# 奇数情况,兄弟是自己(已复制)
proof.append({"hash": level[current_index], "position": "right"})
else:
# 当前是右节点,兄弟在左侧
sibling_index = current_index - 1
proof.append({"hash": level[sibling_index], "position": "left"})
# 移动到上一层
current_index = current_index // 2
current_level += 1
return proof
def verify_proof(self, leaf_hash: str, proof: List[Dict], root_hash: str) -> bool:
"""
验证 Merkle Proof
Args:
leaf_hash: 叶子节点哈希
proof: Merkle Proof 路径(由 get_proof 返回)
root_hash: 根哈希
Returns:
验证是否通过
"""
current_hash = leaf_hash
for step in proof:
sibling_hash = step["hash"]
position = step["position"]
# 按照 build() 相同的位置顺序合并:左 + 右
if position == "right":
combined = current_hash + sibling_hash
else:
combined = sibling_hash + current_hash
hash_obj = hashlib.new(self.hash_algorithm)
hash_obj.update(combined.encode("utf-8"))
current_hash = hash_obj.hexdigest()
return current_hash == root_hash已知安全限制(主动披露):
当前实现没有使用 RFC 6962 §2.1 推荐的域分隔前缀(叶子节点未加 \x00,内部节点未加 \x01)。这意味着在极端对抗性场景下,存在 second-preimage 构造的理论可能。
实际风险评估:
- 威胁模型为"用户为自己的作品生成存证"时,风险可忽略
- 威胁模型若要求"抵抗恶意构造碰撞",当前实现不满足该安全等级
2.3 TSA 客户端完整实现
class TSAClient:
"""
RFC 3161 时间戳服务客户端
支持的服务:
- FreeTSA (https://freetsa.org/tsr) - 免费,无需注册
- DigiCert (http://timestamp.digicert.com)
- IdenTrust (http://timestamp.identrust.com)
- 其他 RFC 3161 兼容服务
"""
# 预定义的 TSA 服务提供商
PROVIDERS = {
"freetsa": {
"name": "FreeTSA",
"url": "https://freetsa.org/tsr",
"hashname": "sha256",
"description": "免费时间戳服务,国际标准 RFC 3161",
"requires_auth": False,
"legal_strength": 3, # 1-5 评分
"price": 0,
},
"digicert": {
"name": "DigiCert",
"url": "http://timestamp.digicert.com",
"hashname": "sha256",
"description": "DigiCert 免费时间戳服务",
"requires_auth": False,
"legal_strength": 4,
"price": 0,
},
"identrust": {
"name": "IdenTrust",
"url": "http://timestamp.identrust.com",
"hashname": "sha256",
"description": "IdenTrust 免费时间戳服务",
"requires_auth": False,
"legal_strength": 4,
"price": 0,
},
}
def __init__(
self, provider: str = "freetsa", custom_url: Optional[str] = None, hashname: str = "sha256", timeout: int = 30
):
"""
初始化 TSA 客户端
Args:
provider: 预定义的服务提供商名称 ('freetsa', 'digicert', 'identrust')
custom_url: 自定义 TSA URL(如果指定,则忽略 provider)
hashname: 哈希算法 ('sha256', 'sha512' 等)
timeout: 请求超时时间(秒)
"""
if not RFC3161_AVAILABLE:
raise ImportError("rfc3161ng 库未安装。请运行: pip install rfc3161ng")
if custom_url:
self.url = custom_url
self.provider_name = "Custom TSA"
self.provider_key = None
elif provider in self.PROVIDERS:
config = self.PROVIDERS[provider]
self.url = config["url"]
self.provider_name = config["name"]
self.provider_key = provider
hashname = config["hashname"]
else:
raise ValueError(f"未知的 TSA 提供商: {provider}。支持的提供商: {', '.join(self.PROVIDERS.keys())}")
self.hashname = hashname
self.timeout = timeout
# 初始化 rfc3161ng 时间戳器
# 注意:某些环境可能遇到 SSL 握手问题,这是正常的
# 我们的设计会自动降级到本地哈希
try:
# include_tsa_certificate (certReq):令牌必须自带签名证书链,
# 否则验证方(尤其 verify.arisfusion.com 拿到的孤令牌)无从验签
self.stamper = rfc3161ng.RemoteTimestamper(
url=self.url, hashname=self.hashname, timeout=self.timeout, include_tsa_certificate=True
)
except Exception as e:
# 如果初始化失败,记录错误但不抛出异常
# 后续调用时会返回失败状态
self.stamper = None
self._init_error = str(e)
FAILOVER_ORDER: List[str] = ["digicert", "freetsa", "identrust"]
def _call_with_retry(self, hash_bytes: bytes, max_retries: int = 3) -> bytes:
"""
带指数退避和提供商故障转移的 TSA 调用
Args:
hash_bytes: 要签名的哈希值(二进制)
max_retries: 每个提供商的最大重试次数
Returns:
TSR 令牌(二进制)
Raises:
Exception: 所有提供商均失败
"""
# 构建尝试顺序:当前提供商优先,然后是其他提供商
providers_to_try = []
if self.provider_key:
providers_to_try.append(self.provider_key)
for p in self.FAILOVER_ORDER:
if p != self.provider_key:
providers_to_try.append(p)
else:
# 自定义 URL,无故障转移
providers_to_try = [None]
last_error = None
for provider_key in providers_to_try:
if provider_key is not None:
config = self.PROVIDERS[provider_key]
url = config["url"]
hashname = config["hashname"]
provider_name = config["name"]
else:
url = self.url
hashname = self.hashname
provider_name = self.provider_name
for attempt in range(max_retries):
try:
stamper = rfc3161ng.RemoteTimestamper(
url=url, hashname=hashname, timeout=self.timeout, include_tsa_certificate=True
)
tsr_token = stamper(digest=hash_bytes)
# 成功后更新当前提供商信息
if provider_key is not None:
self.provider_name = provider_name
self.provider_key = provider_key
self.url = url
self.stamper = stamper
return tsr_token
except Exception as e:
last_error = e
if attempt < max_retries - 1:
time.sleep(2**attempt)
raise Exception(
f"所有 TSA 提供商均失败 (尝试: {', '.join(p or 'custom' for p in providers_to_try)}): {last_error}"
)
def timestamp_data(self, data: bytes) -> bytes:
"""
对原始数据生成时间戳
Args:
data: 要加时间戳的数据(二进制)
Returns:
TSR (Time-Stamp Response) 二进制令牌
Raises:
Exception: 时间戳请求失败
"""
try:
# 计算数据的哈希值
hash_obj = hashlib.new(self.hashname)
hash_obj.update(data)
data_hash = hash_obj.digest()
# 调用 TSA 服务获取时间戳(带重试和故障转移)
tsr_token = self._call_with_retry(data_hash)
return tsr_token
except Exception as e:
raise Exception(f"时间戳请求失败: {str(e)}")
def timestamp_file(self, file_path: Path, output_path: Optional[Path] = None) -> Dict:
"""
为文件生成时间戳(流式处理,不将整个文件载入内存)
Args:
file_path: 要加时间戳的文件路径
output_path: TSR 文件输出路径(可选,默认为 file_path.tsr)
Returns:
包含时间戳信息的字典
"""
file_path = Path(file_path)
if not file_path.exists():
return {"success": False, "message": f"文件不存在: {file_path}"}
try:
# 流式计算文件哈希(8KB 分块,避免大文件内存溢出)
hash_obj = hashlib.new(self.hashname)
with open(file_path, "rb") as f:
while True:
chunk = f.read(8192)
if not chunk:
break
hash_obj.update(chunk)
file_hash = hash_obj.hexdigest()
# 确定输出路径
if output_path is None:
output_path = file_path.parent / f"{file_path.stem}.tsr"
else:
output_path = Path(output_path)
# 委托给 timestamp_hash(处理 TSA 调用、重试和文件写入)
result = self.timestamp_hash(file_hash, output_path)
# 确保返回文件哈希
if result.get("success"):
result["hash"] = file_hash
return result
except Exception as e:
return {"success": False, "message": f"时间戳生成失败: {str(e)}"}
def timestamp_hash(self, hash_value: str, output_path: Path) -> Dict:
"""
为已知的哈希值生成时间戳(用于 Merkle Root 等场景)
Args:
hash_value: 十六进制哈希值字符串
output_path: TSR 文件输出路径
Returns:
包含时间戳信息的字典
"""
try:
# 将十六进制哈希值转换为二进制
hash_bytes = bytes.fromhex(hash_value)
# 获取时间戳令牌(带重试和故障转移)
tsr_token = self._call_with_retry(hash_bytes)
# 保存 TSR 文件
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(tsr_token)
# 从 TSR 令牌中提取 TSA 认证时间(而非本机时钟)
tsa_timestamp = datetime.now().isoformat() # fallback
tsa_issuer = self.provider_name
try:
from asn1crypto import tsp, cms
# Try TimeStampResp first, then ContentInfo
signed_data = None
try:
ts_resp = tsp.TimeStampResp.load(tsr_token)
signed_data = ts_resp["time_stamp_token"]["content"]
except (ValueError, KeyError, TypeError):
try:
ci = cms.ContentInfo.load(tsr_token)
if ci["content_type"].native == "signed_data":
signed_data = ci["content"]
except (ValueError, KeyError, TypeError):
pass
if signed_data:
tst_info = signed_data["encap_content_info"]["content"].parsed
gen_time = tst_info["gen_time"].native
if gen_time:
tsa_timestamp = gen_time.isoformat()
# Extract issuer CN from signer info
try:
signer_infos = signed_data["signer_infos"]
if signer_infos:
sid = signer_infos[0]["sid"]
if sid.name == "issuer_and_serial_number":
for rdn in sid.chosen["issuer"].chosen:
for attr in rdn:
if attr["type"].dotted == "2.5.4.3":
tsa_issuer = attr["value"].native
break
except (KeyError, IndexError, ValueError):
pass
except ImportError:
pass # asn1crypto not available, use fallback values
return {
"success": True,
"timestamp": tsa_timestamp,
"hash": hash_value,
"issuer": tsa_issuer,
"tsr_path": str(output_path),
"algorithm": self.hashname.upper(),
"message": f"时间戳生成成功,TSR 文件: {output_path.name}",
}
except Exception as e:
return {"success": False, "message": f"时间戳生成失败: {str(e)}"}
def verify_tsr(
self,
tsr_path: Path,
data: Optional[bytes] = None,
digest: Optional[bytes] = None,
) -> Dict:
"""
验证 TSR 时间戳令牌
Args:
tsr_path: TSR 文件路径
data: 原始数据(可选,库内部计算哈希后比对)
digest: 预计算的哈希值(可选,直接与 TSR 中记录的哈希比对)
data 和 digest 二选一;都不传则仅验证 TSR 结构
Returns:
验证结果字典,包含 'valid', 'message', 'issuer' 等字段。
当 data/digest 未提供时,结果包含 'partial_verification': True 标志。
"""
tsr_path = Path(tsr_path)
if not tsr_path.exists():
return {"valid": False, "message": f"TSR 文件不存在: {tsr_path}"}
try:
with open(tsr_path, "rb") as f:
tsr_token = f.read()
if digest is None and data is None:
# 结构验证:无原始数据时,验证 TSR 文件结构有效性
return self._verify_tsr_structure(tsr_token)
# messageImprint 比对走 asn1crypto 自解析。不用 rfc3161ng.check_timestamp:
# 它默认 sha1、且对 DigiCert 令牌在 load_certificate 处抛 TypeError,
# 带 data/digest 的验证路径实际从未工作过。
info = self._extract_tst_info(tsr_token)
if info is None:
res = self._verify_tsr_structure(tsr_token)
res["valid"] = False
res["message"] = "无法解析时间戳令牌中的 messageImprint"
return res
hashname, hashed_message, gen_time = info
if digest is None:
digest = hashlib.new(hashname, data).digest()
if digest != hashed_message:
return {"valid": False, "message": "时间戳验证失败:哈希值不匹配"}
# 完整性通过后必须验签——messageImprint 比对只防误改,不防伪造:
# 自造令牌可以写任意哈希和时间。签名+证书链才把令牌钉到真实 TSA。
from .tsa_verify import verify_token_signature
sig = verify_token_signature(tsr_token)
base = {
"issuer": self._extract_issuer_from_tsr(tsr_token),
"algorithm": hashname.upper(),
"timestamp": gen_time.isoformat() if gen_time else "",
"signature": sig["signature"],
"signer": sig.get("signer"),
"chain_root": sig.get("chain_root"),
}
if sig["signature"] == "verified":
return {
"valid": True,
"message": f"时间戳验证通过(数据完整性、TSA 签名与证书链均已确认,根: {sig['chain_root']})",
**base,
}
if sig["signature"] == "unknown_signer":
return {
"valid": False,
"message": "数据完整性匹配,但无法本地验证 TSA 签名(未知签发者)。"
"可用 openssl ts -verify 独立验证该令牌。",
**base,
}
return {
"valid": False,
"message": f"时间戳验证失败:{sig['message']}",
**base,
}
except Exception as e:
return {"valid": False, "message": f"时间戳验证失败: {str(e)}"}
def _extract_tst_info(self, tsr_token: bytes):
"""从 TSR/令牌中解出 (hash 算法名, messageImprint 哈希, 签发时间)。
兼容 TimeStampResp 与裸 ContentInfo/SignedData 两种落盘格式;
解析失败返回 None。"""
try:
from asn1crypto import cms, tsp
signed_data = None
try:
ts_resp = tsp.TimeStampResp.load(tsr_token)
signed_data = ts_resp["time_stamp_token"]["content"]
except (ValueError, KeyError, TypeError):
try:
ci = cms.ContentInfo.load(tsr_token)
if ci["content_type"].native == "signed_data":
signed_data = ci["content"]
except (ValueError, KeyError, TypeError):
pass
if signed_data is None:
return None
econtent = signed_data["encap_content_info"]["content"]
tst = None
if isinstance(econtent, tsp.TSTInfo):
tst = econtent
else:
try:
parsed = econtent.parsed
if isinstance(parsed, tsp.TSTInfo):
tst = parsed
except (ValueError, AttributeError):
pass
if tst is None:
raw = econtent.native
if isinstance(raw, bytes):
tst = tsp.TSTInfo.load(raw)
if tst is None:
return None
imprint = tst["message_imprint"]
hashname = imprint["hash_algorithm"]["algorithm"].native
hashed_message = imprint["hashed_message"].native
gen_time = None
try:
gen_time = tst["gen_time"].native
except (ValueError, KeyError):
pass
return hashname, hashed_message, gen_time
except Exception:
return None
def _extract_issuer_from_tsr(self, tsr_token: bytes) -> str:
"""Extract the actual issuer CN from a TSR token using asn1crypto.
Falls back to self.provider_name if parsing fails or asn1crypto
is unavailable. This ensures verify_tsr() reports the real issuer
even after failover to a different TSA provider.
"""
try:
from asn1crypto import tsp, cms
signed_data = None
try:
ts_resp = tsp.TimeStampResp.load(tsr_token)
signed_data = ts_resp["time_stamp_token"]["content"]
except (ValueError, KeyError, TypeError):
try:
ci = cms.ContentInfo.load(tsr_token)
if ci["content_type"].native == "signed_data":
signed_data = ci["content"]
except (ValueError, KeyError, TypeError):
pass
if signed_data:
signer_infos = signed_data["signer_infos"]
if signer_infos:
sid = signer_infos[0]["sid"]
if sid.name == "issuer_and_serial_number":
for rdn in sid.chosen["issuer"].chosen:
for attr in rdn:
if attr["type"].dotted == "2.5.4.3": # CN
return attr["value"].native
except (ImportError, Exception):
pass
return self.provider_name
def _verify_tsr_structure(self, tsr_token: bytes) -> Dict:
"""验证 TSR 令牌的结构 + TSA 签名与证书链(不验证数据完整性——
无原始数据可比时,签名仍是自包含可验的)"""
if len(tsr_token) < 20:
return {"valid": False, "message": "TSR 文件过小,可能已损坏"}
if tsr_token[0] != 0x30:
return {"valid": False, "message": "TSR 文件不是有效的 ASN.1 DER 格式"}
try:
from asn1crypto import tsp
try:
ts_resp = tsp.TimeStampResp.load(tsr_token)
status = ts_resp["status"]["status"].native
if status not in ("granted", "granted_with_mods"):审计要点:
- 默认构造:
provider='freetsa' - UI 调用
batch_protect_works(tsa_provider='digicert'),因此用户实际首选 DigiCert - 故障转移顺序:
['digicert', 'freetsa', 'identrust'] - 指数退避:
sleep(2 ** attempt),即 1s, 2s, 4s - 单提供商最多 3 次重试
timestamp_hash中的tsa_timestamp初始回退值为datetime.now().isoformat()(本地时钟),仅在asn1crypto成功解析后才替换为 TSA 断言时间
2.4 批量存证主流程
# 同时解析 TSR 获取时间戳详情(签发时间、签发方)
tsr_result = parse_tsr(tsa_path)
return True, {
"valid": True,
"message": (
"深度验证通过:作品文件与作者声明均已绑定 TSA 时间戳"
if metadata_bound
else "文件验证通过;旧版时间戳未覆盖作者等清单信息"
),
"timestamp": tsr_result.get("timestamp"),
"issuer": tsr_result.get("issuer"),
"root_hash": computed_root,
"metadata_bound": metadata_bound,
"file_count": len(file_paths),
}
else:
return False, {
"valid": False,
"message": f"TSA 签名验证失败:{verify_result.get('message', 'unknown')}",
"root_hash": computed_root,
"metadata_bound": False,
"file_count": len(file_paths),
}
except ImportError:
# rfc3161ng 未安装(仅签发需要它)——parse_tsr 仍走签名+链验证,
# 这里只补上 Merkle Root 与 imprint 的比对
tsr_result = parse_tsr(tsa_path)
if not tsr_result.get("valid"):
return False, {
"valid": False,
"message": tsr_result.get("message", "时间戳验证失败"),
"root_hash": computed_root,
"metadata_bound": False,
"file_count": len(file_paths),
}
tsr_hash_raw = tsr_result.get("hash", "")
tsr_hash = tsr_hash_raw.split(":", 1)[1] if ":" in tsr_hash_raw else tsr_hash_raw
if expected_hash.lower() == tsr_hash.lower():
return True, {
"valid": True,
"message": (
"验证通过:作品文件与作者声明均已绑定 TSA 时间戳"
if metadata_bound
else "文件验证通过;旧版时间戳未覆盖作者等清单信息"
),
"timestamp": tsr_result.get("timestamp"),
"issuer": tsr_result.get("issuer"),
"root_hash": computed_root,
"metadata_bound": metadata_bound,
"file_count": len(file_paths),
"partial_verification": True,
}
else:
return False, {
"valid": False,
"message": "验证失败:存证摘要与 TSR 记录的哈希不匹配",
"computed_root": computed_root,
"tsr_hash": tsr_hash,
"file_count": len(file_paths),
}
except Exception as e:
return False, {"valid": False, "message": f"深度验证失败: {e}"}
def batch_protect_works(
file_paths: List[Path],
author_name: str,
inspiration: Optional[str] = None,
output_dir: Optional[Path] = None,
password: Optional[str] = None,
progress_callback=None,
tsa_provider: str = "digicert",
tsa_timeout: int = 30,
cert_mode: str = "simple",
credential_signer=None,
) -> Dict:
"""
批量保护作品(数字存证核心流程)
流程:
0. (可选)为图片作品生成 C2PA 带凭证副本 —— 必须排在哈希之前
1. 计算所有文件哈希(原图 + 副本一起进树)
2. 构建 Merkle Tree
3. 生成 manifest.json
4. 生成缩略图拼贴
5. 调用 TSA 获取时间戳(覆盖完整清单摘要)
6. 生成 PDF 报告
7. 打包为 .nep 文件
Args:
file_paths: 文件路径列表
author_name: 作者名称
inspiration: 创作灵感(可选)
output_dir: 输出目录
password: .nep 文件密码(可选)
progress_callback: 进度回调 (current, total, message)
credential_signer: c2pa Signer;给了就为每张图生成带凭证副本。
副本与原图**一同进入 Merkle Tree**,被同一个 TSA 时间戳覆盖 ——
否则「先打包证据、再做副本」会让副本的哈希不在证据里,用户拿副本
去核验存证包必然对不上。两份都在,手上是哪一份都验得上。
Returns:
包含处理结果的字典
"""
from .utils import build_merkle_tree_from_files
from .rights_packer import RightsPacker
from .pdf_generator import PDFGenerator
if not file_paths:
raise RightsError("文件列表为空")
if output_dir is None:
output_dir = Path.cwd() / "digital_evidence"
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
image_extensions = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
try:
# 步骤 0: 带凭证副本(可选)。必须早于哈希 —— 副本的字节要进 Merkle
# Tree,才能和原图共用同一个 TSA 时间戳。签名失败不阻断存证:凭证是
# 增值,时间戳才是这个流程的本体。
credential_map: Dict[Path, Path] = {}
credential_errors: List[str] = []
if credential_signer is not None:
from tools.validator.c2pa_signer import SUPPORTED_EXTENSIONS, sign_file
cred_dir = output_dir / "credentialed"
for src in file_paths:
if src.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue # 源文件(PSD/CLIP/SAI…)C2PA 不支持,跳过
if progress_callback:
progress_callback(0, len(file_paths), f"正在生成凭证副本: {src.name}")
dst = cred_dir / f"{src.stem}_credentialed{src.suffix}"
res = sign_file(str(src), str(dst), author=author_name, signer=credential_signer)
if res.ok:
credential_map[src] = dst
else:
credential_errors.append(f"{src.name}: {res.error}")
# 步骤 1: 计算文件哈希并构建 Merkle Tree
if progress_callback:
progress_callback(0, len(file_paths), "正在计算文件哈希...")
def hash_progress(current, total):
if progress_callback:
progress_callback(current, total, f"计算哈希: {current}/{total}")
关键行为:
cert_mode默认"simple",不会自动检测源文件升级- UI 层(
PipelineWindow.qml)根据certifySourceFiles.length > 0显式传入"full" rfc3161ng未安装 → 强制降级本地时间戳,不阻断流程manifest_sha256计算时排除自身键,防止循环依赖
2.5 .nep 打包实现
if pdf_report and pdf_report.exists():
zipf.write(pdf_report, "VerificationReport.pdf")
# 5. 原始作品文件 → works/ 目录
# Use indexed filenames (e.g. works/000_photo.jpg) to avoid collisions
# when multiple source files share the same basename. Must match the
# indexed keys in manifest_data['works_map'].
if source_files:
for idx, file_path in enumerate(source_files):
if isinstance(file_path, str):
file_path = Path(file_path)
if file_path.exists() and file_path.is_file():
zipf.write(file_path, f"works/{idx:03d}_{file_path.name}")
# 6. 添加额外文件
if additional_files:
for file_path in additional_files:
if file_path.exists():
zipf.write(file_path, f"additional/{file_path.name}")
def calculate_package_hash(self) -> str:
"""
计算 .nep 文件包的哈希值(用于验证)
Returns:
文件包的 SHA256 哈希值
"""
if not self.output_path.exists():
raise ValueError(f"文件包不存在: {self.output_path}")
hash_obj = hashlib.sha256()
with open(self.output_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_obj.update(chunk)
return hash_obj.hexdigest()
密码保护逻辑(pack() 方法):
- 若
pyzipper可用且设置了密码:使用AESZipFile+WZ_AES(AES-256) - 若
pyzipper不可用但设置了密码:回退标准 ZIP,manifest 中写入_warning - 无密码:标准
zipfile.ZipFile
2.6 独立验证器(verify.arisfusion.com)
公开部署于 verify.arisfusion.com 的 .nep 独立验证器。
部署形态:
- 单文件 HTML(约 2200 行),无构建工具、无打包流程、无外部依赖
- 纯客户端计算(
SubtleCrypto+ 纯 JS 实现的 ASN.1 解析) - 浏览器"查看页面源代码"即可审计完整逻辑
验证链路:
- 用户上传
.nep→ 浏览器本地解包 - 对
works/重算 SHA-256 → 按文件名字典序构建 Merkle Tree - 解析
proof.tsa(RFC 3161 TSR ASN.1 结构)→ 提取messageImprint.hashedMessage - 比对本地 Merkle Root vs TSR 内嵌 digest
- 解析 TSR 中的
genTime和tsa字段,展示签发机构与时间
信任边界:
- 验证器本身不签发任何时间戳,只读取
.nep内已存在的proof.tsa - TSA 签名的密码学验证(证书链 + 公钥链)在当前版本为结构性比对 + TSA 公钥指纹匹配,完整的 CA 链验证推荐使用
openssl ts -verify或rfc3161ng交叉确认 - 源码 MIT 许可,任何人可以自行搭建镜像或离线使用(保存 HTML 文件即可)
2.7 深度验证实现
result["timestamp_file"] = str(local_ts_path)
result["message"] += f",本地时间戳已保存: {local_ts_filename}(建议安装 rfc3161ng 以获取权威时间戳)"
except OSError as e:
result["message"] += f"(时间戳文件保存失败: {e})"
return result
def verify_timestamp_file(file_path: Path) -> Tuple[bool, Dict]:
"""
验证时间戳文件(便捷方法,仅结构验证)
支持 .tsr/.tsa (RFC 3161) 和 .json (本地时间戳) 格式。
Args:
file_path: .tsr/.tsa 或 .json 时间戳文件路径
Returns:
(是否有效, 验证结果字典)
"""
try:
result = parse_tsr(file_path)
return result.get("valid", False), result
except RightsError as e:
return False, {"valid": False, "message": str(e)}
except Exception as e:
return False, {"valid": False, "message": f"验证失败: {str(e)}"}
def verify_evidence_package(
tsa_path: Path,
file_paths: List[Path],
manifest_data: Optional[Dict] = None,
) -> Tuple[bool, Dict]:
"""
深度验证存证包:重算文件哈希 → 重建 Merkle Tree → 与 TSR 中签名的哈希比对。
验证链路:
v1: 文件列表 → Merkle Root → TSA
v2: 文件列表 → Merkle Root → 完整清单摘要 → TSA
Args:
tsa_path: .tsa/.tsr 时间戳文件路径
file_paths: 被存证的原始文件路径列表(顺序必须与存证时一致)
manifest_data: v2 存证必须提供完整清单;v1 可省略,仅验证文件
Returns:
(是否通过, 验证结果字典)
"""
from .utils import build_merkle_tree_from_files
if not tsa_path.exists():
return False, {"valid": False, "message": f"时间戳文件不存在: {tsa_path}"}
missing = [str(p) for p in file_paths if not p.exists()]
if missing:
return False, {"valid": False, "message": f"原始文件缺失: {', '.join(missing)}"}
try:
# Step 1: 重算文件哈希,重建 Merkle Tree
tree = build_merkle_tree_from_files(file_paths)
computed_root = tree.root_hash
expected_hash = computed_root
metadata_bound = False
if manifest_data is not None:
from .certification_manifest import certification_binding
binding = certification_binding(manifest_data)
if list(tree.file_hashes.values()) != list(manifest_data.get("file_hashes", {}).values()):
raise ValueError("原始文件与清单中的文件列表不匹配")
if computed_root != manifest_data.get("merkle_root"):
raise ValueError("文件 Merkle Root 与清单不匹配")
if binding == "manifest-sha256-v1":
expected_hash = manifest_data["manifest_sha256"]
metadata_bound = True
# Step 2: 检查是否为本地时间戳(无第三方签名,不具备证明力)
suffix = tsa_path.suffix.lower()
if suffix == ".json":
tsr_result = parse_tsr(tsa_path)
tsr_hash = tsr_result.get("hash", tsr_result.get("work_identity", ""))
if expected_hash.lower() == tsr_hash.lower():
return False, {
"valid": False,
"message": "本地时间戳无第三方签名,不具备密码学证明力。文件哈希一致但无法证明时间。",
"root_hash": computed_root,
"metadata_bound": False,
"local_only": True,
"file_count": len(file_paths),
}
else:
return False, {
"valid": False,
"message": "验证失败:存证摘要与本地时间戳记录不匹配",
"computed_root": computed_root,
"file_count": len(file_paths),
}
# Step 3: RFC 3161 TSR — 完整性比对 + TSA 签名与证书链验证 (tsa_verify)
computed_digest = bytes.fromhex(expected_hash)
try:
from .tsa_client import TSAClient
tsa_client = TSAClient()
verify_result = tsa_client.verify_tsr(tsa_path, digest=computed_digest)
if verify_result.get("valid"):
# 同时解析 TSR 获取时间戳详情(签发时间、签发方)3. 维权取证核心实现
3.1 浏览器截图
width: w,
height: h
});
}
return results;
}
""",
min_size,
)
# 去重 + CDN URL 升级(缩略图 → 大图)
seen = set()
unique = []
for img in images:
src = img["src"]关键事实:full_page=False。这是视口截图,不是滚动长截图。超长页面的下方内容不会被视觉 capture。
3.2 维权取证主流程
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
manifest_sha256 = hashlib.sha256(manifest_json.encode("utf-8")).hexdigest()
manifest["manifest_sha256"] = manifest_sha256
manifest["manifest_sha256_note"] = (
"To verify: remove 'manifest_sha256' and 'manifest_sha256_note' keys, "
"json.dumps(sort_keys=True, ensure_ascii=False, separators=(',', ':')), "
"SHA-256 the UTF-8 bytes."
)
# Write manifest to disk.
# NOTE: We intentionally do NOT write a separate manifest_canonical.json.
# The canonical form is reproducible by any verifier: remove "manifest_sha256"
# and "manifest_sha256_note" keys, then json.dumps(sort_keys=True,
# ensure_ascii=False) and SHA-256 the UTF-8 bytes. Writing it as a separate
# file would be redundant and was previously included in the .nep archive
# without being tracked in file_hashes (circular dependency).
manifest_path = self._output_dir / "manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False, default=str)
self._record(f"Manifest saved (sha256={manifest_sha256[:16]}...)")
return manifest
# ===== Step 8: Timestamp =====
def timestamp_manifest(self, manifest: Dict) -> Dict:
"""Request RFC 3161 timestamp for the manifest hash."""
manifest_sha256 = manifest.get("manifest_sha256", "")
if not manifest_sha256:
self._record("No manifest hash to timestamp")
return {"success": False, "message": "No manifest hash"}
self._record("Requesting RFC 3161 timestamp for manifest")
tsa_path = self._output_dir / "manifest.tsa"
try:
from .tsa_client import TSAClient
client = TSAClient(
provider=self._tsa_provider,
timeout=self._tsa_timeout,
)
result = client.timestamp_hash(manifest_sha256, tsa_path)
if result.get("success"):
self._record(f"TSA timestamp obtained: {result.get('issuer')}")
return {
"success": True,
"tsa_path": str(tsa_path),
"timestamp": result.get("timestamp"),
"issuer": result.get("issuer"),
}
else:
self._record(f"TSA failed: {result.get('message')}")
except Exception as e:
self._record(f"TSA error: {e}")
# Fallback: local timestamp
local_ts = {
"type": "local_timestamp",
"timestamp": datetime.now(timezone.utc).isoformat(),
"manifest_sha256": manifest_sha256,
"note": "TSA unavailable, local timestamp only",
}
local_path = self._output_dir / "manifest_timestamp.json"
with open(local_path, "w", encoding="utf-8") as f:
json.dump(local_ts, f, indent=2, ensure_ascii=False)
self._record("Falling back to local timestamp")
return {
"success": True,
"tsa_path": str(local_path),
"timestamp": local_ts["timestamp"],
"issuer": "Nephele Workshop (local)",
"local_only": True,
}
# ===== Step 9: Save operation log =====
def save_log(self) -> str:
"""Write the operation log to file."""
log_path = self._output_dir / "operation_log.txt"
with open(log_path, "w", encoding="utf-8") as f:
f.write("Nephele Workshop - URL Evidence Capture Log\n")
f.write(f"Evidence ID: {self._evidence_id}\n")
f.write("=" * 60 + "\n\n")
for entry in self._log:
f.write(entry + "\n")
return str(log_path)
# ===== Orchestrator =====
def capture(
self,
url: str,
progress_callback=None,
) -> Dict:
"""
Execute the full URL evidence capture pipeline.
Args:
url: Target URL to capture
progress_callback: Optional (step, total, message) callback
Returns:
{
"success": bool,
"evidence_id": str,
"output_dir": str,
"manifest": dict,
"timestamp_info": dict,
"message": str,
}
"""
total_phases = 5
self._record(f"Starting evidence capture for: {url}")
try:
# Phase 1: Environment + DNS + TLS certificate
if progress_callback:
progress_callback(1, total_phases, "Collecting environment info...")
environment = self.collect_environment()
dns_info = self.resolve_dns(url)
tls_info = self.capture_tls_certificate(url)
# Phase 2: Browser capture (navigate + screenshot + HTML + images)
if progress_callback:
progress_callback(2, total_phases, "Capturing page...")
artifacts = self.capture_page(url, progress_callback=None)
# Phase 3: Hash all artifacts (including TLS cert files)
if progress_callback:日志不可变性保证:
save_log()在 manifest 生成前调用- 写入后设置
self._log_committed = True - 此后
_record()只追加内存列表,不再写入磁盘 - manifest 中的
file_hashes包含日志文件的 SHA-256 - 因此 manifest 哈希锚定了"冻结"后的日志状态
3.3 TLS 证书抓取
return env
# ===== Step 1b: TLS certificate =====
def capture_tls_certificate(self, url: str) -> Dict:
"""
Capture the server's TLS certificate chain.
This proves the connection was made to the authentic server —
you can't forge a CA-signed certificate.
"""
import ssl
from urllib.parse import urlparse
self._record("Capturing TLS server certificate")
parsed = urlparse(url if "://" in url else f"https://{url}")
hostname = parsed.hostname or ""
port = parsed.port or 443
if not hostname:
return {"error": "Invalid hostname"}
try:
ctx = ssl.create_default_context()
with ctx.wrap_socket(
socket.socket(socket.AF_INET, socket.SOCK_STREAM),
server_hostname=hostname,
) as sock:
sock.settimeout(10)
sock.connect((hostname, port))
cert = sock.getpeercert()
cert_der = sock.getpeercert(binary_form=True)
# Save DER certificate to file
cert_path = self._output_dir / "server_certificate.der"
cert_path.write_bytes(cert_der)
# Also save human-readable PEM
import base64
pem_data = (
"-----BEGIN CERTIFICATE-----\n" + base64.encodebytes(cert_der).decode() + "-----END CERTIFICATE-----\n"
)
pem_path = self._output_dir / "server_certificate.pem"
pem_path.write_text(pem_data, encoding="utf-8")
# Extract key fields
subject = dict(x[0] for x in cert.get("subject", ()))
issuer = dict(x[0] for x in cert.get("issuer", ()))
cert_info = {
"subject_cn": subject.get("commonName", ""),
"issuer_cn": issuer.get("commonName", ""),
"issuer_org": issuer.get("organizationName", ""),
"not_before": cert.get("notBefore", ""),
"not_after": cert.get("notAfter", ""),
"serial_number": cert.get("serialNumber", ""),
"san": [entry[1] for entry in cert.get("subjectAltName", ()) if entry[0] == "DNS"],
"der_path": str(cert_path),
"pem_path": str(pem_path),
"der_sha256": hashlib.sha256(cert_der).hexdigest(),
}
self._record(
f"TLS cert captured: {cert_info['subject_cn']} "
f"(issuer: {cert_info['issuer_org']}, "
f"serial: {cert_info['serial_number'][:16]}...)"
)
return cert_info
except Exception as e:
self._record(f"TLS certificate capture failed: {e}")
return {"error": str(e)}
# ===== Step 2: DNS resolution =====
def resolve_dns(self, url: str) -> Dict:失败场景:自签名证书、ssl.SSLError、连接超时 → 返回 {"error": ...},非致命。
3.4 CAPTCHA 检测与处理
_CAPTCHA_KEYWORDS = (
"验证码",
"验证",
"captcha",
"verify",
"challenge",
"human verification",
"robot",
"机器人",
)
def _is_captcha_page(self, title: str, url: str) -> bool:
"""Detect if the loaded page is a CAPTCHA or anti-bot challenge."""
text = (title or "").lower()处理流程:
- headless 导航
- 检测标题关键词 → 判定 CAPTCHA
- 关闭 headless,打开 visible 浏览器
- 重新导航
time.sleep(2)轮询,最多 120 秒- 超时后 capture 当前状态
阻塞风险:轮询期间使用 time.sleep(2) 阻塞当前线程。
4. 盲水印技术审计
4.1 架构概述
盲水印模块分为三层:
| 层级 | 文件 | 职责 |
|---|---|---|
| 底层库 | blind_watermark (PyPI) | DWT+DCT+SVD 嵌入/提取核心算法 |
| 引擎层 | tools/packer/watermark_protection.py | 包装层:定长编码、round-trip 验证、Alpha 保留、异常回退 |
| 业务层 | tools/packer/logic.py / agent_api.py | 打包参数编排、可见水印与盲水印叠加 |
| Worker 层 | core/workers/watermark_worker.py | 后台线程提取,避免阻塞 UI |
全部运行在本地,零网络依赖。
4.2 底层库算法(blind_watermark)
Nephele 使用的底层库为 blind_watermark(github.com/guofei9987/blind_watermark),采用 DWT(离散小波变换)→ DCT(离散余弦变换)→ SVD(奇异值分解) 的三级混合域嵌入策略。
4.2.1 WaterMark 封装类
blind_watermark/blind_watermark.py
class WaterMark:
def __init__(self, password_wm=1, password_img=1, block_shape=(4, 4), mode='common', processes=None):
self.bwm_core = WaterMarkCore(password_img=password_img, mode=mode, processes=processes)
self.password_wm = password_wm
self.wm_bit = None
self.wm_size = 0
def read_img(self, filename=None, img=None):
if img is None:
img = cv2.imread(filename, flags=cv2.IMREAD_UNCHANGED)
self.bwm_core.read_img_arr(img=img)
return img
def read_wm(self, wm_content, mode='img'):
if mode == 'bit':
self.wm_bit = np.array(wm_content)
# ... img / str 模式省略 ...
self.wm_size = self.wm_bit.size
# 水印加密:用 password_wm 作为种子对 bit 序列做伪随机置乱
np.random.RandomState(self.password_wm).shuffle(self.wm_bit)
self.bwm_core.read_wm(self.wm_bit)
def embed(self, filename=None, compression_ratio=None):
embed_img = self.bwm_core.embed()
if filename is not None:
cv2.imwrite(filename=filename, img=embed_img)
return embed_img
def extract_decrypt(self, wm_avg):
# 逆置乱:根据相同的 seed 生成相同的 shuffle index,再逆序还原
wm_index = np.arange(self.wm_size)
np.random.RandomState(self.password_wm).shuffle(wm_index)
wm_avg[wm_index] = wm_avg.copy()
return wm_avg
def extract(self, filename=None, embed_img=None, wm_shape=None, mode='img'):
if filename is not None:
embed_img = cv2.imread(filename, flags=cv2.IMREAD_COLOR)
self.wm_size = np.array(wm_shape).prod()
if mode in ('str', 'bit'):
wm_avg = self.bwm_core.extract_with_kmeans(img=embed_img, wm_shape=wm_shape)
else:
wm_avg = self.bwm_core.extract(img=embed_img, wm_shape=wm_shape)
wm = self.extract_decrypt(wm_avg=wm_avg)
return wm关键事实:
password_wm用于 水印 bit 序列的伪随机置乱(np.random.RandomState.shuffle)password_img传递给WaterMarkCore,用于 图像块的选择置乱- 加密本质是"基于已知种子的确定性 shuffle",不是现代密码学加密
4.2.2 WaterMarkCore 核心引擎
blind_watermark/bwm_core.py
class WaterMarkCore:
def __init__(self, password_img=1, mode='common', processes=None):
self.block_shape = np.array([4, 4])
self.password_img = password_img
self.d1, self.d2 = 36, 20 # 量化步长:越大鲁棒性越强,但失真越大
self.pool = AutoPool(mode=mode, processes=processes)图像预处理(read_img_arr):
def read_img_arr(self, img):
# 处理透明图
self.alpha = None
if img.shape[2] == 4:
if img[:, :, 3].min() < 255:
self.alpha = img[:, :, 3]
img = img[:, :, :3]
# BGR -> YUV,补白边使像素变偶数(DWT 要求)
self.img = img.astype(np.float32)
self.img_shape = self.img.shape[:2]
self.img_YUV = cv2.copyMakeBorder(
cv2.cvtColor(self.img, cv2.COLOR_BGR2YUV),
0, self.img.shape[0] % 2, 0, self.img.shape[1] % 2,
cv2.BORDER_CONSTANT, value=(0, 0, 0)
)
# 对 Y/U/V 三个通道分别做 1 级 Haar DWT
self.ca_shape = [(i + 1) // 2 for i in self.img_shape]
self.ca_block_shape = (
self.ca_shape[0] // self.block_shape[0],
self.ca_shape[1] // self.block_shape[1],
self.block_shape[0], self.block_shape[1]
)
for channel in range(3):
self.ca[channel], self.hvd[channel] = dwt2(
self.img_YUV[:, :, channel], 'haar'
)
# 将 CA(近似系数)转为 4D 分块数组
self.ca_block[channel] = np.lib.stride_tricks.as_strided(
self.ca[channel].astype(np.float32),
self.ca_block_shape,
strides=4 * np.array([
self.ca_shape[1] * self.block_shape[0],
self.block_shape[1], self.ca_shape[1], 1
])
)审计点:
- 颜色空间:BGR → YUV,水印嵌入在 Y(亮度)通道的 DWT 近似子带
- DWT 级数:仅 1 级 Haar,不是多级分解
- 分块大小:固定
4×4,将 CA 子带切成不重叠的小块
4.2.3 块级嵌入流程(block_add_wm_slow)
def block_add_wm_slow(self, arg):
block, shuffler, i = arg
wm_1 = self.wm_bit[i % self.wm_size]
# Step 1: 对 4x4 块做 DCT
block_dct = dct(block)
# Step 2: flatten 后按 shuffler 打乱顺序(块内置乱)
block_dct_shuffled = block_dct.flatten()[shuffler].reshape(self.block_shape)
# Step 3: SVD 分解
u, s, v = svd(block_dct_shuffled)
# Step 4: 在奇异值上嵌入 1 bit 水印
# 量化公式:把 s[0] 量化到 d1 的整数倍,再根据 wm_1 偏移 1/4 个步长
s[0] = (s[0] // self.d1 + 1/4 + 1/2 * wm_1) * self.d1
if self.d2:
s[1] = (s[1] // self.d2 + 1/4 + 1/2 * wm_1) * self.d2
# Step 5: 逆 SVD
block_dct_flatten = np.dot(u, np.dot(np.diag(s), v)).flatten()
# Step 6: 逆置乱
block_dct_flatten[shuffler] = block_dct_flatten.copy()
# Step 7: 逆 DCT
return idct(block_dct_flatten.reshape(self.block_shape))数学原理:
嵌入公式(以 s[0] 为例):
s'[0] = (floor(s[0] / d1) + 1/4 + 1/2 * w) * d1
其中 w ∈ {0, 1} 为水印 bit。提取时:
w = 1 if (s[0] mod d1) > (d1 / 2) else 0
d1=36 意味着每个 bit 的量化间隔为 36,对系数的最大扰动约为 0.75 × d1 = 27。
4.2.4 块级提取流程(block_get_wm_slow)
def block_get_wm_slow(self, args):
block, shuffler = args
block_dct_shuffled = dct(block).flatten()[shuffler].reshape(self.block_shape)
u, s, v = svd(block_dct_shuffled)
# 从 s[0] 提取 bit
wm = (s[0] % self.d1 > self.d1 / 2) * 1
if self.d2:
# s[1] 作为辅助,加权平均
tmp = (s[1] % self.d2 > self.d2 / 2) * 1
wm = (wm * 3 + tmp * 1) / 4
return wm审计点:
d2(默认 20)为辅助量化步长,s[0]权重 3,s[1]权重 1- 当
d2=0时退化为单奇异值提取
4.2.5 全局嵌入流程(embed)
def embed(self):
self.init_block_index()
embed_ca = copy.deepcopy(self.ca)
# 生成块选择置乱序列(跨块置乱)
self.idx_shuffle = random_strategy1(
self.password_img, self.block_num,
self.block_shape[0] * self.block_shape[1]
)
for channel in range(3):
# 对每个块并行执行 block_add_wm
tmp = self.pool.map(self.block_add_wm, [
(self.ca_block[channel][self.block_index[i]], self.idx_shuffle[i], i)
for i in range(self.block_num)
])
# 写回 4D 数组
for i in range(self.block_num):
self.ca_block[channel][self.block_index[i]] = tmp[i]
# 4D -> 2D,拼接回 CA 子带
self.ca_part[channel] = np.concatenate(np.concatenate(self.ca_block[channel], 1), 1)
embed_ca[channel][:self.part_shape[0], :self.part_shape[1]] = self.ca_part[channel]
# 逆 DWT
embed_YUV[channel] = idwt2((embed_ca[channel], self.hvd[channel]), "haar")
# 合并三通道,YUV -> BGR,裁剪回原始尺寸
embed_img_YUV = np.stack(embed_YUV, axis=2)
embed_img_YUV = embed_img_YUV[:self.img_shape[0], :self.img_shape[1]]
embed_img = cv2.cvtColor(embed_img_YUV, cv2.COLOR_YUV2BGR)
embed_img = np.clip(embed_img, a_min=0, a_max=255)
if self.alpha is not None:
embed_img = cv2.merge([embed_img.astype(np.uint8), self.alpha])
return embed_img关键事实:
- 循环嵌入:水印 bit 序列在
block_num个块中循环重复嵌入(wm_bit[i % wm_size]) - 三通道独立:Y/U/V 三个通道各嵌入完整的一份水印,提取时做平均
- 块内置乱(
idx_shuffle[i]):每个 4×4 块内部的 16 个 DCT 系数顺序被打乱 - 跨块顺序(
block_index):块遍历顺序是固定的行列扫描,未置乱
4.2.6 K-Means 二值化(one_dim_kmeans)
def one_dim_kmeans(inputs):
threshold = 0
e_tol = 10 ** (-6)
center = [inputs.min(), inputs.max()]
for i in range(300):
threshold = (center[0] + center[1]) / 2
is_class01 = inputs > threshold
center = [inputs[~is_class01].mean(), inputs[is_class01].mean()]
if np.abs((center[0] + center[1]) / 2 - threshold) < e_tol:
threshold = (center[0] + center[1]) / 2
break
is_class01 = inputs > threshold
return is_class01用于 extract_with_kmeans 模式(Nephele 的 mode="bit" 不经过此路径,直接返回 wm_avg)。
4.2.7 随机置乱策略
def random_strategy1(seed, size, block_shape):
return np.random.RandomState(seed) \
.random(size=(size, block_shape)) \
.argsort(axis=1)生成 size × block_shape 的随机矩阵,按行 argsort 得到每行的置乱索引。对于 password_img,size = block_num,block_shape = 16。
4.3 引擎包装层源码
Nephele 在底层库之上增加了定长编码、round-trip 验证、Alpha 通道保留和异常回退。
4.3.1 常量与工具函数
"""Local invisible watermarks with authenticated payloads and legacy extraction."""
import hashlib
import hmac
import logging
import os
import secrets
import tempfile
from enum import Enum
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
from PIL import Image
from . import watermark_keys as keys
from .watermark_keys import WatermarkError
from .watermark_keys import export_watermark_keys as export_watermark_keys
from .watermark_keys import import_watermark_keys as import_watermark_keys
logger = logging.getLogger(__name__)
WATERMARK_BYTES = 32
WATERMARK_BITS = WATERMARK_BYTES * 8
_V2_MAGIC = b"NW\x02"
# Header (3), mark ID (12), text length (1), UTF-8 text (32), HMAC tag (16).
_V2_BYTES = 64
_V2_BITS = _V2_BYTES * 8
_LEGACY_PASSWORD_IMG = 2024
_LEGACY_PASSWORD_WM = 1314
_user_seeds_cache: Optional[Tuple[int, int]] = None
def _seed_file() -> Path:
return keys.seed_file()
def _get_user_seeds() -> Tuple[int, int]:
global _user_seeds_cache
key = keys.load_store(create=True)["keys"][0]
_user_seeds_cache = (key["password_img"], key["password_wm"])
return _user_seeds_cache
class ProtectionLevel(Enum):
NONE = "none"
INVISIBLE = "invisible"
LEVEL_ALIASES = {"maximum": "invisible"}
def _label_bytes(text: str) -> bytes:
if not text.strip() or any(not ch.isprintable() for ch in text):
raise WatermarkError("水印署名不能为空或包含控制字符")
raw = text.encode("utf-8")
if len(raw) > WATERMARK_BYTES:
raise WatermarkError("水印署名最多 32 个 UTF-8 字节(通常为 10 个汉字或 32 个英文字母)")
return raw
def _bytes_to_bits(raw: bytes) -> list[int]:
return np.unpackbits(np.frombuffer(raw, dtype=np.uint8)).tolist()
def _bits_to_bytes(bits: list) -> bytes:
values = np.asarray(bits).reshape(-1) > 0.5审计点:
- UTF-8 定长截断:超长文本静默截断至 32 字节
- 阈值判定:提取时
b > 0.5视为 1,对噪声有一定容忍
4.3.2 嵌入引擎
def _text_to_bits(text: str) -> list[int]:
return _bytes_to_bits(_label_bytes(text).ljust(WATERMARK_BYTES, b"\x00"))
def _bits_to_text(bits: list) -> str:
return _bits_to_bytes(bits).rstrip(b"\x00").decode("utf-8", errors="replace")
def _bits_to_valid_text(bits: list) -> Optional[str]:
"""Best-effort legacy text; this format has no authentication."""
prefix = _bits_to_bytes(bits).split(b"\x00", 1)[0]
if len(prefix) < 2:
return None
try:
text = prefix.decode("utf-8").strip()
except UnicodeDecodeError:
return None
return text if text and all(ch.isprintable() for ch in text) else None
def _make_payload(text: str, key: dict) -> bytes:
label = _label_bytes(text)
body = _V2_MAGIC + secrets.token_bytes(12) + bytes([len(label)]) + label.ljust(WATERMARK_BYTES, b"\x00")
tag = hmac.new(bytes.fromhex(key["auth_key"]), body, hashlib.sha256).digest()[:16]
return body + tag
def _read_payload(raw: bytes, key: dict) -> dict | None:
if len(raw) != _V2_BYTES or not raw.startswith(_V2_MAGIC) or not key.get("auth_key"):
return None
expected = hmac.new(bytes.fromhex(key["auth_key"]), raw[:48], hashlib.sha256).digest()[:16]
if not hmac.compare_digest(raw[48:], expected):
return None
size = raw[15]
if not 0 < size <= WATERMARK_BYTES or any(raw[16 + size : 48]):
return None
try:
label = raw[16 : 16 + size].decode("utf-8")
_label_bytes(label)
except (UnicodeDecodeError, WatermarkError):
return None
return {
"status": "verified",
"text": label,
"mark_id": raw[3:15].hex(),
"message": "水印认证校验通过;该结果不代表原创或作者身份认证",
}
def _bgr(image: Image.Image) -> np.ndarray:
# OpenCV's filename APIs cannot read Windows CJK paths.
return np.asarray(image.convert("RGB"))[:, :, ::-1].copy()审计点:
- 单例模式:
WatermarkEngine为单例,但blind_watermark.WaterMark每次新建实例 - 文件级 I/O:通过
TemporaryDirectory+ PNG 临时文件工作,规避 numpy array 模式的 dtype/shape 兼容性 bug - 验证失败仅打 warning,仍返回含水印图片
- Alpha 通道:RGBA 输入先转 RGB 嵌入,完成后恢复 alpha
- 异常回退:任何异常返回原始
image,调用方无感知失败
4.3.3 提取引擎
def _extract_bits(image: Image.Image, key: dict, bit_count: int) -> list:
from blind_watermark import WaterMark
engine = WaterMark(password_img=key["password_img"], password_wm=key["password_wm"])
return engine.extract(embed_img=_bgr(image), wm_shape=bit_count, mode="bit")
def _capacity(image: Image.Image) -> int:
return ((image.width + 1) // 8) * ((image.height + 1) // 8)
class WatermarkEngine:
def embed(self, image: Image.Image, text: str) -> Image.Image:
"""Authenticate the quantized pixels; never return an unmarked fallback."""
_label_bytes(text)
if _capacity(image) <= _V2_BITS:
raise WatermarkError("图片尺寸不足以嵌入隐形水印,请使用更大的图片")
try:
from blind_watermark import WaterMark
key = keys.load_store(create=True)["keys"][0]审计点:
- 提取失败返回
None,无法区分"图片无水印"与"提取过程出错" - 空字符串(全零填充)经
strip()后同样返回None
4.3.4 公共 API
payload = _make_payload(text, key)
engine = WaterMark(password_img=key["password_img"], password_wm=key["password_wm"])
engine.read_img(img=_bgr(image))
engine.read_wm(np.asarray(_bytes_to_bits(payload)), mode="bit")
pixels = np.rint(engine.embed()).clip(0, 255).astype(np.uint8)
result = Image.fromarray(pixels[:, :, ::-1].copy())
if image.mode == "RGBA":
result = result.convert("RGBA")
result.putalpha(image.getchannel("A"))
recovered = _bits_to_bytes(_extract_bits(result, key, _V2_BITS))
if not hmac.compare_digest(recovered, payload):
raise WatermarkError("隐形水印回读校验失败,图片未导出")
result.info["nephele_watermark"] = {"payload": payload, "key": key}
return result
except WatermarkError:
raise
except Exception as exc:
logger.exception("Invisible watermark embedding failed")
raise WatermarkError("隐形水印嵌入失败,图片未导出") from exc
def extract(self, image: Image.Image) -> Optional[str]:
result = inspect_watermark(image)4.4 业务层调用
def pack_image(
input_path: str,
watermark_path: Optional[str] = None,
output_dir: Optional[str] = None,
watermark_mode: str = "center",
watermark_opacity: float = 0.3,
preview_max_size: int = 1920,
thumbnail_max_size: int = 500,
protection_level: str = "none",
copyright_info: str = "© ArisFusion Studio",
output_folder_name: str = "Delivery_Pack",
) -> dict:
"""Burn a visible watermark + creator declaration onto the image (single output).
The old multi-size delivery export (HD/preview/thumbnail) was removed; the
preview_max_size / thumbnail_max_size args are legacy no-ops kept for signature
compatibility — process_image now writes one file.
"""
try:
src = Path(input_path)
if not src.exists():
return api_err(f"文件不存在: {input_path}")
mode_map = {"center": WatermarkMode.CENTER, "tile": WatermarkMode.TILE}
wm_mode = mode_map.get(watermark_mode, WatermarkMode.CENTER)
wm_path = Path(watermark_path) if watermark_path else None
if wm_path and not wm_path.exists():
return api_err(f"水印文件不存在: {watermark_path}")
out_dir = Path(output_dir) if output_dir else None
packer = DeliveryPacker(
preview_max_size=preview_max_size,
thumbnail_max_size=thumbnail_max_size,
watermark_opacity=watermark_opacity,
protection_level=protection_level,
copyright_info=copyright_info,
output_folder_name=output_folder_name,
)
result_dir, results = packer.process_image(
input_path=src,
watermark_path=wm_path,
output_dir=out_dir,
watermark_mode=wm_mode,
)
file_info = {k: str(v) for k, v in results.items()}
return api_ok(
f"打包完成,共生成 {len(results)} 个文件",
output_path=str(result_dir),
data={"files": file_info},
)
except PackerError as e:
logger.error("打包失败: %s", e)
return api_err(str(e))关键事实:
protection_level默认"none",即默认不启用盲水印copyright_info最长 32 字节(超长静默截断)
4.5 Worker 层实现
"""Background watermark inspection and encrypted key backup."""
import json
import logging
from pathlib import Path
from PySide6.QtCore import QT_TRANSLATE_NOOP, QCoreApplication, QThread, QUrl, Signal
from .._utils import ensure_src_path
logger = logging.getLogger(__name__)
# Keep dynamically translated headless errors discoverable by lupdate.
_WATERMARK_ERROR_MESSAGES = (
QT_TRANSLATE_NOOP("WatermarkError", "水印署名不能为空或包含控制字符"),
QT_TRANSLATE_NOOP("WatermarkError", "水印署名最多 32 个 UTF-8 字节(通常为 10 个汉字或 32 个英文字母)"),
QT_TRANSLATE_NOOP("WatermarkError", "图片尺寸不足以嵌入隐形水印,请使用更大的图片"),
QT_TRANSLATE_NOOP("WatermarkError", "隐形水印回读校验失败,图片未导出"),
QT_TRANSLATE_NOOP("WatermarkError", "隐形水印嵌入失败,图片未导出"),
QT_TRANSLATE_NOOP("WatermarkError", "水印提取器运行失败,无法判断图片是否含有水印"),
QT_TRANSLATE_NOOP("WatermarkError", "不支持的水印保护级别"),
QT_TRANSLATE_NOOP("WatermarkError", "导出文件的水印校验失败,原有文件已保留"),
QT_TRANSLATE_NOOP("WatermarkError", "图片保存失败,请检查输出位置;原有文件已保留"),
QT_TRANSLATE_NOOP("WatermarkError", "水印密钥无法读取或保存,请从备份恢复;原密钥文件已保留"),
QT_TRANSLATE_NOOP("WatermarkError", "水印密钥已变化,请重新导出图片"),
QT_TRANSLATE_NOOP("WatermarkError", "无法保存水印记录,图片尚未导出"),
QT_TRANSLATE_NOOP("WatermarkError", "请输入密钥备份密码"),
QT_TRANSLATE_NOOP("WatermarkError", "备份文件不能覆盖正在使用的水印密钥"),
QT_TRANSLATE_NOOP("WatermarkError", "备份密码至少需要 8 个字符"),
QT_TRANSLATE_NOOP("WatermarkError", "水印密钥备份失败,请检查保存位置"),
QT_TRANSLATE_NOOP("WatermarkError", "备份无法解密:请检查密码和备份文件"),
QT_TRANSLATE_NOOP("WatermarkError", "无法保留原密钥文件,恢复已停止"),
QT_TRANSLATE_NOOP("WatermarkError", "无法保存恢复的水印密钥"),
)
def watermark_error_text(error: Exception) -> str:
"""Localize known watermark failures at the Qt boundary."""
from tools.packer.watermark_keys import WatermarkError
current = error
while current is not None:
if isinstance(current, WatermarkError):
return QCoreApplication.translate("WatermarkError", str(current))
current = current.__cause__ or current.__context__
return str(error)4.6 容量与编码
| 编码 | 每字符字节 | 最大字符数 |
|---|---|---|
| ASCII | 1 | 32 |
| CJK (UTF-8) | 3 | 10 |
| 混合 | — | 视具体字符而定 |
5. AI 凭据检测白盒源码审计
本节审计 Nephele Workshop 的 AI 元数据 / C2PA 凭据检测 功能。该功能用于读取图片文件中已有的机器可读证据,包括 C2PA 内容凭证、生成工具元数据、平台声明和导出痕迹。它不使用视觉风格分类模型,不把"未检出"解释为"非 AI"。
5.1 架构概述
AI 元数据检测分为四层:
| 层级 | 文件 | 职责 |
|---|---|---|
| 规则层 | tools/validator/logic.py:MetaDataDetector | 元数据读取、规则匹配、证据分级、最终状态输出 |
| C2PA 层 | tools/validator/c2pa_verifier.py | 官方 C2PA SDK 适配、manifest 读取、签名链与信任状态解析 |
| Worker 层 | core/workers/ai_detector_worker.py | 批量检测线程、错误隔离、结果信号 |
| UI 层 | gui/qml/views/AIValidatorView.qml | 将 raw evidence 映射成用户友好的证据标签 |
数据流:
用户选择图片
-> core/workers/ai_detector_worker.py
-> tools/validator/logic.py:MetaDataDetector.detect()
├── Pillow 读取 PNG/JPEG/WebP/TIFF 元数据(PNG info / EXIF)
├── tools/validator/c2pa_verifier.py:verify_c2pa_file()(官方 SDK)
└── 原始字节扫描(JUMBF / APP11 fallback)
-> 返回 {status, reason, tool, evidence}
-> UI 映射证据标签检测结果结构:
{
"status": "ai" | "unknown" | "human" | "error",
"reason": str,
"tool": str,
"evidence": str,
}注意
status="human" 是历史字段名。UI 不应把它展示为"人类作品实锤",而应展示为"未发现凭据"或"凭据不足"。
5.2 MetaDataDetector 规则层
tools/validator/logic.py:MetaDataDetector 是规则匹配的主类,本身不持有状态,__init__ 为空。
5.2.1 规则常量
pass
class MetaDataDetector:
"""
Detects AI generation metadata from image files using heuristic analysis
of EXIF, PNG info chunks, and generation parameters.
"""
# 1. 明确的软件签名 (强特征,优先匹配专有字符串)
# 顺序:Midjourney 先于 Gemini,以避免误判
AI_SOFTWARE_SIGNATURES = {
# NOTE: removed the loose "mj_" / "mj v" substrings — they collide with
# arbitrary binary EXIF/ICC bytes (observed: human 设定集 .jpg files
# false-flagged as Midjourney on a stray "mj_" in the EXIF block). The
# remaining markers are Midjourney-specific prompt/Job-ID syntax.
"Midjourney": [
re.compile(r"job id:\s*[a-f0-9\-]{8,}", re.IGNORECASE), # 专有 Job ID (UUID-ish)
re.compile(r"--ar\s+\d+:\d+", re.IGNORECASE), # --ar 参数
re.compile(r"--niji\s*\d*", re.IGNORECASE), # --niji 模型
re.compile(r"--stylize\s+\d+", re.IGNORECASE), # --stylize
re.compile(r"midjourney", re.IGNORECASE), # 显式名称
],
"ComfyUI": [
re.compile(r"comfyui", re.IGNORECASE),
re.compile(r"comfyland", re.IGNORECASE),
# workflow/prompt JSON 在 detect 中单独处理
],
"Gemini (Google)": [
re.compile(r"gemini", re.IGNORECASE),
re.compile(r"google deepmind", re.IGNORECASE),
re.compile(r"generated by google", re.IGNORECASE),
re.compile(r"google imagen", re.IGNORECASE), # 必须有 "google"
re.compile(r"imagen by google", re.IGNORECASE),
re.compile(r"synthid", re.IGNORECASE), # Google SynthID 水印
re.compile(r"nano banana", re.IGNORECASE), # 你的工具标识
re.compile(r"nanobanana", re.IGNORECASE),
],
"DALL-E": [re.compile(r"dall-e", re.IGNORECASE), re.compile(r"dalle", re.IGNORECASE)],
"NovelAI": [re.compile(r"novelai", re.IGNORECASE), re.compile(r"nai-diffusion", re.IGNORECASE)],
"InvokeAI": [re.compile(r"invokeai", re.IGNORECASE), re.compile(r"invoke ai", re.IGNORECASE)],
"Fooocus": [re.compile(r"fooocus", re.IGNORECASE)],
"Stable Diffusion": [
re.compile(r"stable diffusion", re.IGNORECASE),
re.compile(r"sd\.?next", re.IGNORECASE),
# Anchor "forge" — the bare substring collides with arbitrary text
# (artwork titles, file paths, the word "forge"). The real WebUI
# Forge tag is "Stable Diffusion WebUI Forge", already caught by the
# "stable diffusion" marker above; only the sd-/webui-forge variants
# need this.
re.compile(r"webui[\s\-]?forge|sd[\s\-]?forge", re.IGNORECASE),
],
"Leonardo.ai": [re.compile(r"leonardo.ai", re.IGNORECASE)],
"Adobe Firefly": [re.compile(r"adobe firefly", re.IGNORECASE)],
"Bing Image Creator": [re.compile(r"bing image creator", re.IGNORECASE)],
}
# 2. 生成参数指纹 (次优先,当没有软件名时)
# 移除 Gemini 专有,将 trainedAlgorithmicMedia 作为通用 AI 标记
# SD-exclusive vocabulary that carries effectively zero false-positive risk
# on its own — exempt from the >=2 co-occurrence gate below (a platform
# repost may metadata-strip everything but a single one of these fields).
# Keep this list strict: only tokens no human-art pipeline ever emits.
STANDALONE_STRONG_FINGERPRINTS = [
re.compile(r"Negative prompt:", re.IGNORECASE),
re.compile(r"Model hash:\s*[a-f0-9]{6,}", re.IGNORECASE),
]
GENERATION_PARAM_FINGERPRINTS = [
(r"Steps:\s*\d+", "Stable Diffusion (Parameters)"),
(r"CFG scale:\s*[\d\.]+", "Stable Diffusion (Parameters)"),
(r"Sampler:\s*\w+", "Stable Diffusion (Parameters)"),
(r"Seed:\s*\d+", "Stable Diffusion (Parameters)"),
(r"Model hash:\s*[a-f0-9]+", "Stable Diffusion (Parameters)"),
(r"Model:\s*[^,\n]+", "Stable Diffusion (Parameters)"),
(r"Size:\s*\d+x\d+", "Stable Diffusion (Parameters)"),
(r"Clip skip:\s*\d+", "Stable Diffusion (Parameters)"),
(r"Schedule type:\s*[^,\n]+", "Stable Diffusion (Parameters)"),
(r"Denoising strength:\s*[\d\.]+", "Stable Diffusion (Parameters)"),
(r"Hires upscale:\s*[\d\.]+", "Stable Diffusion (Parameters)"),
# 更新 regex 以匹配 IPTC/XMP 变体(包括 URL)
(
r"DigitalSourceType\s*[:=]\s*(?:http://cv\.iptc\.org/newscodes/digitalsourcetype/)?trainedAlgorithmicMedia",
"Generative AI (IPTC/XMP Standard)",
),审计点:
- 规则层级:软件签名(强)→ 参数指纹(次)→ C2PA 容器 + AI marker(强)→ 弱特征(文件名)
- 顺序敏感:
AI_SOFTWARE_SIGNATURES是dict,Python 3.7+ 保留插入顺序。Midjourney排在Gemini (Google)前,用于阻止 Midjourney 图内引用 Google 工具时被错判为 Gemini。 - SynthID 歧义:
synthid字符串被视为 Gemini 强证据,但这只是字符串匹配,本模块不解码 SynthID 像素水印。 - IPTC 通用标记:
trainedAlgorithmicMedia不是 Google 专有;只有在同一文本中同时出现 Google 证据时才归因 Gemini,否则归为Generative AI (Unknown)。 - 原始扫描上限:128 MB,超过即跳过字节级 fallback(只依赖 Pillow 与 C2PA SDK)。
5.2.2 detect() 主流程
if not include_weak_markers and pattern.pattern in weak_patterns:
continue
if pattern.search(text_lower):
return tool, pattern.pattern
# 2a. Standalone-strong fingerprints: SD-exclusive tokens that flag on a
# single hit (a stripped repost may keep only one). Not gated by >=2.
for pattern in self.STANDALONE_STRONG_FINGERPRINTS:
if pattern.search(text):
return "Stable Diffusion WebUI", pattern.pattern
# 2b. Weak generation-param fingerprints (require co-occurrence, see below)
match_count = 0
evidence = []
detected_tool = None
for pattern, tool_name in self.GENERATION_PARAM_FINGERPRINTS:
match = re.search(pattern, text, re.IGNORECASE)
if match:
if "IPTC/XMP Standard" in tool_name:
# 如果是通用 IPTC,检查是否有 Google 证据
if any(re.search(p, text_lower) for p in self.AI_SOFTWARE_SIGNATURES["Gemini (Google)"]):
return "Gemini (Google)", "IPTC/XMP with Google Evidence"
else:
detected_tool = "Generative AI (Unknown)"
evidence.append("IPTC/XMP Signature")
else:
match_count += 1
evidence.append(pattern)
# Require co-occurrence of >=2 distinct generation params before flagging.
# Single weak fingerprints (`Model:`, `Size:`, `Seed:`...) collide with
# ordinary photo/3D/stock metadata (camera Model field, a stray "Size:
# 1920x1080" in XMP) and false-flag human art. A genuine A1111/SD
# parameter block always carries many params in one text blob, so the
# >=2 gate keeps every real hit while killing single-field collisions.
# (Mirrors ai_provenance's "Steps: AND structure" co-occurrence rule.)
if match_count >= 2:
return "Stable Diffusion WebUI", "Generation Parameters Detected"
if detected_tool:
return detected_tool, "; ".join(evidence)
return None
def detect(self, image_path: str) -> Dict[str, str]:
"""
检测图像文件中的 AI 生成元数据
Args:
image_path: 图像文件路径
Returns:
检测结果字典,包含:
- status: "ai" | "human" | "unknown" | "error"
("human" only on a positive C2PA human-source declaration;
pure metadata absence is "unknown", not "human")
- reason: 检测原因描述
- tool: 检测到的 AI 工具名称(如果为 AI)
- evidence: 证据描述
"""
img_path = Path(image_path)
if not img_path.exists():
return {"status": "error", "reason": "文件不存在", "tool": "", "evidence": "", "c2pa": None}
evidence_found = []
weak_evidence_found = []
context_evidence_found = []
detected_tool = None
extension_mismatch = False
minimal_web_jpeg = False
try:
with Image.open(img_path) as img:
img.load() # Ensure header is loaded
actual_format = (img.format or "").upper()
suffix = img_path.suffix.lower()
if actual_format == "JPEG" and suffix not in {".jpg", ".jpeg", ".jpe"}:
extension_mismatch = True
context_evidence_found.append(f"File extension mismatch: {suffix} file contains JPEG data")
elif actual_format == "PNG" and suffix != ".png":
extension_mismatch = True
context_evidence_found.append(f"File extension mismatch: {suffix} file contains PNG data")
minimal_jpeg_keys = {"jfif", "jfif_density", "jfif_unit", "jfif_version", "progression", "progressive"}
if actual_format == "JPEG" and set(img.info.keys()).issubset(minimal_jpeg_keys):
minimal_web_jpeg = True
context_evidence_found.append("Minimal JPEG metadata only")
# --- 1. Check PNG Info / tEXt chunks ---
if hasattr(img, "info") and img.info:
software_value = img.info.get("Software") or img.info.get("software")
if isinstance(software_value, bytes):
software_text = software_value.decode("utf-8", errors="ignore")
else:
software_text = str(software_value or "")
software_lower = software_text.lower()
if "celsys" in software_lower or "clip studio" in software_lower:
context_evidence_found.append("Edited/exported by CELSYS/Clip Studio Paint")
if not detected_tool:
for key, val in img.info.items():
if not isinstance(val, (str, bytes)):
continue
structured_res = self._detect_structured_generator_metadata(key, val)
if structured_res:
detected_tool, marker = structured_res
evidence_found.append(marker)
break
# Case A: A1111 / SD
if "parameters" in img.info:
val = img.info["parameters"]
if isinstance(val, str) and ("Steps:" in val or "Prompt" in val):
detected_tool = "Stable Diffusion (A1111)"
evidence_found.append("Stable Diffusion parameters chunk")
res = self._analyze_text(val)
if res:
detected_tool, marker = res
evidence_found.append(f"Parameters: {marker}")
# Case B: ComfyUI (专有检查)
if not detected_tool and ("workflow" in img.info or "prompt" in img.info):
# 验证是否是 JSON
try:
if "workflow" in img.info:
json.loads(img.info["workflow"])
evidence_found.append("Valid 'workflow' JSON")
if "prompt" in img.info:
json.loads(img.info["prompt"])
evidence_found.append("Valid 'prompt' JSON")
detected_tool = "ComfyUI"
except json.JSONDecodeError:
pass # 非 JSON,忽略
# Case B2: China national AIGC label (GB 45438-2025) — the
# dedicated "AIGC" chunk domestic services embed (Doubao /
# Jimeng / 通义 ...). Strong, dedicated-field signal.
if not detected_tool and "AIGC" in img.info:
from tools.eagle_tagger.ai_provenance import parse_aigc_label
aigc = parse_aigc_label(img.info.get("AIGC"))
if aigc:
detected_tool = aigc["tool"]
evidence_found.append(aigc["evidence"])
# Case C: Generic Scan (其他 info)
if not detected_tool:
for key, val in img.info.items():
if isinstance(val, (str, bytes)):
val_str = self._metadata_value_to_text(val)
res = self._analyze_text(val_str)
if res:
detected_tool, marker = res
evidence_found.append(f"PNG Info '{key}': {marker}")
break
for key, val in img.info.items():
if not isinstance(val, (str, bytes)):
continue
val_str = val.decode("utf-8", errors="ignore") if isinstance(val, bytes) else str(val)
if "DigitalSourceType" in val_str and "trainedAlgorithmicMedia" in val_str:
marker = f"PNG Info '{key}': IPTC/XMP trainedAlgorithmicMedia"
if marker not in evidence_found:
evidence_found.append(marker)
# --- 2. Check EXIF / XMP Data ---
if not detected_tool:
exif = img.getexif()
if exif:
for tag_id, value in exif.items():
tag_name = ExifTags.TAGS.get(tag_id, str(tag_id))
# Handle UserComment or other bytes
if isinstance(value, bytes):
try:
value_str = value.decode("utf-8", errors="ignore")
except Exception:
logger.debug("EXIF tag %s: undecodable bytes value, skipping", tag_name)
continue
else:
value_str = str(value)
res = self._analyze_text(value_str)
if res:
detected_tool, marker = res
evidence_found.append(f"EXIF {tag_name}: {marker}")
break
except Exception as e:
return {"status": "error", "reason": f"读取错误: {str(e)}", "tool": "", "evidence": "", "c2pa": None}
# --- 3. Official C2PA manifest/signature verification ---
c2pa_available = False
c2pa_has_manifest = False
c2pa_claim_generator = ""
c2pa_declares_human = False
c2pa_detail: Optional[dict] = None # structured manifest fields for the UI
try:
from tools.validator.c2pa_verifier import build_c2pa_detail, verify_c2pa_file
c2pa_result = verify_c2pa_file(img_path)
c2pa_available = c2pa_result.available
if c2pa_result.has_manifest:
c2pa_has_manifest = True
c2pa_claim_generator = c2pa_result.claim_generator or ""
c2pa_detail = build_c2pa_detail(c2pa_result)
evidence_found.append(c2pa_result.evidence_summary())
if c2pa_result.ai_generated:
detected_tool = "Generative AI (C2PA Content Credentials)"
elif c2pa_result.declares_human_origin:
c2pa_declares_human = True
elif c2pa_result.available and c2pa_result.error:
evidence_found.append(f"C2PA verification error: {c2pa_result.error}")
elif c2pa_result.available:审计点(执行顺序):
- 打开 Pillow 句柄,识别 format / suffix 不一致、最小 JPEG 元数据、CELSYS 导出等上下文证据(不升级状态)
- PNG info:
parameters→ A1111;workflow/promptJSON → ComfyUI;其他 key 用_detect_structured_generator_metadata()识别 NovelAI / InvokeAI / Fooocus;最后通用_analyze_text()跑一遍 - EXIF:对每个标签做
_analyze_text() - 官方 C2PA SDK(
verify_c2pa_file):manifest 存在即抓claim_generator与 AI marker - 原始字节 fallback:仅在前两步都未命中时执行,读取整个文件头(≤ 128 MB),搜
c2pa / jumbf / contentauth容器 + AI marker 字符串 - Google 可见水印:仅在"有 Google claim_generator"或"文件名含 gemini / google"时启用,避免对所有图片做视觉扫描
- 文件名弱线索:不会触发
ai,最多产生unknown
状态收敛规则:
| 证据组合 | status |
|---|---|
detected_tool 被赋值(任一强证据命中) | ai |
无强证据,但有 weak_evidence_found(文件名提示) | unknown |
| 无任何证据 + C2PA manifest 存在但未声明 AI | human(reason: "C2PA Content Credentials do not declare AI generation") |
| 无任何证据 + 无 manifest | human(reason: "No known AI generation metadata detected") |
| Pillow 抛异常 | error |
注意
"无元数据"与"有 C2PA manifest 但声明非 AI"在 status 上都归为 human,只能通过 reason 区分。UI 必须读取 reason,否则会把"元数据被清洗"的图片误展示为"确认非 AI"。
5.2.3 元数据辅助解析
C2PA_CONTAINER_MARKERS = [
b"c2pa",
b"jumbf",
b"content credentials",
b"contentcredentials",
b"contentauth",
]
C2PA_AI_MARKERS = [
b"trainedalgorithmicmedia",
b"compositewithtrainedalgorithmicmedia",
b"algorithmicmedia",
b"generated by ai",
b"ai generated",
b"adobe firefly",
b"google imagen",
b"synthid",
b"dall-e",
b"dalle",
b"midjourney",
b"stable diffusion",
]
def __init__(self):
pass
def _decode_metadata_bytes(self, data: bytes) -> str:
"""Best-effort decoding for embedded XMP/C2PA text inside binary assets."""
if not data:
return ""
text = data.decode("utf-8", errors="ignore")
if len(text.strip()) < 8:
text = data.decode("latin-1", errors="ignore")
return text
def _metadata_value_to_text(self, value: Any) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore")
return str(value)
def _json_loads(self, value: str) -> Any:
try:
return json.loads(value)
except Exception:
return None
def _json_has_keys(self, value: Any, keys: set[str]) -> bool:
if isinstance(value, dict):
lowered = {str(key).lower() for key in value.keys()}
if lowered.intersection(keys):
return True
return any(self._json_has_keys(item, keys) for item in value.values())
if isinstance(value, list):
return any(self._json_has_keys(item, keys) for item in value)
return False
def _detect_structured_generator_metadata(self, key: str, value: Any) -> Optional[Tuple[str, str]]:
text = self._metadata_value_to_text(value)
lowered_key = key.lower()
lowered_text = text.lower()
parsed = self._json_loads(text)
审计点:
- NovelAI 判据:JSON 中同时含
uc与(sampler/steps/scale)之一 → NovelAI generation JSON。这个形状判据允许 NovelAI 把显式名称清洗后仍被识别。 - InvokeAI / SD JSON:必须"key 名 + 生成字段"同时命中,防止任意 JSON 被当成生成元数据。
5.2.4 原始字节扫描(C2PA/JUMBF fallback)
if "invoke" in lowered_key or "invokeai" in lowered_text or "invoke ai" in lowered_text:
return "InvokeAI", f"PNG Info '{key}': InvokeAI metadata"
if "fooocus" in lowered_key or "fooocus" in lowered_text:
return "Fooocus", f"PNG Info '{key}': Fooocus metadata"
if "novelai" in lowered_text or "nai-diffusion" in lowered_text:
return "NovelAI", f"PNG Info '{key}': NovelAI metadata"
if isinstance(parsed, dict):
has_generation_keys = self._json_has_keys(
parsed,
{"sampler", "sampler_name", "steps", "scale", "cfg_scale", "seed", "model", "model_hash", "uc"},
)
has_novelai_shape = self._json_has_keys(parsed, {"uc"}) and self._json_has_keys(
parsed, {"sampler", "steps", "scale"}
)
if has_novelai_shape:
return "NovelAI", f"PNG Info '{key}': NovelAI generation JSON"
if "invoke" in lowered_key and has_generation_keys:
return "InvokeAI", f"PNG Info '{key}': InvokeAI generation JSON"
if (
lowered_key in {"sd-metadata", "sd_metadata", "generation_data", "generation_data_formatted"}
and has_generation_keys
):
return "Stable Diffusion", f"PNG Info '{key}': Stable Diffusion generation JSON"
return None
def _analyze_c2pa_bytes(self, data: bytes) -> Optional[Tuple[str, str]]:
"""Detect AI signals embedded in C2PA/JUMBF Content Credentials payloads."""
if not data:
return None
lowered = data.lower()
has_c2pa_container = any(marker in lowered for marker in self.C2PA_CONTAINER_MARKERS)
if not has_c2pa_container:
return None
for marker in self.C2PA_AI_MARKERS:
if marker in lowered:
return "Generative AI (C2PA Content Credentials)", marker.decode("ascii", errors="ignore")
text = self._decode_metadata_bytes(data)
res = self._analyze_text(text, include_weak_markers=False)
if res:
tool, marker = res
return tool, f"C2PA payload: {marker}"
return None
# PNG ancillary chunks that may carry metadata text. Skip IDAT/PLTE/etc.
PNG_METADATA_CHUNKS = {b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP", b"caBX", b"jumb"}
# WebP RIFF chunks that may carry metadata.
WEBP_METADATA_CHUNKS = {b"EXIF", b"XMP ", b"ICCP", b"JUMB"}
def _extract_png_metadata(self, data: bytes) -> bytes:
out = bytearray()
pos = 8 # skip signature
n = len(data)
iend_end = n
while pos + 12 <= n:
try:
length = int.from_bytes(data[pos : pos + 4], "big")
chunk_type = data[pos + 4 : pos + 8]
except Exception:
break
data_end = pos + 8 + length
if length < 0 or data_end + 4 > n:
break
if chunk_type in self.PNG_METADATA_CHUNKS:
out.extend(data[pos + 8 : data_end])
out.append(0)
if chunk_type == b"IEND":
iend_end = data_end + 4
break
pos = data_end + 4 # skip CRC
# Some pipelines append C2PA/JUMBF payloads after IEND. Include any
# trailing bytes verbatim — they cannot be pixel data.
if iend_end < n:
out.extend(data[iend_end:])
return bytes(out)
def _extract_jpeg_metadata(self, data: bytes) -> bytes:
out = bytearray()
pos = 2 # skip SOI (FFD8)
n = len(data)
while pos + 4 <= n:
if data[pos] != 0xFF:
break
# Skip fill bytes
while pos < n and data[pos] == 0xFF:
pos += 1
if pos >= n:
break
marker = data[pos]
pos += 1
# Standalone markers without length payload
if marker == 0xD9: # EOI
break
if marker == 0xDA: # SOS — compressed image data starts here
break
if marker == 0x00 or marker == 0x01 or 0xD0 <= marker <= 0xD8:
continue
if pos + 2 > n:
break
seg_len = int.from_bytes(data[pos : pos + 2], "big")
if seg_len < 2 or pos + seg_len > n:
break
seg_data = data[pos + 2 : pos + seg_len]
# APP0..APP15 (E0..EF) and COM (FE) carry text-style metadata
if 0xE0 <= marker <= 0xEF or marker == 0xFE:
out.extend(seg_data)
out.append(0)
pos += seg_len
return bytes(out)
def _extract_webp_metadata(self, data: bytes) -> bytes:
out = bytearray()
n = len(data)
if n < 12:
return b""
pos = 12 # skip RIFF + size + WEBP
while pos + 8 <= n:
fourcc = data[pos : pos + 4]
size = int.from_bytes(data[pos + 4 : pos + 8], "little")
if size < 0 or pos + 8 + size > n:
break
if fourcc in self.WEBP_METADATA_CHUNKS:
out.extend(data[pos + 8 : pos + 8 + size])
out.append(0)
pos += 8 + size + (size & 1) # chunks are padded to even size
return bytes(out)
def _extract_metadata_blocks(self, data: bytes) -> bytes:
"""
Return bytes from text/metadata-bearing containers only, skipping
pixel/compressed payloads. This prevents random regex hits in raw
IDAT / SOS data from being mistaken for AI markers.
"""
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return self._extract_png_metadata(data)
if data[:2] == b"\xff\xd8":
return self._extract_jpeg_metadata(data)审计点:
- 仅在"官方 C2PA SDK 读不到 manifest"且"PNG info/EXIF 未命中"时执行,避免对所有图片做整文件扫描
- 扫描整个文件字节,性能开销取决于文件大小;> 128 MB 的文件直接跳过
- 扫描到 container 但无 AI marker →
has_c2pa_container=True,detect()把它作为上下文信息附在 evidence 里,不升级状态 - 该路径对恶意伪造 C2PA 字符串没有防御能力(见 §5.3 官方 SDK 做的签名验证才是可信链路)
5.2.5 文本规则分析
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
comp_w = max_x - min_x + 1
comp_h = max_y - min_y + 1
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
density = area / max(1, comp_w * comp_h)
if not (80 <= area <= 900):
continue
if not (14 <= comp_w <= 60 and 14 <= comp_h <= 80):
continue
if not (0.12 <= density <= 0.70):
continue
if center_x < width * 0.62 or center_y < height * 0.55:
continue
# A sparkle mark has a sparse center-heavy diamond shape.
mid_x = (min_x + max_x) / 2
mid_y = (min_y + max_y) / 2
near_center = sum(
1 for x, y in zip(xs, ys) if abs(x - mid_x) <= comp_w * 0.25 and abs(y - mid_y) <= comp_h * 0.25
)
if near_center / area >= 0.18:
return True
except Exception:
return False
return False
def _analyze_text(self, text: str, include_weak_markers: bool = True) -> Optional[Tuple[str, str]]:
"""
Analyze a string for AI markers.
Returns: (tool_name, found_marker) or None
检测优先级:
1. 先检查明确的软件签名(使用 regex 更严格匹配)
2. 再检查生成参数指纹(如果 IPTC 标记,检查是否有 Google 证据,否则通用)
"""
if not text:
return None
text_lower = text.lower()
# 1. Check Explicit Software Names (按字典顺序,Midjourney 先)
# weak_patterns: substrings too short to trust on their own (gated
# behind include_weak_markers). Currently empty after the "mj_" removal.
weak_patterns: set[str] = set()
for tool, patterns in self.AI_SOFTWARE_SIGNATURES.items():
for pattern in patterns:审计点:
include_weak_markers=False是文件名扫描模式:mj_这种两字符前缀太容易误命中(比如mj_portrait.jpg),在文件名上下文中被屏蔽- 参数指纹匹配 ≥ 1 即升级 SD:单个
Steps:或Sampler:就足够判定,宽松但可能对"用户在注释里抄了 SD 参数"的非 AI 图产生误报 - IPTC 归因分支:
trainedAlgorithmicMedia碰到 Google 关键词即归 Gemini,否则标Generative AI (Unknown)。不会归给 Midjourney / DALL-E
5.2.6 可见 Google/Gemini 水印
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return self._extract_webp_metadata(data)
# Unknown format: keep legacy behavior (whole file).
return data
def _scan_raw_metadata(self, image_path: Path) -> Tuple[Optional[Tuple[str, str]], bool]:
"""
Scan only metadata-bearing chunks/segments of an image, not pixel data.
C2PA manifests live in PNG `caBX`/`jumb` chunks or JPEG APP11 segments.
Pillow may not surface them through Image.info, so we parse the
container ourselves and feed only those bytes to the text analyzer.
"""
try:
size = image_path.stat().st_size
if size > self.RAW_METADATA_SCAN_LIMIT:
return None, False
data = image_path.read_bytes()
except OSError:
return None, False
metadata_bytes = self._extract_metadata_blocks(data)
lowered = metadata_bytes.lower()
has_c2pa_container = any(marker in lowered for marker in self.C2PA_CONTAINER_MARKERS)
c2pa_res = self._analyze_c2pa_bytes(metadata_bytes)
if c2pa_res:
return c2pa_res, has_c2pa_container
text = self._decode_metadata_bytes(metadata_bytes)
return self._analyze_text(text, include_weak_markers=False), has_c2pa_container
def _detect_google_visible_watermark(self, image_path: Path) -> bool:
"""
Detect the visible Google/Gemini sparkle mark often placed near the
lower-right area of generated images. This is visual evidence, not a
SynthID decoder.
"""
try:
with Image.open(image_path) as img:
img = img.convert("RGB")
scale = 512 / max(img.size)
if scale < 1:
img = img.resize((round(img.width * scale), round(img.height * scale)))
width, height = img.size
pix = img.load()
mask = set()
for y in range(height // 2, height):
for x in range(width // 2, width):
r, g, b = pix[x, y]
saturation = max(r, g, b) - min(r, g, b)
luminance = (r * 299 + g * 587 + b * 114) // 1000
if 110 <= luminance <= 245 and saturation < 28:
if not (luminance > 235 and saturation < 8):
mask.add((x, y))
seen = set()
for pt in list(mask):
if pt in seen:
continue
stack = [pt]
seen.add(pt)
xs = []
ys = []
while stack:
x, y = stack.pop()
xs.append(x)
ys.append(y)
for nx in (x - 1, x, x + 1):
for ny in (y - 1, y, y + 1):
npt = (nx, ny)
if npt in mask and npt not in seen:
seen.add(npt)
stack.append(npt)
area = len(xs)审计点:
- 不是 SynthID 解码器,纯形态学判别:低饱和、中偏高亮度、右下象限、面积 80–900 px、长宽比约束、中心稠密
- 长边缩放到 512 px以标准化判据
- 仅在 Google 上下文下启用(见 §5.2.2 流程第 5 步),避免对所有图片跑一遍 O(W·H) 的扫描
- 漏检场景:白底图、裁剪掉右下角、重压缩导致 sparkle 连通块破碎
- 误报场景:右下角原本有低饱和装饰元素(月亮、星星、LOGO 等)
5.3 C2PA 官方 SDK 适配
tools/validator/c2pa_verifier.py 把可选的 c2pa-python 依赖隔离到单独模块。已安装时,它通过官方 SDK 读取 manifest 并要求 SDK 验证 manifest / 签名链。
5.3.1 C2PAVerificationResult
logger = logging.getLogger(__name__)
AI_DIGITAL_SOURCE_MARKERS = {
"trainedalgorithmicmedia",
"compositewithtrainedalgorithmicmedia",
"algorithmicmedia",
"generated by ai",
"ai generated",
"adobe firefly",
"google imagen",
"synthid",
"dall-e",
"dalle",
"midjourney",
"stable diffusion",
}
# IPTC DigitalSourceType values that declare a NON-AI origin: camera/phone
# capture, film scan, or human-made digital art. The inverse of the AI markers
# — lets the validator say "declares camera/human-made" instead of only the
# absence-based "no AI marker found". Matched as lowercased substrings against
# manifest text, same as the AI markers; kept to specific camelCase-derived
# tokens to avoid false positives from generic words like "print".
HUMAN_SOURCE_MARKERS = {
"digitalcapture", # photo from a digital camera / phone
"computationalcapture", # multi-frame computational capture (phone HDR etc.)
"digitalart", # human-made with digital tools (IPTC, retired)
"digitalcreation", # replaces the retired digitalArt
"negativefilm",
"positivefilm",
}
@dataclass
class C2PAVerificationResult:
available: bool
has_manifest: bool = False
verified: bool | None = None
trusted: bool | None = None
validation_state: str = ""
validation_results: dict[str, Any] | None = None
manifest_store: dict[str, Any] | None = None
active_manifest: dict[str, Any] | None = None
sdk_version: str = ""
embedded: bool | None = None
remote_url: str | None = None
ai_markers: list[str] = field(default_factory=list)
provenance_markers: list[str] = field(default_factory=list)
claim_generator: str = ""
author: str = "" # creator/author asserted in the manifest
copyright: str = "" # rights / copyright asserted in the manifest
error: str = ""
asset_format: str = ""
extension_mismatch: bool = False
validation_issues: list[str] = field(default_factory=list)
@property
def ai_generated(self) -> bool:
return bool(self.ai_markers)
@property
def declares_human_origin(self) -> bool:
"""Manifest positively declares a camera/film/human-made source and
carries no AI marker. A *positive* signal, unlike the absence of an AI
marker (which proves nothing — credentials are easily stripped)."""
return bool(self.provenance_markers) and not self.ai_markers
def evidence_summary(self) -> str:
if not self.has_manifest:审计点:
verified/trusted用bool | None三值:None= 无法判定,不是失败ai_generated是 派生属性:凡是在 manifest store 任意深度字符串中命中AI_DIGITAL_SOURCE_MARKERS即为 True,不要求签名 / trustevidence_summary()是 UI 摘要,最多列 4 个 AI marker 和 4 个 validation issue
5.3.2 verify_c2pa_file 入口与 SDK 配置
def _contains_trust_signal(value: Any) -> bool:
if isinstance(value, dict):
for key, item in value.items():
if "trust" in str(key).lower():
return True
if _contains_trust_signal(item):
return True
elif isinstance(value, list):
for item in value:
if _contains_trust_signal(item):
return True
elif isinstance(value, str):
return "trust" in value.lower()
return False
def _active_manifest_results(validation_results: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(validation_results, dict):
return None
active = validation_results.get("activeManifest")
return active if isinstance(active, dict) else None
def _has_validation_code(value: Any, code_fragment: str) -> bool:
if isinstance(value, dict):
code = value.get("code")
if isinstance(code, str) and code_fragment in code:
return True
return any(_has_validation_code(item, code_fragment) for item in value.values())
if isinstance(value, list):
return any(_has_validation_code(item, code_fragment) for item in value)
return False
def _collect_validation_issues(validation_results: dict[str, Any] | None) -> list[str]:
issues: list[str] = []
if not validation_results:
return issues
if _has_validation_code(validation_results, "ingredient.malformed"):
issues.append("ingredient_malformed")
if _has_validation_code(validation_results, "timeStamp.untrusted"):
issues.append("timestamp_untrusted")
return issues
def _verification_flags(
validation_state: str, validation_results: dict[str, Any] | None
) -> tuple[bool | None, bool | None]:
# Judge the ACTIVE manifest's own signature from its explicit success/failure
# code lists — NOT the aggregate validation_state, which folds in the entire
# provenance chain. A signed asset whose own signature is valid + trusted can
# still report validation_state="Invalid" purely because a deep ancestor
# ingredient is malformed (e.g. Gemini edit chains:
# "assertion.ingredient.malformed" lives in ingredientDeltas, not the active
# manifest). That is a chain-completeness nit, not a broken signature on the
# asset in front of the user, and must not read as "签名无效".
active = _active_manifest_results(validation_results)
if isinstance(active, dict) and (active.get("success") or active.get("failure")):
success = {str(s.get("code", "")) for s in active.get("success", []) if isinstance(s, dict)}
failure = {str(s.get("code", "")) for s in active.get("failure", []) if isinstance(s, dict)}
# A failure on the active manifest's signature / data hash / assertion
# hashes = the asset itself was tampered with or can't be verified.
# (Cert trust is handled separately — an untrusted root is "valid but
# untrusted", not a broken signature.)
sig_broken = any(
any(tok in code for tok in ("claimSignature", "dataHash", "boxesHash", "hashedURI")) for code in failure
)
# The hard binding is either a data hash (byte ranges) or a boxes hash
# (box-based formats like PNG/JPEG) — accept whichever the producer used.
hash_ok = "assertion.dataHash.match" in success or "assertion.boxesHash.match" in success
sig_ok = "claimSignature.validated" in success and hash_ok
if sig_broken:
verified: bool | None = False
elif sig_ok:
verified = True
else:
verified = None
if verified is True:
trusted: bool | None = "signingCredential.trusted" in success
elif verified is False:
trusted = False
else:
trusted = None
return verified, trusted
# Fallback 1: no per-manifest results — trust the SDK's aggregate state.
# ("invalid" checked first since it contains the substring "valid".)
state = (validation_state or "").lower()
if "invalid" in state:
return False, False
if "trusted" in state and "untrusted" not in state:
return True, True
if "valid" in state:
return True, False
# Fallback 2 (oldest SDK builds, no state at all): the historical scan.
active_results = validation_results
has_failure = _contains_failure(active_results) if active_results else False
has_non_trust_failure = _contains_non_trust_failure(active_results) if active_results else False
has_trust_failure = _contains_trust_failure(active_results) if active_results else False
has_trust_signal = _contains_trust_signal(active_results) if active_results else False
active_signature_valid = _has_validation_code(active_results, "claimSignature.validated")
active_data_hash_valid = _has_validation_code(active_results, "assertion.dataHash.match")
审计点:
- 扩展名错配时的 SDK 回退:如果文件头显示是 JPEG 但后缀是
.png,改用Reader.try_create(format, stream, ...)流式接口,告诉 SDK 真实格式。直接传路径会让 SDK 按后缀判断失败。 - SDK 配置启用:trust list、timestamp trust、OCSP 吊销检查、remote manifest 远端拉取全部开启
- SDK 版本兼容:两种
try_create签名都尝试(带 context / 不带),兼容不同版本c2pa-python - reader 为空不是错误:
has_manifest保持False,available=True,错误字段为空
5.3.3 签名链与信任状态分离
def _find_ai_markers(manifest_store: dict[str, Any]) -> list[str]:
return _find_markers(manifest_store, AI_DIGITAL_SOURCE_MARKERS)
def _extract_author_copyright(active_manifest: dict[str, Any] | None) -> tuple[str, str]:
"""Pull creator/author + copyright from a manifest's assertions.
Author: schema.org CreativeWork `author[].name` or a CAWG identity name.
Copyright: a `dc.rights` / `*.rights` assertion or copyrightNotice field.
Best-effort across assertion shapes; returns ("", "") when absent.
"""
if not isinstance(active_manifest, dict):
return "", ""
author = ""
copyright_ = ""
assertions = active_manifest.get("assertions")
items = assertions if isinstance(assertions, list) else []
for a in items:
if not isinstance(a, dict):
continue
label = str(a.get("label", "")).lower()
data = a.get("data") if isinstance(a.get("data"), dict) else {}
if not author and ("creativework" in label or "schema-org" in label):
au = data.get("author")
if isinstance(au, list) and au:
first = au[0]
if isinstance(first, dict):
author = str(first.get("name") or first.get("@id") or "")
elif isinstance(first, str):
author = first
elif isinstance(au, str):
author = au
if not copyright_:
for k in ("dc:rights", "rights", "copyrightNotice", "copyright"):
v = data.get(k)
if isinstance(v, str) and v.strip():
copyright_ = v.strip()
break
if not author and "cawg.identity" in label:
nm = data.get("name") or data.get("display_name")
if isinstance(nm, str):
author = nm
return author[:120], copyright_[:200]
def _extract_claim_generator(active_manifest: dict[str, Any] | None) -> str:
if not active_manifest:
return ""
claim_generator = active_manifest.get("claim_generator")
if isinstance(claim_generator, str):
return claim_generator
if isinstance(claim_generator, dict):
name = claim_generator.get("name") or claim_generator.get("identifier")
version = claim_generator.get("version")
if name and version:
return f"{name} {version}"
if name:
return str(name)
infos = active_manifest.get("claim_generator_info")
if isinstance(infos, list) and infos:
first = infos[0]
if isinstance(first, dict):
name = first.get("name") or first.get("identifier")
version = first.get("version")
if name and version:
return f"{name} {version}"
if name:
return str(name)
return ""
def _contains_failure(value: Any) -> bool:
if isinstance(value, dict):
for key, item in value.items():
key_lower = str(key).lower()
if key_lower in {"failure", "failures", "error", "errors"} and item:
return True
if _contains_failure(item):
return True
elif isinstance(value, list):
for item in value:
if _contains_failure(item):
return True
elif isinstance(value, str):
lowered = value.lower()
return any(token in lowered for token in ("invalid", "failure", "error", "untrusted"))
return False
def _contains_trust_failure(value: Any) -> bool:
if isinstance(value, dict):
for key, item in value.items():
key_lower = str(key).lower()
if "trust" in key_lower and _contains_failure(item):
return True
if _contains_trust_failure(item):
return True
elif isinstance(value, list):
for item in value:
if _contains_trust_failure(item):
return True
elif isinstance(value, str):
lowered = value.lower()
return "trust" in lowered and any(token in lowered for token in ("invalid", "failure", "error", "untrusted"))
return False
def _contains_non_trust_failure(value: Any) -> bool:
if isinstance(value, dict):
for key, item in value.items():
key_lower = str(key).lower()
if key_lower in {"failure", "failures", "error", "errors"} and item:
if not _contains_only_trust_related(item):
return True
if _contains_non_trust_failure(item):
return True
elif isinstance(value, list):
for item in value:
if _contains_non_trust_failure(item):
return True
elif isinstance(value, str):
lowered = value.lower()
if any(token in lowered for token in ("invalid", "failure", "error")):
return "trust" not in lowered and "untrusted" not in lowered
return False
def _contains_only_trust_related(value: Any) -> bool:
strings = list(_iter_strings(value))
if not strings:
return False
for text in strings:
lowered = text.lower()
if any(token in lowered for token in ("invalid", "failure", "error")):
if "trust" not in lowered and "untrusted" not in lowered:
return False
return any("trust" in text.lower() or "untrusted" in text.lower() for text in strings)
审计点:
- active manifest 优先:
_active_manifest_results()先取validation_results["activeManifest"],取不到才回退到全树 - "签名有效 + 数据哈希有效" 且 "没有非 trust 类失败" →
verified=True。即使validation_state字段本身含 "invalid"(可能来自 ingredient chain 的问题),也不拉低 active manifest 的签名结论 - trust 独立评估:trust 失败不会把
verified打成 False。UI 可以得到signature_chain=verified; trust=untrusted这种组合,表示签名本身可验证,但签名证书不在当前信任列表(常见于 Google、OpenAI 尚未纳入默认 trust anchors)
5.3.4 AI marker 与 claim_generator 提取
if self.available:
parts = ["No C2PA manifest found"]
if self.asset_format:
parts.append(f"asset_format={self.asset_format}")
if self.extension_mismatch:
parts.append("extension_mismatch=true")
return "; ".join(parts)
return self.error or "C2PA verifier unavailable"
parts = ["C2PA manifest found"]
if self.validation_state:
parts.append(f"validation_state={self.validation_state}")
if self.verified is not None:
parts.append(f"signature_chain={'verified' if self.verified else 'failed'}")
if self.trusted is not None:
parts.append(f"trust={'trusted' if self.trusted else 'untrusted'}")
if self.claim_generator:
parts.append(f"claim_generator={self.claim_generator}")
if self.ai_markers:
parts.append("ai_markers=" + ",".join(self.ai_markers[:4]))
if self.provenance_markers:
parts.append("source_markers=" + ",".join(self.provenance_markers[:4]))
if self.validation_issues:
parts.append("validation_issues=" + ",".join(self.validation_issues[:4]))
if self.remote_url:
parts.append(f"remote_manifest={self.remote_url}")
if self.sdk_version:
parts.append(f"c2pa_sdk={self.sdk_version}")
return "; ".join(parts)
def _iter_strings(value: Any) -> Iterable[str]:
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for key, item in value.items():
yield str(key)
yield from _iter_strings(item)
elif isinstance(value, list):
for item in value:
yield from _iter_strings(item)
def _find_markers(manifest_store: dict[str, Any], markers: set[str]) -> list[str]:
found: list[str] = []
seen = set()
for text in _iter_strings(manifest_store):
lowered = text.lower()
for marker in markers:
if marker in lowered and marker not in seen:
seen.add(marker)
found.append(marker)
return found审计点:
- 递归遍历 manifest store 所有字符串(包括 key),大小写无关匹配
trainedalgorithmicmedia/algorithmicmedia/synthid等 C2PA / IPTC 语义被视为强证据- 不解码不可见 SynthID 水印;
synthid出现在 manifest store 中代表 C2PA 声明"图片含 SynthID",不代表本地完成了 SynthID 解码 claim_generator兼容三种形态:字符串、dict({name, version}/{identifier, version})、claim_generator_info数组
5.3.5 Validation issues
return False
def _contains_trust_failure(value: Any) -> bool:
if isinstance(value, dict):
for key, item in value.items():
key_lower = str(key).lower()
if "trust" in key_lower and _contains_failure(item):
return True
if _contains_trust_failure(item):
return True
elif isinstance(value, list):
for item in value:
if _contains_trust_failure(item):
return True
elif isinstance(value, str):
lowered = value.lower()
return "trust" in lowered and any(token in lowered for token in ("invalid", "failure", "error", "untrusted"))
return False
def _contains_non_trust_failure(value: Any) -> bool:典型 evidence summary 输出:
C2PA manifest found;
validation_state=Invalid;
signature_chain=verified;
trust=untrusted;
ai_markers=algorithmicmedia,trainedalgorithmicmedia,synthid;
validation_issues=ingredient_malformed,timestamp_untrusted审计点:
validation_state=Invalid不一定代表当前图片数据被篡改。active manifest 的签名和数据哈希可以有效,同时 ingredient chain 存在问题- UI 应展示为"签名链有效 / 证书未在当前信任列表 / 存在链路问题",不是"签名失败"
5.4 Worker 层
"""AI Metadata Detection Worker"""
import logging
from pathlib import Path
from PySide6.QtCore import QThread, Signal, QCoreApplication
logger = logging.getLogger(__name__)
class AIDetectorWorker(QThread):
"""Worker thread for AI metadata detection."""
progress = Signal(int, int, str) # (current, total, filename)
item_finished = Signal(str, str, str, str, str) # (path, status, reason, tool, evidence)
all_finished = Signal()
model_status = Signal(str) # Status for DynamicIsland
def __init__(self, file_paths: list):
super().__init__()
self.file_paths = file_paths
def run(self):
"""Execute metadata detection in background thread."""
try:
from .._utils import ensure_src_path
ensure_src_path()
from tools.validator.logic import MetaDataDetector
detector = MetaDataDetector()
self.model_status.emit(QCoreApplication.translate("AIDetectorWorker", "扫描元数据..."))
total = len(self.file_paths)
logger.info("开始检测 %d 个文件", total)
for i, path in enumerate(self.file_paths):
if self.isInterruptionRequested():
break
filename = Path(path).name
self.progress.emit(i + 1, total, filename)
try:
res = detector.detect(path)
self.item_finished.emit(
path, res["status"], res["reason"], res["tool"] or "", res["evidence"] or ""
)
except Exception as e:
logger.error("[MetaDetector] 检测文件出错 %s: %s", path, e)
self.item_finished.emit(path, "error", QCoreApplication.translate("AIDetectorWorker", "检测出错: %s") % str(e), "", "")
logger.info("[MetaDetector] 检测完成,共 %d 个文件", total)
except Exception as e:
logger.error("[MetaDetector] Worker error: %s", e, exc_info=True)
finally:
self.all_finished.emit()
审计点:
- 逐文件错误隔离:单个文件的异常被捕获并转换为
status="error"信号,不影响后续文件 - 可中断:
isInterruptionRequested()允许 UI 取消批量任务 - Detector 实例复用:整批共享一个
MetaDataDetector,但该类自身无状态(__init__为空),无跨文件污染风险 - 信号载荷:
item_finished串一张图一发,避免积压
5.5 证据分级表
| 证据 | 等级 | 是否触发 ai |
|---|---|---|
C2PA ai_markers(trainedAlgorithmicMedia / synthid / ...) | 强 | 是 |
ComfyUI workflow / prompt JSON | 强 | 是 |
Stable Diffusion parameters chunk | 强 | 是 |
NovelAI 生成 JSON(uc + sampler/steps/scale) | 强 | 是 |
| InvokeAI / Fooocus 元数据 | 强 | 是 |
Midjourney Job ID / --ar / --v / --stylize | 强 | 是 |
IPTC/XMP trainedAlgorithmicMedia | 强 | 是 |
| Google 可见 sparkle 水印(Google context 下) | 中 | 是 |
| 文件名含平台词 | 弱 | 否(最多 unknown) |
| 扩展名错配 | 上下文 | 否 |
| 最小 JPEG 元数据 | 上下文 | 否 |
| CELSYS / Clip Studio 导出标记 | 上下文 | 否 |
| 无任何元数据 | 无证据 | 否 |
5.6 测试覆盖
tests/test_validator_c2pa.py 当前覆盖:
- C2PA raw payload AI marker
- C2PA manifest 无 AI marker 不误报
- 官方 C2PA verifier mock 驱动检测
- Google C2PA 无 AI marker 时压制 raw false positive
- Google / Gemini 可见水印上下文检测
- Midjourney Job ID 与 IPTC AI source
- 文件名单独 Gemini 仅输出
unknown - 平台重编码 /
.png后缀 JPEG 不升级 AI - A1111 Stable Diffusion parameters
- NovelAI generation JSON
- InvokeAI metadata
- Fooocus metadata
- active C2PA signature valid but ingredient chain has issue 的分离解释
建议持续加入真实样本回归集:OpenAI、Gemini、Midjourney、ComfyUI、A1111、Forge、Fooocus、NovelAI、InvokeAI、Adobe Firefly、Tusi / Liblib / TensorArt 等平台下载图。
6. 威胁模型总览
6.1 数字存证
| 威胁 | 缓解措施 | 残余风险 |
|---|---|---|
| 用户篡改原始文件 | Merkle Tree 根哈希验证 | 无(篡改必被发现) |
| TSA 私钥泄露 | 多供应商故障转移 | 单一 TSA 泄露不影响历史验证 |
| 本地 JSON 被修改 | 可选 AES-256 加密 | 未加密时可修改,但文件哈希验证仍会暴露 |
| Merkle second-preimage | 无域分隔前缀 | 不满足对抗性碰撞构造 |
| 作者身份伪造 | 用户自声明 | author_name 无第三方验证 |
6.2 维权取证
| 威胁 | 缓解措施 | 残余风险 |
|---|---|---|
| 目标页面删除 | 立即取证 + RFC 3161 | 取证前已删除则无法补救 |
| 本地伪造网页 | TLS 证书抓取 | 仅验证域名证书,不验证内容真实性 |
| 截图被 PS | manifest SHA-256 | 截图本身无法证明"未经过 PS" |
| 浏览器被识别为 bot | stealth + visible fallback | 部分平台仍可能拦截 |
| HAR / 证书缺失 | 多源采集 | 单点失败不会导致整包失效 |
| 超长页面 | 视口截图 | full_page=False,下方内容不会被 capture |
6.3 盲水印
| 威胁 | 缓解措施 | 残余风险 |
|---|---|---|
| 密码硬编码 | 编译期常量 | 所有用户实例共享同一对密码,逆向获取后可批量提取 |
| 无认证水印 | 双密码系统 | 无法证明"这条水印是我嵌入的",只能证明"图片包含这段文本" |
| 伪造水印 | 密码保密 | 知道密码后可将任意文本嵌入并声称来自 Nephele |
| 去除水印 | 量化嵌入(d1=36) | 重度压缩、旋转、大面积裁切(> 50%)可破坏 |
| 静默截断 | 定长 32 字节编码 | 超长文本被静默截断,用户可能误以为完整嵌入 |
| 验证失败仍输出 | round-trip 检查 | mismatch 时仅记录 warning,图片仍会输出 |
注意
盲水印在 Nephele 中的定位是辅助溯源工具,不是密码学意义上的数字签名。其核心价值在于"增加盗图者去除水印的成本",而非"提供不可伪造的权属证明"。如需法律级确权,请使用数字存证功能。
6.4 AI 凭据检测
| 威胁 / 场景 | 结果 |
|---|---|
| 原始 ComfyUI PNG | 可检出 workflow / prompt |
| 原始 A1111 PNG | 可检出 parameters |
| OpenAI / Google C2PA 图 | 可读取 manifest、AI marker、签名链、trust 状态 |
| Midjourney 保留 Job ID / XMP | 可检出强证据 |
| 平台重编码图(微博 / Twitter / 小红书下载) | 只能提示凭据不足,status=human(reason 区分) |
| 截图 | 原始 metadata 通常丢失,无法判定 |
| 恶意清理元数据 | 无法恢复已删除证据 |
| 恶意伪造非 C2PA 文本元数据 | 无密码学真实性保证,可能误报 |
| 仅视觉风格像 AI | 不判断 |
| 用户在注释里抄 SD 参数的非 AI 图 | 可能误报为 ai |
| 右下角有低饱和装饰物的非 AI 图(Google context 下) | 可能触发 sparkle 误报 |
注意
本功能的审计结论是:适合作为 AI 生成凭据与元数据筛查工具,不应被宣传为通用 AI 图像真伪检测器。"未检出"不等于"非 AI 生成"。
7. 依赖清单与降级行为
| 库 | 用途 | 缺失时行为 |
|---|---|---|
rfc3161ng | TSA 通信 | TSA 完全不可用,强制降级本地 .json |
asn1crypto | TSR 解析 | 回退到本地时钟和 provider_name |
pyzipper | .nep AES-256 | 密码不生效,标准 ZIP |
Pillow | 缩略图 / 图像 I/O / 元数据读取 | 存证流程阻断 / 打包功能阻断 / AI 检测阻断 |
reportlab | PDF 报告 | 存证流程阻断 |
qrcode | PDF 二维码 | 回退纯文本 URL |
playwright | 浏览器取证 | 功能完全不可用 |
blind_watermark | 盲水印 DWT 嵌入 / 提取 | 盲水印功能完全不可用,返回原图 |
numpy | 盲水印 bit 数组转换 / 图像处理 | 盲水印功能完全不可用 |
pywt | 小波变换(blind_watermark 传递依赖) | 盲水印功能完全不可用 |
c2pa-python | 官方 C2PA SDK | 回退到字节级扫描(无签名验证),evidence 注明 "official C2PA verifier unavailable" |
8. 隐私与网络行为
8.1 默认联网路径
| 功能 | 网络动作 | 是否可关闭 |
|---|---|---|
| 数字存证(TSA) | 向 DigiCert / FreeTSA / IdenTrust 发送 SHA-256 摘要请求 | 可切回纯本地时间戳(降级) |
| 维权取证 | 通过 Playwright 向目标站点发起 HTTPS 请求、TLS 握手、DNS 解析 | 否(功能本质即为联网取证) |
| AI 元数据检测(C2PA SDK) | ocsp_fetch=True + remote_manifest_fetch=True | 当前通过 SDK settings 硬编码开启,无 UI 开关 |
| 盲水印 | 无 | — |
8.2 离线性说明
-
数字存证:向 TSA 只发送哈希值,不发送原始文件内容。
-
维权取证:按设计发起对目标 URL 的完整请求,这就是取证本身。
-
AI 元数据检测:常规 PNG info / EXIF / 字节扫描在本地完成。但 C2PA SDK 在验证签名链时可能:
- 拉取远端 manifest(
remote_manifest_fetch) - 检查证书吊销(
ocsp_fetch) - 验证时间戳 trust(
verify_timestamp_trust)
因此产品文案不应笼统宣称"C2PA 验证永远不联网"。如用户需要严格离线模式,应提供关闭 remote manifest / OCSP 的开关(尚未实现)。
- 拉取远端 manifest(
-
盲水印:完全本地,嵌入 / 提取均不触网。