From afdff377626b8c0bcde1a53ff91f4c524684ec13 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Wed, 2 Sep 2026 20:28:56 +0800 Subject: [PATCH 1/5] chore: finalize post-migration CarryCtx config --- .carryctx/config.toml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.carryctx/config.toml b/.carryctx/config.toml index 48bc439..481f1a4 100644 --- a/.carryctx/config.toml +++ b/.carryctx/config.toml @@ -2,12 +2,19 @@ schema_version = 1 [project] id = "01M0094ARQJD79NQ9M0P1YSWPZ" -name = "sigil" -task_prefix = "CTX" +name = "capglyph-cli" +task_prefix = "CGCLI" [git] main_branch = "main" -branch_template = "ctx-{task_id}/{slug}" +branch_template = "carryctx/{task_id}-{slug}" + +[worktree.cleanup] +on_task_completed = "when_idle" +on_task_cancelled = "keep" +require_clean = true +require_no_active_session = true +delete_branch = "never" [session] stale_after = "2h" @@ -16,6 +23,7 @@ single_active_session_per_agent = true [task] single_active_task_per_agent = true strict_completion = false +list_limit = 200 [context] default_mode = "compact" From 56705c459cf5917dce76854d8c4070b76a1e7042 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Fri, 4 Sep 2026 21:05:06 +0800 Subject: [PATCH 2/5] feat(server): close capglyphd credential security and policy gates (CGCLI-0001) --- crates/capglyph-server/src/bin/capglyphd.rs | 31 +- .../src/carrier_integration.rs | 22 +- crates/capglyph-server/src/db.rs | 46 +- crates/capglyph-server/src/error.rs | 89 ++- crates/capglyph-server/src/http.rs | 194 ++++-- crates/capglyph-server/src/models.rs | 23 + crates/capglyph-server/src/service.rs | 291 ++++++--- .../tests/concurrent_consume.rs | 2 +- .../tests/security_contract.rs | 576 ++++++++++++++++++ 9 files changed, 1101 insertions(+), 173 deletions(-) create mode 100644 crates/capglyph-server/tests/security_contract.rs diff --git a/crates/capglyph-server/src/bin/capglyphd.rs b/crates/capglyph-server/src/bin/capglyphd.rs index 95229a8..c361349 100644 --- a/crates/capglyph-server/src/bin/capglyphd.rs +++ b/crates/capglyph-server/src/bin/capglyphd.rs @@ -19,7 +19,7 @@ fn main() { #[cfg(not(target_arch = "wasm32"))] #[tokio::main] async fn main() -> anyhow::Result<()> { - // Minimal CLI: `capglyphd --db /tmp/capglyphd.db --listen 127.0.0.1:3000` + // Minimal CLI: `capglyphd --db ./capglyphd.db --listen 127.0.0.1:3000` let args: Vec = std::env::args().collect(); let mut db_path: Option = None; let mut listen: String = "127.0.0.1:3000".to_string(); @@ -43,7 +43,7 @@ async fn main() -> anyhow::Result<()> { println!("Usage: capglyphd [--db PATH] [--listen ADDR]"); println!(" --db PATH SQLite file (default: in-memory)"); println!(" --listen ADDR HTTP listen addr (default: 127.0.0.1:3000)"); - println!("Env: CAPGLYPHD_MASTER_KEY (hex 32 bytes) or random if unset"); + println!("Env: CAPGLYPHD_MASTER_KEY (exactly 32 non-zero bytes as hex)"); return Ok(()); } _ => {} @@ -53,6 +53,7 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); + let persistent_db = db_path.is_some(); let db = if let Some(p) = db_path { eprintln!("capglyphd: opening db at {:?}", p); Db::new(p)? @@ -61,22 +62,29 @@ async fn main() -> anyhow::Result<()> { Db::new_in_memory()? }; - // KMS: load master from env or generate + // KMS: persistent deployments must provide a stable, valid master key. let mut kms = Kms::new(); if let Ok(hex_key) = std::env::var("CAPGLYPHD_MASTER_KEY") { - let bytes = hex::decode(hex_key.trim()).unwrap_or_else(|_| vec![0u8; 32]); - if bytes.len() == 32 { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - kms = kms.with_key("default", arr); - kms = kms.with_key("cred-2026-08", arr); - eprintln!("capglyphd: loaded master from CAPGLYPHD_MASTER_KEY"); + let bytes = hex::decode(hex_key.trim()) + .map_err(|_| anyhow::anyhow!("CAPGLYPHD_MASTER_KEY must be valid hexadecimal"))?; + if bytes.len() != 32 { + anyhow::bail!("CAPGLYPHD_MASTER_KEY must decode to exactly 32 bytes"); } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + if arr == [0u8; 32] { + anyhow::bail!("CAPGLYPHD_MASTER_KEY must not be the all-zero key"); + } + kms = kms.with_key("default", arr); + kms = kms.with_key("cred-2026-08", arr); + eprintln!("capglyphd: loaded master from CAPGLYPHD_MASTER_KEY"); + } else if persistent_db { + anyhow::bail!("CAPGLYPHD_MASTER_KEY is required with --db"); } else { kms.generate_key_id("default"); kms.generate_key_id("cred-2026-08"); eprintln!( - "capglyphd: generated ephemeral master keys (set CAPGLYPHD_MASTER_KEY for persistence)" + "capglyphd: generated ephemeral master keys for the in-memory development database" ); } @@ -88,6 +96,7 @@ async fn main() -> anyhow::Result<()> { let app = capglyph_server::router(svc); let addr: SocketAddr = listen.parse()?; eprintln!("capglyphd: listening on http://{}", addr); + eprintln!(" GET /v1/version — API and wire versions"); eprintln!(" POST /v1/credentials — issue"); eprintln!(" POST /v1/credentials/verify — verify (read-only)"); eprintln!(" POST /v1/credentials/consume — consume (atomic, Idempotency-Key)"); diff --git a/crates/capglyph-server/src/carrier_integration.rs b/crates/capglyph-server/src/carrier_integration.rs index db9c4cc..ec5d044 100644 --- a/crates/capglyph-server/src/carrier_integration.rs +++ b/crates/capglyph-server/src/carrier_integration.rs @@ -21,7 +21,9 @@ pub fn encode_credential_token(token_id: &[u8; 16], k_mac: &[u8; 32]) -> Vec payload_type: PayloadType::Credential, flags: 0, }; - let sealed = framing::seal(token_id, ¶ms, k_mac); + let payload = framing::credential_payload(token_id); + let sealed = framing::try_seal_typed(&payload, ¶ms, k_mac) + .expect("canonical v1 Credential payload is always sealable"); ecc::encode(&sealed, Profile::Repetition8) } @@ -32,13 +34,8 @@ pub fn decode_credential_token(coded: &[u8], k_mac: &[u8; 32]) -> anyhow::Result // For MVP we use hard-bit path; real server uses soft_bits via SignalMetrics. let bits: Vec = coded.iter().map(|&b| b != 0).collect(); let sealed = ecc::decode_hard(&bits, Profile::Repetition8)?; - let (_hdr, payload) = framing::open(&sealed, k_mac)?; - if payload.len() != 16 { - anyhow::bail!("expected 16-byte token_id, got {}", payload.len()); - } - let mut out = [0u8; 16]; - out.copy_from_slice(&payload); - Ok(out) + let (header, payload) = framing::open_typed(&sealed, k_mac)?; + framing::credential_token_id(&payload, header.flags).map_err(Into::into) } /// Soft-bit decode path (demonstrates `magnitude → LLR` integration). @@ -49,13 +46,8 @@ pub fn decode_credential_token_soft( k_mac: &[u8; 32], ) -> anyhow::Result<[u8; 16]> { let sealed = ecc::decode(soft, Profile::Repetition8)?; - let (_hdr, payload) = framing::open(&sealed, k_mac)?; - if payload.len() != 16 { - anyhow::bail!("expected 16-byte token_id, got {}", payload.len()); - } - let mut out = [0u8; 16]; - out.copy_from_slice(&payload); - Ok(out) + let (header, payload) = framing::open_typed(&sealed, k_mac)?; + framing::credential_token_id(&payload, header.flags).map_err(Into::into) } #[cfg(test)] diff --git a/crates/capglyph-server/src/db.rs b/crates/capglyph-server/src/db.rs index be35791..b30b25d 100644 --- a/crates/capglyph-server/src/db.rs +++ b/crates/capglyph-server/src/db.rs @@ -474,12 +474,12 @@ impl Db { token_hash: &[u8], idempotency_key: &str, actor_id: Option, - request_hash: Option>, - ) -> Result { + request_hash: &[u8; 32], + ) -> Result<(Credential, bool)> { self.with_conn_mut(|conn| { // Use IMMEDIATE to acquire reserved lock early and avoid deadlock busy loops conn.execute_batch("BEGIN IMMEDIATE;")?; - let result: Result = (|| { + let result: Result<(Credential, bool)> = (|| { // 1. Find credential by token_hash let cred_opt = { let mut stmt = conn.prepare( @@ -519,26 +519,26 @@ impl Db { let cred = cred_opt.ok_or_else(|| ServerError::NotFound("credential not found".into()))?; // 2. Check idempotency: has this key already been used for this credential? - let existing: Option<(String, String)> = { + let existing: Option<(Option, Option>, String)> = { let mut stmt = conn.prepare( - "SELECT id, outcome FROM credential_consumptions WHERE credential_id = ?1 AND idempotency_key = ?2", + "SELECT actor_id, request_hash, outcome FROM credential_consumptions WHERE credential_id = ?1 AND idempotency_key = ?2", )?; stmt.query_row( params![cred.id.to_string(), idempotency_key], - |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional()? }; - if let Some((_id, _outcome)) = existing { - // Idempotent replay — return current credential without mutating. - // Outcome must have been success previously; we treat replay as success. - // Verify credential still looks valid (but allow replay even if now expired? spec says idempotent). - // For strictness, we return credential as-is. - // Need to ensure we didn't already count this consumption; replay should not increment. - // Just return the credential with current use_count. - // The caller can distinguish replay via `outcome`. - // We do not insert audit again. - return Ok(cred); + if let Some((stored_actor, stored_hash, outcome)) = existing { + let actor_matches = stored_actor.as_deref() + == actor_id.as_ref().map(|actor| actor.to_string()).as_deref(); + let hash_matches = stored_hash.as_deref() == Some(request_hash.as_slice()); + if outcome != "consumed" || !actor_matches || !hash_matches { + return Err(ServerError::Conflict( + "idempotency key was already used for a different request".into(), + )); + } + return Ok((cred, true)); } // 3. Check credential state before consuming (fail-closed) @@ -550,7 +550,7 @@ impl Db { &cred.id, idempotency_key, actor_id, - request_hash.as_deref(), + Some(request_hash.as_slice()), "revoked", ); return Err(ServerError::Revoked); @@ -563,7 +563,7 @@ impl Db { &cred.id, idempotency_key, actor_id, - request_hash.as_deref(), + Some(request_hash.as_slice()), "not_yet_valid", ); return Err(ServerError::Expired); @@ -576,7 +576,7 @@ impl Db { &cred.id, idempotency_key, actor_id, - request_hash.as_deref(), + Some(request_hash.as_slice()), "expired", ); return Err(ServerError::Expired); @@ -589,7 +589,7 @@ impl Db { &cred.id, idempotency_key, actor_id, - request_hash.as_deref(), + Some(request_hash.as_slice()), "exhausted", ); return Err(ServerError::Exhausted); @@ -623,7 +623,7 @@ impl Db { &cred.id, idempotency_key, actor_id, - request_hash.as_deref(), + Some(request_hash.as_slice()), "exhausted_race", ); return Err(ServerError::Exhausted); @@ -636,14 +636,14 @@ impl Db { &cred.id, idempotency_key, actor_id, - request_hash.as_deref(), + Some(request_hash.as_slice()), "consumed", )?; // 6. Return updated credential let mut updated = cred; updated.use_count = new_use_count; - Ok(updated) + Ok((updated, false)) })(); match &result { diff --git a/crates/capglyph-server/src/error.rs b/crates/capglyph-server/src/error.rs index ed7dd22..f50925e 100644 --- a/crates/capglyph-server/src/error.rs +++ b/crates/capglyph-server/src/error.rs @@ -1,4 +1,6 @@ -/// Central error type for capglyph-server (sigild). +//! Central error type for capglyph-server (sigild). + +pub use capglyph_core::error::{CapGlyphError, ErrorCode}; #[derive(Debug, thiserror::Error)] pub enum ServerError { @@ -6,6 +8,8 @@ pub enum ServerError { Db(#[from] rusqlite::Error), #[error("not found: {0}")] NotFound(String), + #[error("key not found: {0}")] + KeyNotFound(String), #[error("conflict: {0}")] Conflict(String), #[error("expired")] @@ -16,12 +20,95 @@ pub enum ServerError { Exhausted, #[error("invalid token")] InvalidToken, + #[error("scope denied")] + ScopeDenied, #[error("unauthorized scope: {0}")] Unauthorized(String), + #[error(transparent)] + Core(#[from] CapGlyphError), #[error("internal: {0}")] Internal(String), } +impl ServerError { + /// Stable machine-readable code returned on the HTTP wire. + pub const fn code(&self) -> &'static str { + match self { + Self::NotFound(_) => "E_NOT_FOUND", + Self::KeyNotFound(_) => ErrorCode::KeyNotFound.as_str(), + Self::Conflict(_) => "E_CONFLICT", + Self::Expired => ErrorCode::Expired.as_str(), + Self::Revoked => ErrorCode::Revoked.as_str(), + Self::Exhausted => ErrorCode::Consumed.as_str(), + Self::InvalidToken => ErrorCode::PayloadInvalid.as_str(), + Self::ScopeDenied => "E_SCOPE_DENIED", + Self::Unauthorized(_) => "E_UNAUTHORIZED", + Self::Core(error) => error.code.as_str(), + Self::Db(_) | Self::Internal(_) => ErrorCode::Internal.as_str(), + } + } + + /// Stable error name without the `E_` prefix. + pub const fn error_name(&self) -> &'static str { + match self { + Self::NotFound(_) => "NOT_FOUND", + Self::KeyNotFound(_) => "KEY_NOT_FOUND", + Self::Conflict(_) => "CONFLICT", + Self::Expired => "EXPIRED", + Self::Revoked => "REVOKED", + Self::Exhausted => "CONSUMED", + Self::InvalidToken => "PAYLOAD_INVALID", + Self::ScopeDenied => "SCOPE_DENIED", + Self::Unauthorized(_) => "UNAUTHORIZED", + Self::Core(error) => match error.code { + ErrorCode::VersionUnsupported => "VERSION_UNSUPPORTED", + ErrorCode::KeyNotFound => "KEY_NOT_FOUND", + ErrorCode::MalformedFrame => "MALFORMED_FRAME", + ErrorCode::AuthFailed => "AUTH_FAILED", + ErrorCode::PayloadInvalid => "PAYLOAD_INVALID", + ErrorCode::InsufficientCapacity => "INSUFFICIENT_CAPACITY", + ErrorCode::Expired => "EXPIRED", + ErrorCode::Revoked => "REVOKED", + ErrorCode::Consumed => "CONSUMED", + ErrorCode::Tampered => "TAMPERED", + ErrorCode::GeometryMismatch => "GEOMETRY_MISMATCH", + ErrorCode::Internal => "INTERNAL", + }, + Self::Db(_) | Self::Internal(_) => "INTERNAL", + } + } + + /// Public message deliberately omits database, key, token, and policy details. + pub fn public_message(&self) -> &'static str { + match self { + Self::NotFound(_) => "resource not found", + Self::KeyNotFound(_) => "key not found", + Self::Conflict(_) => "request conflicts with existing state", + Self::Expired => "credential is outside its validity window", + Self::Revoked => "credential is revoked", + Self::Exhausted => "credential quota is exhausted", + Self::InvalidToken => "credential token is invalid", + Self::ScopeDenied => "requested scope is not authorized", + Self::Unauthorized(_) => "caller is not authorized", + Self::Core(error) => match error.code { + ErrorCode::VersionUnsupported => "CapGlyph version is not supported", + ErrorCode::KeyNotFound => "key not found", + ErrorCode::MalformedFrame => "credential frame is malformed", + ErrorCode::AuthFailed => "credential authentication failed", + ErrorCode::PayloadInvalid => "credential payload is invalid", + ErrorCode::InsufficientCapacity => "carrier capacity is insufficient", + ErrorCode::Expired => "credential is outside its validity window", + ErrorCode::Revoked => "credential is revoked", + ErrorCode::Consumed => "credential quota is exhausted", + ErrorCode::Tampered => "carrier evidence is tampered", + ErrorCode::GeometryMismatch => "image geometry does not match", + ErrorCode::Internal => "internal error", + }, + Self::Db(_) | Self::Internal(_) => "internal error", + } + } +} + pub type Result = std::result::Result; impl From for ServerError { diff --git a/crates/capglyph-server/src/http.rs b/crates/capglyph-server/src/http.rs index e7d0940..b4a6700 100644 --- a/crates/capglyph-server/src/http.rs +++ b/crates/capglyph-server/src/http.rs @@ -13,11 +13,12 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use base64::Engine as _; +use capglyph_core::error::{CapGlyphError, ErrorCode}; use crate::error::ServerError; use crate::models::{ - ConsumeResponse, IssueRequest, IssueResponse, MessageObject, ResolveMessageResponse, - RevokeRequest, StoreMessageResponse, VerifyResponse, + AuthorizationContext, ConsumeResponse, IssueRequest, IssueResponse, ResolveMessageResponse, + RevokeRequest, StoreMessageResponse, VerifyResponse, VersionResponse, API_VERSION, }; use crate::service::Service; @@ -31,20 +32,88 @@ pub struct AppState { // ── Error mapping ───────────────────────────────────────────────────────────── fn map_err(e: ServerError) -> (StatusCode, Json) { - let (code, msg) = match &e { - ServerError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()), - ServerError::Conflict(m) => (StatusCode::CONFLICT, m.clone()), - ServerError::Expired => (StatusCode::GONE, "expired".into()), - ServerError::Revoked => (StatusCode::GONE, "revoked".into()), - ServerError::Exhausted => (StatusCode::TOO_MANY_REQUESTS, "exhausted".into()), - ServerError::InvalidToken => (StatusCode::BAD_REQUEST, "invalid token".into()), - ServerError::Unauthorized(m) => (StatusCode::FORBIDDEN, m.clone()), - ServerError::Db(_) | ServerError::Internal(_) => { - (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) - } + let status = match &e { + ServerError::NotFound(_) | ServerError::KeyNotFound(_) => StatusCode::NOT_FOUND, + ServerError::Conflict(_) | ServerError::Exhausted => StatusCode::CONFLICT, + ServerError::Expired | ServerError::Revoked => StatusCode::GONE, + ServerError::InvalidToken => StatusCode::UNPROCESSABLE_ENTITY, + ServerError::ScopeDenied | ServerError::Unauthorized(_) => StatusCode::FORBIDDEN, + ServerError::Core(error) => match error.code { + ErrorCode::VersionUnsupported | ErrorCode::MalformedFrame => StatusCode::BAD_REQUEST, + ErrorCode::KeyNotFound => StatusCode::NOT_FOUND, + ErrorCode::AuthFailed => StatusCode::UNAUTHORIZED, + ErrorCode::PayloadInvalid | ErrorCode::Tampered | ErrorCode::GeometryMismatch => { + StatusCode::UNPROCESSABLE_ENTITY + } + ErrorCode::InsufficientCapacity => StatusCode::PAYLOAD_TOO_LARGE, + ErrorCode::Expired | ErrorCode::Revoked => StatusCode::GONE, + ErrorCode::Consumed => StatusCode::CONFLICT, + ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }, + ServerError::Db(_) | ServerError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; - let body = serde_json::json!({ "error": msg, "code": code.as_u16() }); - (code, Json(body)) + let mut body = serde_json::json!({ + "error": e.error_name(), + "code": e.code(), + "message": e.public_message(), + }); + if e.code() == ErrorCode::VersionUnsupported.as_str() { + body["supported_versions"] = serde_json::json!([API_VERSION]); + } + (status, Json(body)) +} + +fn negotiate_version(headers: &HeaderMap) -> Result<(), (StatusCode, Json)> { + let Some(raw) = headers.get("accept-capglyph-version") else { + return Ok(()); + }; + let accepted = raw + .to_str() + .ok() + .map(|value| { + value + .split(',') + .filter_map(|item| item.trim().parse::().ok()) + .any(|version| version == API_VERSION) + }) + .unwrap_or(false); + if accepted { + Ok(()) + } else { + Err(map_err(ServerError::Core(CapGlyphError::new( + ErrorCode::VersionUnsupported, + "no mutually supported CapGlyph version", + )))) + } +} + +/// Extract identity asserted by trusted ingress. A standalone deployment must +/// put authentication middleware in front of this header; request bodies are +/// never allowed to self-assert a different actor. +fn authorization_context( + headers: &HeaderMap, + claimed_actor: Option, + requested_scope: Option, +) -> Result)> { + let header_actor = headers + .get("x-capglyph-actor-id") + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or_else(|| map_err(ServerError::Unauthorized("invalid actor context".into()))) + }) + .transpose()?; + if claimed_actor.is_some() && claimed_actor != header_actor { + return Err(map_err(ServerError::Unauthorized( + "body actor does not match trusted ingress actor".into(), + ))); + } + Ok(AuthorizationContext { + actor_id: header_actor, + requested_scope, + }) } // ── Handlers ────────────────────────────────────────────────────────────────── @@ -52,13 +121,21 @@ fn map_err(e: ServerError) -> (StatusCode, Json) { #[derive(Debug, Deserialize)] pub struct VerifyBody { pub token_id: String, + pub requested_scope: Option, } async fn handle_verify( State(state): State, + headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { - state.svc.verify(&body.token_id).map(Json).map_err(map_err) + negotiate_version(&headers)?; + let auth = authorization_context(&headers, None, body.requested_scope)?; + state + .svc + .verify_authorized(&body.token_id, &auth) + .map(Json) + .map_err(map_err) } #[derive(Debug, Deserialize)] @@ -66,6 +143,7 @@ pub struct ConsumeBody { pub token_id: String, pub idempotency_key: Option, pub actor_id: Option, + pub requested_scope: Option, } async fn handle_consume( @@ -73,6 +151,7 @@ async fn handle_consume( headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { + negotiate_version(&headers)?; // Idempotency-Key can be in header or body; header takes precedence let idem = headers .get("idempotency-key") @@ -85,9 +164,10 @@ async fn handle_consume( )) })?; + let auth = authorization_context(&headers, body.actor_id, body.requested_scope)?; state .svc - .consume(&body.token_id, &idem, body.actor_id) + .consume_authorized(&body.token_id, &idem, &auth) .map(Json) .map_err(map_err) } @@ -95,8 +175,18 @@ async fn handle_consume( async fn handle_get( State(state): State, Path(id): Path, + headers: HeaderMap, ) -> Result, (StatusCode, Json)> { - state.svc.get(&id).map(Json).map_err(map_err) + negotiate_version(&headers)?; + let auth = authorization_context(&headers, None, None)?; + let token = headers + .get("x-capglyph-token") + .and_then(|value| value.to_str().ok()); + state + .svc + .get_authorized(&id, &auth, token) + .map(Json) + .map_err(map_err) } async fn handle_revoke( @@ -105,14 +195,17 @@ async fn handle_revoke( headers: HeaderMap, body: Option>, ) -> Result, (StatusCode, Json)> { - // Actor can be in body or header; optional - let actor_id = body.and_then(|b| b.actor_id).or_else(|| { - headers - .get("x-actor-id") - .and_then(|v| v.to_str().ok()) - .and_then(|s| Uuid::parse_str(s).ok()) + negotiate_version(&headers)?; + let body = body.map(|body| body.0).unwrap_or(RevokeRequest { + actor_id: None, + token_id: None, }); - state.svc.revoke(&id, actor_id).map(Json).map_err(map_err) + let auth = authorization_context(&headers, body.actor_id, None)?; + state + .svc + .revoke_authorized(&id, &auth, body.token_id.as_deref()) + .map(Json) + .map_err(map_err) } #[derive(Debug, Deserialize, Serialize)] @@ -130,8 +223,11 @@ pub struct IssueBody { async fn handle_issue( State(state): State, + headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { + negotiate_version(&headers)?; + let _auth = authorization_context(&headers, None, None)?; let req = IssueRequest { cover_id: body.cover_id, scope: body.scope, @@ -161,8 +257,12 @@ pub struct StoreMessageBody { async fn handle_store_message( State(state): State, + headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { + negotiate_version(&headers)?; + let auth = authorization_context(&headers, body.owner_id, None)?; + let owner_id = auth.actor_id; // Two modes: if plaintext_base64 provided, encrypt server-side; else raw ciphertext if let Some(pt_b64) = body.plaintext_base64 { let pt = base64::engine::general_purpose::STANDARD @@ -176,7 +276,7 @@ async fn handle_store_message( // encrypt_and_store generates key/nonce internally state .svc - .encrypt_and_store(&pt, policy, body.owner_id, body.expires_at) + .encrypt_and_store(&pt, policy, owner_id, body.expires_at) .map(|(resp, _, _)| Json(resp)) .map_err(map_err) } else { @@ -207,7 +307,7 @@ async fn handle_store_message( tag, None, policy, - body.owner_id, + owner_id, body.expires_at, ) .map(Json) @@ -223,20 +323,25 @@ pub struct ResolveMessageBody { async fn handle_resolve_message( State(state): State, + headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { + negotiate_version(&headers)?; + let auth = authorization_context(&headers, body.actor_id, None)?; state .svc - .resolve_message(&body.capability_id, body.actor_id) + .resolve_message(&body.capability_id, auth.actor_id) .map(Json) .map_err(map_err) } -async fn handle_get_message( - State(state): State, - Path(id): Path, -) -> Result, (StatusCode, Json)> { - state.svc.get_message_object(&id).map(Json).map_err(map_err) +async fn handle_version() -> Json { + Json(VersionResponse { + api_version: API_VERSION, + wire_version: 1, + spec_version: "1.0.1".into(), + supported_versions: vec![API_VERSION], + }) } // ── Router ──────────────────────────────────────────────────────────────────── @@ -244,6 +349,7 @@ async fn handle_get_message( pub fn router(svc: Service) -> Router { let state = AppState { svc: Arc::new(svc) }; Router::new() + .route("/v1/version", get(handle_version)) .route("/v1/credentials", post(handle_issue)) .route("/v1/credentials/verify", post(handle_verify)) .route("/v1/credentials/consume", post(handle_consume)) @@ -251,7 +357,6 @@ pub fn router(svc: Service) -> Router { .route("/v1/credentials/:id/revoke", post(handle_revoke)) .route("/v1/messages", post(handle_store_message)) .route("/v1/messages/resolve", post(handle_resolve_message)) - .route("/v1/messages/:id", get(handle_get_message)) .with_state(state) } @@ -341,7 +446,10 @@ mod tests { let issue: IssueResponse = serde_json::from_slice(&body).unwrap(); // Verify - let verify_body = serde_json::json!({ "token_id": issue.token_id }); + let verify_body = serde_json::json!({ + "token_id": issue.token_id, + "requested_scope": "download:asset:42" + }); let req = Request::builder() .uri("/v1/credentials/verify") .method("POST") @@ -354,7 +462,8 @@ mod tests { // Consume #1 let consume_body = serde_json::json!({ "token_id": issue.token_id, - "idempotency_key": "idem-1" + "idempotency_key": "idem-1", + "requested_scope": "download:asset:42" }); let req = Request::builder() .uri("/v1/credentials/consume") @@ -374,7 +483,8 @@ mod tests { // Consume #2 (different idempotency) let consume_body2 = serde_json::json!({ "token_id": issue.token_id, - "idempotency_key": "idem-2" + "idempotency_key": "idem-2", + "requested_scope": "download:asset:42" }); let req = Request::builder() .uri("/v1/credentials/consume") @@ -388,7 +498,8 @@ mod tests { // Consume #3 should exhaust (max_uses=2) let consume_body3 = serde_json::json!({ "token_id": issue.token_id, - "idempotency_key": "idem-3" + "idempotency_key": "idem-3", + "requested_scope": "download:asset:42" }); let req = Request::builder() .uri("/v1/credentials/consume") @@ -397,12 +508,13 @@ mod tests { .body(Body::from(consume_body3.to_string())) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(resp.status(), StatusCode::CONFLICT); // Get credential let req = Request::builder() .uri(format!("/v1/credentials/{}", issue.credential_id)) .method("GET") + .header("x-capglyph-token", &issue.token_id) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); @@ -413,7 +525,9 @@ mod tests { .uri(format!("/v1/credentials/{}/revoke", issue.credential_id)) .method("POST") .header("content-type", "application/json") - .body(Body::from("{}")) + .body(Body::from( + serde_json::json!({ "token_id": issue.token_id }).to_string(), + )) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); // First revoke should succeed 200 diff --git a/crates/capglyph-server/src/models.rs b/crates/capglyph-server/src/models.rs index 59a56d9..5f29592 100644 --- a/crates/capglyph-server/src/models.rs +++ b/crates/capglyph-server/src/models.rs @@ -2,6 +2,26 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +pub const API_VERSION: u8 = 1; + +/// Authorization data supplied by trusted ingress after caller authentication. +/// +/// Possession of a credential token proves identity evidence only. It does not +/// bypass the subject or scope checks represented by this context. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthorizationContext { + pub actor_id: Option, + pub requested_scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VersionResponse { + pub api_version: u8, + pub wire_version: u8, + pub spec_version: String, + pub supported_versions: Vec, +} + // ── Covers ──────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -106,6 +126,7 @@ pub struct NewAuditEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VerifyRequest { pub token_id: String, // base64url or hex 32 chars + pub requested_scope: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -123,6 +144,7 @@ pub struct ConsumeRequest { pub token_id: String, pub idempotency_key: String, pub actor_id: Option, + pub requested_scope: Option, pub request_hash: Option>, } @@ -137,6 +159,7 @@ pub struct ConsumeResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RevokeRequest { pub actor_id: Option, + pub token_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/capglyph-server/src/service.rs b/crates/capglyph-server/src/service.rs index e7bd539..86f2df0 100644 --- a/crates/capglyph-server/src/service.rs +++ b/crates/capglyph-server/src/service.rs @@ -1,16 +1,17 @@ use base64::Engine as _; use chrono::Utc; -use hmac::KeyInit; use rand::RngCore; use sha2::{Digest, Sha256}; use uuid::Uuid; +use capglyph_core::keying::KeyMaterial; + use crate::db::Db; use crate::error::{Result, ServerError}; use crate::models::{ capability_id_to_base64url, parse_capability_id, parse_token_id, sha256, token_id_to_base64url, - Credential, IssueRequest, IssueResponse, MessageObject, NewCover, NewCredential, - NewMessageObject, ResolveMessageResponse, StoreMessageResponse, + AuthorizationContext, Credential, IssueRequest, IssueResponse, MessageObject, NewCover, + NewCredential, NewMessageObject, ResolveMessageResponse, StoreMessageResponse, }; /// High-level service that wraps Db + KMS derivation + carrier framing. @@ -47,33 +48,14 @@ impl Kms { self.master_by_key_id .get(key_id) .copied() - .ok_or_else(|| ServerError::NotFound(format!("key_id not found: {}", key_id))) + .filter(|key| *key != [0u8; 32]) + .ok_or_else(|| ServerError::KeyNotFound(key_id.to_string())) } - /// Derive K_mac and K_embed from master via HMAC-SHA256 domain separation. - /// Matches `sigil-core-api.md` §4.4 `KeyMaterial` but simplified. - pub fn derive( - &self, - key_id: &str, - cover_id: &Uuid, - token_id: &[u8; 16], - ) -> Result<([u8; 32], [u8; 32])> { + /// Derive the three v1 role keys with RFC 5869 HKDF-SHA256. + pub fn derive(&self, key_id: &str, cover_id: &Uuid) -> Result { let master = self.get_master(key_id)?; - let k_mac = Self::prf(&master, b"capglyph-k-mac-v1", cover_id, token_id); - let k_embed = Self::prf(&master, b"capglyph-k-embed-v1", cover_id, token_id); - Ok((k_mac, k_embed)) - } - - fn prf(master: &[u8; 32], domain: &[u8], cover_id: &Uuid, token_id: &[u8; 16]) -> [u8; 32] { - use hmac::{Hmac, Mac}; - let mut mac = as KeyInit>::new_from_slice(master).unwrap(); - mac.update(domain); - mac.update(cover_id.as_bytes()); - mac.update(token_id); - let out = mac.finalize().into_bytes(); - let mut arr = [0u8; 32]; - arr.copy_from_slice(&out); - arr + KeyMaterial::from_ikm_v1(&master, cover_id.as_bytes()).map_err(ServerError::from) } } @@ -130,38 +112,38 @@ impl Service { .ok_or_else(|| ServerError::NotFound(format!("cover not found: {}", cover_id)))?; let key_id = req.key_id.clone().unwrap_or_else(|| "default".to_string()); - // Ensure KMS has this key, or generate - if self.kms.get_master(&key_id).is_err() { - // Auto-generate for MVP demo; real server would error - // We can't mutate self.kms here (clone), so we just derive with a zero key? - // Instead, treat missing key as 32 zero bytes for derivation (deterministic). + // Resolve trusted key context before generating or persisting any token. + // Unknown keys are a hard failure; there is no process-default or zero-key path. + let keys = self.kms.derive(&key_id, &cover.id)?; + + Self::validate_scope_shape(&req.scope)?; + if matches!(req.max_uses, Some(max) if max <= 0) { + return Err(ServerError::Conflict( + "max_uses must be greater than zero".into(), + )); + } + if let (Some(not_before), Some(expires_at)) = (req.not_before, req.expires_at) { + if not_before >= expires_at { + return Err(ServerError::Conflict( + "not_before must precede expires_at".into(), + )); + } } // Generate token_id (CSPRNG 128-bit) let mut token_id = [0u8; 16]; rand::thread_rng().fill_bytes(&mut token_id); - // Derive K_mac/K_embed (if key_id missing, use zero master) - let (k_mac, _k_embed) = match self.kms.derive(&key_id, &cover.id, &token_id) { - Ok(v) => v, - Err(_) => { - let zero = [0u8; 32]; - let k_mac = Kms::prf(&zero, b"capglyph-k-mac-v1", &cover.id, &token_id); - let k_embed = Kms::prf(&zero, b"capglyph-k-embed-v1", &cover.id, &token_id); - (k_mac, k_embed) - } - }; - - // Carrier framing: seal token_id via capglyph_core::framing - // This demonstrates carrier integration without needing an image. + // Carrier framing: seal the exact v1 Credential payload `{0: bstr16}`. let sealed = { - use capglyph_core::framing::{seal, Params, PayloadType}; + use capglyph_core::framing::{credential_payload, try_seal_typed, Params, PayloadType}; let params = Params { version: 1, payload_type: PayloadType::Credential, flags: 0, }; - seal(&token_id, ¶ms, &k_mac) + let payload = credential_payload(&token_id); + try_seal_typed(&payload, ¶ms, keys.k_mac())? }; // ECC encode (demonstrates interleave + soft-bits stack) @@ -212,6 +194,19 @@ impl Service { // ── Verify (read-only) ──────────────────────────────────────────────────── pub fn verify(&self, token_id_str: &str) -> Result { + self.verify_authorized(token_id_str, &AuthorizationContext::default()) + } + + /// Verify identity evidence, then enforce subject and scope authorization. + /// + /// The token locates and authenticates a credential record. Authorization is + /// a distinct policy decision and requires trusted caller context whenever a + /// credential is subject-bound or carries non-empty scopes. + pub fn verify_authorized( + &self, + token_id_str: &str, + auth: &AuthorizationContext, + ) -> Result { let token_id = parse_token_id(token_id_str).map_err(|_e| ServerError::InvalidToken)?; let token_hash = sha256(&token_id); let cred = self @@ -219,25 +214,22 @@ impl Service { .get_credential_by_token_hash(&token_hash)? .ok_or_else(|| ServerError::NotFound("credential not found".into()))?; - // Check state without mutating + // Resolve KMS context and exercise the canonical authenticated envelope + // before any application policy or authorization decision. + self.verify_framing(&cred, &token_id)?; + + // Check state without mutating. let status = Self::credential_status(&cred); if status != "valid" { - // Map status to error but still return response for HTTP 200 with status field? - // For service layer we return error to let HTTP map to 400/403. match status.as_str() { "revoked" => return Err(ServerError::Revoked), - "expired" => return Err(ServerError::Expired), + "expired" | "not_yet_valid" => return Err(ServerError::Expired), "exhausted" => return Err(ServerError::Exhausted), _ => {} } } - // Optionally verify framing MAC (demonstrates carrier integration) - // We derive K_mac and try to open the sealed frame that would have been - // embedded. Since we don't have the image, we reconstruct the sealed - // payload from token_id and verify it matches expected framing. - // This is a no-op for DB-only verify, but shows the code path. - let _ = self.verify_framing(&cred, &token_id); + Self::authorize_credential(&cred, auth)?; Ok(crate::models::VerifyResponse { credential_id: cred.id, @@ -273,27 +265,91 @@ impl Service { } fn verify_framing(&self, cred: &Credential, token_id: &[u8; 16]) -> Result<()> { - // Re-derive K_mac and verify that `seal(token_id)` opens correctly. - let cover_id = cred.cover_id; - let key_id = &cred.key_id; - let (k_mac, _) = match self.kms.derive(key_id, &cover_id, token_id) { - Ok(v) => v, - Err(_) => return Ok(()), // if KMS missing, skip check (MVP) + let keys = self.kms.derive(&cred.key_id, &cred.cover_id)?; + use capglyph_core::framing::{ + credential_payload, credential_token_id, open_typed, try_seal_typed, Params, + PayloadType, }; - use capglyph_core::framing::{open, Params, PayloadType}; let params = Params { version: 1, payload_type: PayloadType::Credential, flags: 0, }; - let sealed = capglyph_core::framing::seal(token_id, ¶ms, &k_mac); - let (_hdr, payload) = open(&sealed, &k_mac).map_err(|_| ServerError::InvalidToken)?; - if payload != token_id { + let payload = credential_payload(token_id); + let sealed = try_seal_typed(&payload, ¶ms, keys.k_mac())?; + let (header, opened) = open_typed(&sealed, keys.k_mac())?; + if credential_token_id(&opened, header.flags)? != *token_id { return Err(ServerError::InvalidToken); } Ok(()) } + fn validate_scope_shape(scope: &serde_json::Value) -> Result<()> { + let values = scope.as_array().ok_or_else(|| { + ServerError::Conflict("scope must be an array of non-empty strings".into()) + })?; + if values + .iter() + .any(|value| value.as_str().is_none_or(str::is_empty)) + { + return Err(ServerError::Conflict( + "scope must be an array of non-empty strings".into(), + )); + } + Ok(()) + } + + fn authorize_credential(cred: &Credential, auth: &AuthorizationContext) -> Result<()> { + if let Some(subject_id) = cred.subject_id { + if auth.actor_id != Some(subject_id) { + return Err(ServerError::Unauthorized( + "credential is bound to another subject".into(), + )); + } + } + + Self::validate_scope_shape(&cred.scope)?; + let scopes = cred.scope.as_array().expect("validated scope array"); + if scopes.is_empty() { + if auth.requested_scope.is_some() { + return Err(ServerError::ScopeDenied); + } + return Ok(()); + } + let requested = auth + .requested_scope + .as_deref() + .ok_or(ServerError::ScopeDenied)?; + if !scopes.iter().any(|scope| scope.as_str() == Some(requested)) { + return Err(ServerError::ScopeDenied); + } + Ok(()) + } + + /// Hash all semantic consume inputs. Callers must not supply this hash. + pub fn consume_request_hash(token_id_str: &str, auth: &AuthorizationContext) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"capglyph/consume-request/v1\0"); + hasher.update((token_id_str.len() as u64).to_be_bytes()); + hasher.update(token_id_str.as_bytes()); + match auth.actor_id { + Some(actor) => { + hasher.update([1]); + hasher.update(actor.as_bytes()); + } + None => hasher.update([0]), + } + match auth.requested_scope.as_deref() { + Some(scope) => { + hasher.update([1]); + hasher.update((scope.len() as u64).to_be_bytes()); + hasher.update(scope.as_bytes()); + } + None => hasher.update([0]), + } + hasher.finalize().into() + } + // ── Consume (atomic) ────────────────────────────────────────────────────── pub fn consume( @@ -302,28 +358,51 @@ impl Service { idempotency_key: &str, actor_id: Option, ) -> Result { - let token_id = parse_token_id(token_id_str).map_err(|_| ServerError::InvalidToken)?; - let token_hash = sha256(&token_id); + let auth = AuthorizationContext { + actor_id, + requested_scope: None, + }; + self.consume_authorized(token_id_str, idempotency_key, &auth) + } - // Verify framing MAC before touching DB (fail-closed if MAC fails) - // We need credential to get cover_id/key_id for K_mac derivation. - // So first fetch credential (read-only) to derive K_mac, verify, then atomic consume. - if let Some(cred) = self.db.get_credential_by_token_hash(&token_hash)? { - let _ = self.verify_framing(&cred, &token_id); + pub fn consume_authorized( + &self, + token_id_str: &str, + idempotency_key: &str, + auth: &AuthorizationContext, + ) -> Result { + if idempotency_key.trim().is_empty() { + return Err(ServerError::Conflict( + "idempotency key must not be empty".into(), + )); } + let token_id = parse_token_id(token_id_str).map_err(|_| ServerError::InvalidToken)?; + let token_hash = sha256(&token_id); let cred = self .db - .consume(&token_hash, idempotency_key, actor_id, None)?; + .get_credential_by_token_hash(&token_hash)? + .ok_or_else(|| ServerError::NotFound("credential not found".into()))?; + self.verify_framing(&cred, &token_id)?; + Self::authorize_credential(&cred, auth)?; + + // Bind idempotency to the semantic request on the trusted side of the + // API. Callers cannot supply a hash that disguises another token, + // actor, or requested scope. + let request_hash = Self::consume_request_hash(token_id_str, auth); + let (cred, replayed) = + self.db + .consume(&token_hash, idempotency_key, auth.actor_id, &request_hash)?; - // Check if this was an idempotent replay: if use_count didn't increase relative to - // previous? For MVP we treat replay as success with same use_count. - // To detect replay, we could query consumptions, but we just return. Ok(crate::models::ConsumeResponse { credential_id: cred.id, use_count: cred.use_count, max_uses: cred.max_uses, - outcome: "consumed".into(), + outcome: if replayed { + "idempotent_replay".into() + } else { + "consumed".into() + }, }) } @@ -333,12 +412,60 @@ impl Service { self.db.revoke(credential_id, actor_id) } + pub fn revoke_authorized( + &self, + credential_id: &Uuid, + auth: &AuthorizationContext, + token_id: Option<&str>, + ) -> Result { + let cred = self.get(credential_id)?; + Self::authorize_subject_or_token(&cred, auth, token_id)?; + self.db.revoke(credential_id, auth.actor_id) + } + pub fn get(&self, credential_id: &Uuid) -> Result { self.db .get_credential(credential_id)? .ok_or_else(|| ServerError::NotFound(format!("credential {}", credential_id))) } + pub fn get_authorized( + &self, + credential_id: &Uuid, + auth: &AuthorizationContext, + token_id: Option<&str>, + ) -> Result { + let cred = self.get(credential_id)?; + Self::authorize_subject_or_token(&cred, auth, token_id)?; + Ok(cred) + } + + fn authorize_subject_or_token( + cred: &Credential, + auth: &AuthorizationContext, + token_id: Option<&str>, + ) -> Result<()> { + if let Some(subject_id) = cred.subject_id { + return if auth.actor_id == Some(subject_id) { + Ok(()) + } else { + Err(ServerError::Unauthorized( + "credential is bound to another subject".into(), + )) + }; + } + + let token_id = token_id.ok_or_else(|| { + ServerError::Unauthorized("bearer credential requires token proof".into()) + })?; + let token_id = parse_token_id(token_id) + .map_err(|_| ServerError::Unauthorized("invalid token proof".into()))?; + if sha256(&token_id) != cred.token_hash { + return Err(ServerError::Unauthorized("invalid token proof".into())); + } + Ok(()) + } + // ── Image-based verify/consume (carrier integration stub) ───────────────── /// Verify from raw image bytes using original-assisted extraction. @@ -368,7 +495,7 @@ impl Service { key: &[u8; 32], nonce_bytes: &[u8; 12], ) -> Result<(Vec, Vec)> { - use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, Nonce}; + use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, KeyInit, Nonce}; let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let nonce = Nonce::from_slice(nonce_bytes); let combined = cipher @@ -389,7 +516,7 @@ impl Service { key: &[u8; 32], nonce_bytes: &[u8; 12], ) -> Result> { - use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, Nonce}; + use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, KeyInit, Nonce}; let mut combined = Vec::with_capacity(ciphertext.len() + tag.len()); combined.extend_from_slice(ciphertext); combined.extend_from_slice(tag); @@ -540,7 +667,7 @@ impl Service { /// Direct object lookup by object_id (for offline pointer: object_id + content_key in carrier). /// Still requires authorization check via policy. - pub fn get_message_object(&self, object_id: &Uuid) -> Result { + fn get_message_object(&self, object_id: &Uuid) -> Result { self.db .get_message_object(object_id)? .ok_or_else(|| ServerError::NotFound(format!("message object {}", object_id))) diff --git a/crates/capglyph-server/tests/concurrent_consume.rs b/crates/capglyph-server/tests/concurrent_consume.rs index a2c19c6..c8e6574 100644 --- a/crates/capglyph-server/tests/concurrent_consume.rs +++ b/crates/capglyph-server/tests/concurrent_consume.rs @@ -48,7 +48,7 @@ fn setup_service_with_credential(max_uses: Option) -> (Service, String, Uui .create_credential(NewCredential { cover_id: cover.id, subject_id: None, - scope: json!(["download:asset:42"]), + scope: json!([]), mode: "dct".into(), schema_version: 1, key_id: "default".into(), diff --git a/crates/capglyph-server/tests/security_contract.rs b/crates/capglyph-server/tests/security_contract.rs new file mode 100644 index 0000000..5a6191e --- /dev/null +++ b/crates/capglyph-server/tests/security_contract.rs @@ -0,0 +1,576 @@ +use axum::{ + body::{to_bytes, Body}, + http::{Method, Request, StatusCode}, + Router, +}; +use base64::Engine as _; +use capglyph_core::{ + framing::{credential_payload, try_seal_typed, Params, PayloadType}, + keying::KeyMaterial, +}; +use capglyph_server::{ + models::{ + parse_token_id, AuthorizationContext, IssueRequest, IssueResponse, NewCover, + StoreMessageResponse, + }, + router, Db, Kms, ServerError, Service, +}; +use chrono::{Duration, Utc}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tower::ServiceExt; +use uuid::Uuid; + +const MASTER: [u8; 32] = [0x42; 32]; + +fn test_service() -> (Service, Uuid) { + let db = Db::new_in_memory_shared().expect("in-memory database"); + let kms = Kms::new().with_key("default", MASTER); + let svc = Service::new(db, kms); + let cover = svc + .db + .create_cover(NewCover { + sha256: vec![0x41; 32], + object_uri: "memory://security-contract-cover".into(), + width: 512, + height: 512, + format: "png".into(), + family_id: None, + status: "active".into(), + }) + .expect("cover"); + (svc, cover.id) +} + +fn issue( + svc: &Service, + cover_id: Uuid, + subject_id: Option, + scope: Value, + max_uses: Option, + not_before: Option>, + expires_at: Option>, +) -> IssueResponse { + svc.issue(IssueRequest { + cover_id, + scope, + mode: Some("dct".into()), + subject_id, + max_uses, + expires_at, + not_before, + key_id: Some("default".into()), + embed_params: None, + }) + .expect("issue credential") +} + +fn json_request( + method: Method, + uri: impl AsRef, + body: Value, + headers: &[(&str, String)], +) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(uri.as_ref()) + .header("content-type", "application/json"); + for (name, value) in headers { + builder = builder.header(*name, value); + } + builder.body(Body::from(body.to_string())).expect("request") +} + +async fn response_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body"); + serde_json::from_slice(&bytes).expect("JSON response") +} + +async fn post_issue(app: &Router, cover_id: Uuid, body: Value) -> IssueResponse { + let mut body = body; + body["cover_id"] = json!(cover_id); + let response = app + .clone() + .oneshot(json_request(Method::POST, "/v1/credentials", body, &[])) + .await + .expect("issue response"); + assert_eq!(response.status(), StatusCode::OK); + serde_json::from_value(response_json(response).await).expect("issue body") +} + +#[test] +fn kms_rejects_missing_and_zero_keys_and_uses_the_normative_schedule() { + let cover_id = Uuid::from_bytes([0x24; 16]); + let missing = Kms::new().derive("missing", &cover_id).unwrap_err(); + assert!(matches!(missing, ServerError::KeyNotFound(_))); + + let zero = Kms::new() + .with_key("zero", [0; 32]) + .derive("zero", &cover_id) + .unwrap_err(); + assert!(matches!(zero, ServerError::KeyNotFound(_))); + + let derived = Kms::new() + .with_key("known", MASTER) + .derive("known", &cover_id) + .expect("derive configured key"); + let expected = KeyMaterial::from_ikm_v1(&MASTER, cover_id.as_bytes()) + .expect("normative RFC 5869 schedule"); + assert_eq!(derived.k_embed(), expected.k_embed()); + assert_eq!(derived.k_mac(), expected.k_mac()); + assert_eq!(derived.k_object(), expected.k_object()); +} + +#[test] +fn issue_fails_on_unknown_key_and_seals_the_exact_credential_payload() { + let (svc, cover_id) = test_service(); + let unknown = svc.issue(IssueRequest { + cover_id, + scope: json!([]), + mode: None, + subject_id: None, + max_uses: None, + expires_at: None, + not_before: None, + key_id: Some("missing".into()), + embed_params: None, + }); + assert!(matches!(unknown, Err(ServerError::KeyNotFound(_)))); + + let issued = issue(&svc, cover_id, None, json!([]), None, None, None); + let token = parse_token_id(&issued.token_id).expect("token"); + let payload = credential_payload(&token); + assert_eq!(payload.len(), 19); + assert_eq!(&payload[..3], &[0xa1, 0x00, 0x50]); + + let params = Params { + version: 1, + payload_type: PayloadType::Credential, + flags: 0, + }; + let keys = svc.kms.derive("default", &cover_id).unwrap(); + let sealed = try_seal_typed(&payload, ¶ms, keys.k_mac()).unwrap(); + let expected_output_hash = Sha256::digest(sealed).to_vec(); + let stored = svc + .db + .get_credential(&issued.credential_id) + .unwrap() + .unwrap(); + assert_eq!(stored.output_sha256, expected_output_hash); +} + +#[test] +fn token_evidence_does_not_bypass_subject_or_scope_authorization() { + let (svc, cover_id) = test_service(); + let subject = Uuid::new_v4(); + let other = Uuid::new_v4(); + let issued = issue( + &svc, + cover_id, + Some(subject), + json!(["asset:read"]), + Some(2), + None, + None, + ); + + let wrong_actor = AuthorizationContext { + actor_id: Some(other), + requested_scope: Some("asset:read".into()), + }; + assert!(matches!( + svc.verify_authorized(&issued.token_id, &wrong_actor), + Err(ServerError::Unauthorized(_)) + )); + + let missing_scope = AuthorizationContext { + actor_id: Some(subject), + requested_scope: None, + }; + assert!(matches!( + svc.consume_authorized(&issued.token_id, "denied", &missing_scope), + Err(ServerError::ScopeDenied) + )); + + let stored = svc + .db + .get_credential(&issued.credential_id) + .unwrap() + .unwrap(); + assert_eq!( + stored.use_count, 0, + "authorization failure must not burn quota" + ); + + let allowed = AuthorizationContext { + actor_id: Some(subject), + requested_scope: Some("asset:read".into()), + }; + assert_eq!( + svc.verify_authorized(&issued.token_id, &allowed) + .unwrap() + .status, + "valid" + ); + assert_eq!( + svc.consume_authorized(&issued.token_id, "allowed", &allowed) + .unwrap() + .use_count, + 1 + ); +} + +#[test] +fn idempotency_replays_exact_requests_and_rejects_semantic_collisions() { + let (svc, cover_id) = test_service(); + let issued = issue( + &svc, + cover_id, + None, + json!(["asset:read", "asset:write"]), + Some(5), + None, + None, + ); + let read = AuthorizationContext { + actor_id: None, + requested_scope: Some("asset:read".into()), + }; + let first = svc + .consume_authorized(&issued.token_id, "same-key", &read) + .unwrap(); + assert_eq!(first.outcome, "consumed"); + let replay = svc + .consume_authorized(&issued.token_id, "same-key", &read) + .unwrap(); + assert_eq!(replay.outcome, "idempotent_replay"); + assert_eq!(replay.use_count, 1); + + let write = AuthorizationContext { + actor_id: None, + requested_scope: Some("asset:write".into()), + }; + assert!(matches!( + svc.consume_authorized(&issued.token_id, "same-key", &write), + Err(ServerError::Conflict(_)) + )); + + let different_actor = AuthorizationContext { + actor_id: Some(Uuid::new_v4()), + requested_scope: Some("asset:read".into()), + }; + assert!(matches!( + svc.consume_authorized(&issued.token_id, "same-key", &different_actor), + Err(ServerError::Conflict(_)) + )); + let stored = svc + .db + .get_credential(&issued.credential_id) + .unwrap() + .unwrap(); + assert_eq!(stored.use_count, 1); +} + +#[test] +fn temporal_revocation_and_quota_checks_are_fail_closed() { + let (svc, cover_id) = test_service(); + let now = Utc::now(); + + let future = issue( + &svc, + cover_id, + None, + json!([]), + Some(1), + Some(now + Duration::hours(1)), + Some(now + Duration::hours(2)), + ); + assert!(matches!( + svc.verify(&future.token_id), + Err(ServerError::Expired) + )); + assert!(matches!( + svc.consume(&future.token_id, "future", None), + Err(ServerError::Expired) + )); + + let expired = issue( + &svc, + cover_id, + None, + json!([]), + Some(1), + None, + Some(now - Duration::hours(1)), + ); + assert!(matches!( + svc.verify(&expired.token_id), + Err(ServerError::Expired) + )); + + let revoked = issue(&svc, cover_id, None, json!([]), Some(1), None, None); + svc.revoke_authorized( + &revoked.credential_id, + &AuthorizationContext::default(), + Some(&revoked.token_id), + ) + .unwrap(); + assert!(matches!( + svc.consume(&revoked.token_id, "revoked", None), + Err(ServerError::Revoked) + )); + + let quota = issue(&svc, cover_id, None, json!([]), Some(1), None, None); + svc.consume("a.token_id, "quota-1", None).unwrap(); + assert!(matches!( + svc.consume("a.token_id, "quota-2", None), + Err(ServerError::Exhausted) + )); +} + +#[tokio::test] +async fn api_is_versioned_and_returns_stable_error_envelopes() { + let (svc, cover_id) = test_service(); + let app = router(svc); + + let version = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/version") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(version.status(), StatusCode::OK); + let version = response_json(version).await; + assert_eq!(version["api_version"], 1); + assert_eq!(version["wire_version"], 1); + assert_eq!(version["spec_version"], "1.0.1"); + + let unversioned = app + .clone() + .oneshot( + Request::builder() + .uri("/version") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unversioned.status(), StatusCode::NOT_FOUND); + + let unsupported = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/credentials/verify", + json!({"token_id": "invalid"}), + &[("accept-capglyph-version", "2".into())], + )) + .await + .unwrap(); + assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST); + let unsupported = response_json(unsupported).await; + assert_eq!(unsupported["error"], "VERSION_UNSUPPORTED"); + assert_eq!(unsupported["code"], "E_VERSION_UNSUPPORTED"); + assert_eq!(unsupported["supported_versions"], json!([1])); + + let invalid = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/credentials/verify", + json!({"token_id": "invalid"}), + &[], + )) + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::UNPROCESSABLE_ENTITY); + let invalid = response_json(invalid).await; + assert_eq!(invalid["error"], "PAYLOAD_INVALID"); + assert_eq!(invalid["code"], "E_PAYLOAD_INVALID"); + + let missing_key = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/credentials", + json!({"cover_id": cover_id, "scope": [], "key_id": "missing"}), + &[], + )) + .await + .unwrap(); + assert_eq!(missing_key.status(), StatusCode::NOT_FOUND); + let missing_key = response_json(missing_key).await; + assert_eq!(missing_key["error"], "KEY_NOT_FOUND"); + assert_eq!(missing_key["code"], "E_KEY_NOT_FOUND"); +} + +#[tokio::test] +async fn credential_http_policy_requires_ingress_identity_and_token_proof() { + let (svc, cover_id) = test_service(); + let app = router(svc); + let subject = Uuid::new_v4(); + let issued = post_issue( + &app, + cover_id, + json!({"subject_id": subject, "scope": ["asset:read"], "max_uses": 1}), + ) + .await; + + // A body actor is merely a claim. Without matching trusted-ingress + // evidence it is rejected before authorization. + let self_asserted = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/credentials/consume", + json!({ + "token_id": issued.token_id, + "idempotency_key": "self-asserted", + "actor_id": subject, + "requested_scope": "asset:read" + }), + &[], + )) + .await + .unwrap(); + assert_eq!(self_asserted.status(), StatusCode::FORBIDDEN); + assert_eq!(response_json(self_asserted).await["code"], "E_UNAUTHORIZED"); + + let denied_scope = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/credentials/verify", + json!({"token_id": issued.token_id}), + &[("x-capglyph-actor-id", subject.to_string())], + )) + .await + .unwrap(); + assert_eq!(denied_scope.status(), StatusCode::FORBIDDEN); + assert_eq!(response_json(denied_scope).await["code"], "E_SCOPE_DENIED"); + + let allowed = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/credentials/consume", + json!({ + "token_id": issued.token_id, + "idempotency_key": "allowed", + "requested_scope": "asset:read" + }), + &[("x-capglyph-actor-id", subject.to_string())], + )) + .await + .unwrap(); + assert_eq!(allowed.status(), StatusCode::OK); + + let exhausted = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/credentials/consume", + json!({ + "token_id": issued.token_id, + "idempotency_key": "second", + "requested_scope": "asset:read" + }), + &[("x-capglyph-actor-id", subject.to_string())], + )) + .await + .unwrap(); + assert_eq!(exhausted.status(), StatusCode::CONFLICT); + assert_eq!(response_json(exhausted).await["code"], "E_CONSUMED"); + + let get_without_proof = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/credentials/{}", issued.credential_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(get_without_proof.status(), StatusCode::FORBIDDEN); + + let get_with_actor = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/credentials/{}", issued.credential_id)) + .header("x-capglyph-actor-id", subject.to_string()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(get_with_actor.status(), StatusCode::OK); +} + +#[tokio::test] +async fn message_objects_are_readable_only_through_the_capability_route() { + let (svc, _cover_id) = test_service(); + let app = router(svc); + let plaintext = base64::engine::general_purpose::STANDARD.encode(b"secret message"); + let stored = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/messages", + json!({"plaintext_base64": plaintext, "policy": {}}), + &[], + )) + .await + .unwrap(); + assert_eq!(stored.status(), StatusCode::OK); + let stored: StoreMessageResponse = serde_json::from_value(response_json(stored).await).unwrap(); + + let by_object_id = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/messages/{}", stored.object_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(by_object_id.status(), StatusCode::NOT_FOUND); + + let wrong_capability = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x99; 16]); + let wrong = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/messages/resolve", + json!({"capability_id": wrong_capability}), + &[], + )) + .await + .unwrap(); + assert_eq!(wrong.status(), StatusCode::NOT_FOUND); + assert_eq!(response_json(wrong).await["code"], "E_NOT_FOUND"); + + let resolved = app + .clone() + .oneshot(json_request( + Method::POST, + "/v1/messages/resolve", + json!({"capability_id": stored.capability_id}), + &[], + )) + .await + .unwrap(); + assert_eq!(resolved.status(), StatusCode::OK); + let resolved = response_json(resolved).await; + assert_eq!(resolved["object_id"], stored.object_id.to_string()); + assert!(resolved.get("ciphertext_base64").is_some()); + assert!(resolved.get("content_key").is_none()); +} From e02ae8d91c0a94f8c55164232a397ad1aa8c60f2 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Fri, 4 Sep 2026 21:05:11 +0800 Subject: [PATCH 3/5] feat(ci): derive packaging version from tag and fix arch mapping (CGCLI-0005) --- .github/workflows/ci.yml | 24 ++ .github/workflows/release.yml | 36 +-- nfpm.yaml | 13 +- scripts/package-linux.sh | 74 ++++++ scripts/test-package-metadata.sh | 135 ++++++++++ scripts/verify-package-metadata.py | 393 +++++++++++++++++++++++++++++ 6 files changed, 655 insertions(+), 20 deletions(-) create mode 100755 scripts/package-linux.sh create mode 100755 scripts/test-package-metadata.sh create mode 100755 scripts/verify-package-metadata.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e0656f..00bfe56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,30 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + package-metadata: + name: Native package metadata + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: capglyph-cli + + - name: Install nFPM + working-directory: capglyph-cli + run: | + set -euo pipefail + mkdir -p ../recording/package-audit/tooling + nfpm_releases=https://github.com/goreleaser/nfpm/releases + curl --fail --silent --show-error --location \ + "$nfpm_releases/download/v2.37.1/nfpm_2.37.1_Linux_x86_64.tar.gz" \ + | tar -xz -C ../recording/package-audit/tooling nfpm + + - name: Build and inspect DEB, RPM, and Arch metadata + working-directory: capglyph-cli + env: + NFPM: ${{ github.workspace }}/recording/package-audit/tooling/nfpm + run: ./scripts/test-package-metadata.sh ../recording/package-audit/ci + test: name: Test & Lint runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86ed170..1eaa805 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,12 +24,12 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu artifact_name: capglyph-linux-x86_64 - arch: amd64 + arch: x86_64 os_name: linux - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu artifact_name: capglyph-linux-aarch64 - arch: arm64 + arch: aarch64 os_name: linux - os: windows-latest target: x86_64-pc-windows-msvc @@ -101,19 +101,23 @@ jobs: - name: Package for Linux distributions (nfpm) if: matrix.os_name == 'linux' run: | - if [ "${{ matrix.arch }}" = "arm64" ]; then - NFPM_ARCH="arm64" - PKG_ARCH="aarch64" + set -euo pipefail + if [ "${{ matrix.arch }}" = "aarch64" ]; then + nfpm_download_arch=arm64 else - NFPM_ARCH="x86_64" - PKG_ARCH="x86_64" + nfpm_download_arch=x86_64 fi - curl -sL https://github.com/goreleaser/nfpm/releases/download/v2.37.1/nfpm_2.37.1_Linux_${NFPM_ARCH}.tar.gz | tar xz nfpm - sed -i "s/amd64/${NFPM_ARCH}/g" nfpm.yaml - sed -i "s/capglyph-linux-x86_64/${{ matrix.artifact_name }}/g" nfpm.yaml - ./nfpm pkg --packager deb --target capglyph-linux-${PKG_ARCH}.deb - ./nfpm pkg --packager rpm --target capglyph-linux-${PKG_ARCH}.rpm - ./nfpm pkg --packager archlinux --target capglyph-linux-${PKG_ARCH}.pkg.tar.zst + audit_dir="../recording/package-audit/native-${{ matrix.arch }}" + mkdir -p "$audit_dir/tooling" + nfpm_releases=https://github.com/goreleaser/nfpm/releases + curl --fail --silent --show-error --location \ + "$nfpm_releases/download/v2.37.1/nfpm_2.37.1_Linux_${nfpm_download_arch}.tar.gz" \ + | tar -xz -C "$audit_dir/tooling" nfpm + NFPM="$audit_dir/tooling/nfpm" ./scripts/package-linux.sh \ + "$GITHUB_REF_NAME" \ + "${{ matrix.arch }}" \ + "${{ matrix.artifact_name }}" \ + "$audit_dir/packages" - name: Upload artifact # v5 runs on the Node24 runtime; v4.x still declares node20 and @@ -124,9 +128,9 @@ jobs: path: | capglyph-cli/${{ matrix.artifact_name }}.tar.gz capglyph-cli/${{ matrix.artifact_name }}.zip - capglyph-cli/*.deb - capglyph-cli/*.rpm - capglyph-cli/*.pkg.tar.zst + recording/package-audit/native-${{ matrix.arch }}/packages/*.deb + recording/package-audit/native-${{ matrix.arch }}/packages/*.rpm + recording/package-audit/native-${{ matrix.arch }}/packages/*.pkg.tar.zst release-github: name: Create GitHub Release diff --git a/nfpm.yaml b/nfpm.yaml index 94298c8..6e6b673 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,14 +1,19 @@ name: "capglyph" -arch: "amd64" +arch: __CAPGLYPH_ARCH__ platform: "linux" -version: 0.1.0 +version: __CAPGLYPH_VERSION__ +release: "1" section: "default" priority: "optional" maintainer: "capglyph developers" description: "CapGlyph - Invisible structural watermark for images (formerly Sigil)" license: "Apache-2.0" contents: - - src: ./capglyph-linux-x86_64 + - src: __CAPGLYPH_SOURCE__ dst: /usr/bin/capglyph - - src: ./capglyph-linux-x86_64 + file_info: + mode: 0755 + - src: __CAPGLYPH_SOURCE__ dst: /usr/bin/sigil + file_info: + mode: 0755 diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..e248ee3 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: $0 " >&2 + exit 2 +} + +if [[ $# -ne 4 ]]; then + usage +fi + +release_tag=$1 +target_arch=$2 +artifact=$3 +output_dir=$4 + +script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd) +python_bin=${PYTHON:-python3} +nfpm_bin=${NFPM:-nfpm} + +case "$target_arch" in + x86_64 | aarch64) + package_arch=$target_arch + ;; + *) + echo "error: unsupported target architecture: $target_arch" >&2 + exit 2 + ;; +esac + +if [[ ! -f "$artifact" ]]; then + echo "error: package artifact does not exist: $artifact" >&2 + exit 1 +fi + +if ! command -v "$nfpm_bin" >/dev/null 2>&1; then + echo "error: nFPM executable not found: $nfpm_bin" >&2 + exit 1 +fi + +"$python_bin" "$script_dir/verify-package-metadata.py" contract \ + --tag "$release_tag" \ + --cargo-manifest "$repo_root/Cargo.toml" \ + --nfpm-config "$repo_root/nfpm.yaml" + +mkdir -p "$output_dir" +artifact=$(realpath "$artifact") +output_dir=$(realpath "$output_dir") + +deb="$output_dir/capglyph-linux-$package_arch.deb" +rpm="$output_dir/capglyph-linux-$package_arch.rpm" +arch="$output_dir/capglyph-linux-$package_arch.pkg.tar.zst" +rendered_config="$output_dir/nfpm-$package_arch.yaml" + +"$python_bin" "$script_dir/verify-package-metadata.py" render \ + --tag "$release_tag" \ + --target-arch "$target_arch" \ + --source "$artifact" \ + --cargo-manifest "$repo_root/Cargo.toml" \ + --nfpm-config "$repo_root/nfpm.yaml" \ + --output "$rendered_config" + +"$nfpm_bin" package --config "$rendered_config" --packager deb --target "$deb" +"$nfpm_bin" package --config "$rendered_config" --packager rpm --target "$rpm" +"$nfpm_bin" package --config "$rendered_config" --packager archlinux --target "$arch" + +"$python_bin" "$script_dir/verify-package-metadata.py" packages \ + --tag "$release_tag" \ + --target-arch "$target_arch" \ + --deb "$deb" \ + --rpm "$rpm" \ + --arch "$arch" diff --git a/scripts/test-package-metadata.sh b/scripts/test-package-metadata.sh new file mode 100755 index 0000000..7b7498a --- /dev/null +++ b/scripts/test-package-metadata.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd) +python_bin=${PYTHON:-python3} +audit_dir=$1 + +mkdir -p "$audit_dir" +audit_dir=$(realpath "$audit_dir") +case "$audit_dir/" in + "$repo_root/"*) + echo "error: package audit output must be outside the Git repository: $audit_dir" >&2 + exit 2 + ;; +esac + +version=$( + "$python_bin" -c \ + 'import pathlib, sys, tomllib; print(tomllib.loads(pathlib.Path(sys.argv[1]).read_text())["package"]["version"])' \ + "$repo_root/Cargo.toml" +) +tag="v$version" + +for target_arch in x86_64 aarch64; do + target_dir="$audit_dir/$target_arch" + artifact="$target_dir/capglyph-linux-$target_arch" + mkdir -p "$target_dir" + install -m 0755 /bin/true "$artifact" + "$script_dir/package-linux.sh" "$tag" "$target_arch" "$artifact" "$target_dir/packages" +done + +if "$python_bin" "$script_dir/verify-package-metadata.py" contract \ + --tag v999.0.0 \ + --cargo-manifest "$repo_root/Cargo.toml" \ + --nfpm-config "$repo_root/nfpm.yaml" \ + >"$audit_dir/cargo-tag-mismatch.log" 2>&1; then + echo "error: Cargo/tag mismatch was accepted" >&2 + exit 1 +fi + +if "$python_bin" "$script_dir/verify-package-metadata.py" contract \ + --tag 0.1.7 \ + --cargo-manifest "$repo_root/Cargo.toml" \ + --nfpm-config "$repo_root/nfpm.yaml" \ + >"$audit_dir/malformed-tag.log" 2>&1; then + echo "error: malformed release tag was accepted" >&2 + exit 1 +fi + +malformed_semver_log="$audit_dir/malformed-semver.log" +if "$python_bin" "$script_dir/verify-package-metadata.py" contract \ + --tag v1.2.3-alpha..1 \ + --cargo-manifest "$repo_root/Cargo.toml" \ + --nfpm-config "$repo_root/nfpm.yaml" \ + >"$malformed_semver_log" 2>&1; then + echo "error: malformed SemVer release tag was accepted" >&2 + exit 1 +fi +if ! grep -q "complete v-prefixed SemVer" "$malformed_semver_log"; then + echo "error: malformed SemVer did not fail at tag validation" >&2 + exit 1 +fi + +manifest_fixture="$audit_dir/manifest-mismatch" +mkdir -p "$manifest_fixture/crates/capglyph-server" +install -m 0644 "$repo_root/Cargo.toml" "$manifest_fixture/Cargo.toml" +"$python_bin" -c \ + 'import pathlib, sys; source=pathlib.Path(sys.argv[1]).read_text(); needle=f"version = \"{sys.argv[3]}\""; assert needle in source; pathlib.Path(sys.argv[2]).write_text(source.replace(needle, "version = \"0.0.0\"", 1))' \ + "$repo_root/crates/capglyph-server/Cargo.toml" \ + "$manifest_fixture/crates/capglyph-server/Cargo.toml" \ + "$version" +if "$python_bin" "$script_dir/verify-package-metadata.py" contract \ + --tag "$tag" \ + --cargo-manifest "$manifest_fixture/Cargo.toml" \ + --nfpm-config "$repo_root/nfpm.yaml" \ + >"$audit_dir/server-manifest-mismatch.log" 2>&1; then + echo "error: server Cargo/tag mismatch was accepted" >&2 + exit 1 +fi + +bad_config="$audit_dir/nfpm-static-version.yaml" +"$python_bin" -c \ + 'import pathlib, sys; source=pathlib.Path(sys.argv[1]).read_text(); pathlib.Path(sys.argv[2]).write_text(source.replace("__CAPGLYPH_VERSION__", "0.0.0"))' \ + "$repo_root/nfpm.yaml" "$bad_config" +if "$python_bin" "$script_dir/verify-package-metadata.py" contract \ + --tag "$tag" \ + --cargo-manifest "$repo_root/Cargo.toml" \ + --nfpm-config "$bad_config" \ + >"$audit_dir/nfpm-mismatch.log" 2>&1; then + echo "error: static nFPM version was accepted" >&2 + exit 1 +fi + +duplicate_config="$audit_dir/nfpm-duplicate-version.yaml" +"$python_bin" -c \ + 'import pathlib, sys; source=pathlib.Path(sys.argv[1]).read_text(); needle="version: __CAPGLYPH_VERSION__"; assert source.count(needle) == 1; pathlib.Path(sys.argv[2]).write_text(source.replace(needle, needle + "\nversion: 0.0.0"))' \ + "$repo_root/nfpm.yaml" "$duplicate_config" +if "$python_bin" "$script_dir/verify-package-metadata.py" contract \ + --tag "$tag" \ + --cargo-manifest "$repo_root/Cargo.toml" \ + --nfpm-config "$duplicate_config" \ + >"$audit_dir/nfpm-duplicate-version.log" 2>&1; then + echo "error: duplicate nFPM version field was accepted" >&2 + exit 1 +fi + +if "$python_bin" "$script_dir/verify-package-metadata.py" packages \ + --tag v999.0.0 \ + --target-arch x86_64 \ + --deb "$audit_dir/x86_64/packages/capglyph-linux-x86_64.deb" \ + --rpm "$audit_dir/x86_64/packages/capglyph-linux-x86_64.rpm" \ + --arch "$audit_dir/x86_64/packages/capglyph-linux-x86_64.pkg.tar.zst" \ + >"$audit_dir/package-version-mismatch.log" 2>&1; then + echo "error: package version mismatch was accepted" >&2 + exit 1 +fi + +if "$python_bin" "$script_dir/verify-package-metadata.py" packages \ + --tag "$tag" \ + --target-arch aarch64 \ + --deb "$audit_dir/x86_64/packages/capglyph-linux-x86_64.deb" \ + --rpm "$audit_dir/x86_64/packages/capglyph-linux-x86_64.rpm" \ + --arch "$audit_dir/x86_64/packages/capglyph-linux-x86_64.pkg.tar.zst" \ + >"$audit_dir/package-architecture-mismatch.log" 2>&1; then + echo "error: package architecture mismatch was accepted" >&2 + exit 1 +fi + +echo "package metadata integration checks passed for $tag (x86_64 and aarch64)" diff --git a/scripts/verify-package-metadata.py b/scripts/verify-package-metadata.py new file mode 100755 index 0000000..c0e4554 --- /dev/null +++ b/scripts/verify-package-metadata.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Fail-closed validation for CapGlyph native package metadata.""" + +from __future__ import annotations + +import argparse +import gzip +import io +import json +import lzma +import re +import struct +import subprocess +import sys +import tarfile +from pathlib import Path + +import tomllib + +CORE_VERSION = r"(?:0|[1-9][0-9]*)" +PRERELEASE_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +BUILD_IDENTIFIER = r"[0-9A-Za-z-]+" +TAG_RE = re.compile( + rf"^v(?P{CORE_VERSION}\.{CORE_VERSION}\.{CORE_VERSION}" + rf"(?:-{PRERELEASE_IDENTIFIER}(?:\.{PRERELEASE_IDENTIFIER})*)?" + rf"(?:\+{BUILD_IDENTIFIER}(?:\.{BUILD_IDENTIFIER})*)?)$" +) +EXPECTED_CONFIG_FIELDS = { + "arch": "__CAPGLYPH_ARCH__", + "version": "__CAPGLYPH_VERSION__", + "release": "1", +} +ARCHITECTURES = { + "x86_64": {"deb": "amd64", "rpm": "x86_64", "arch": "x86_64"}, + "aarch64": {"deb": "arm64", "rpm": "aarch64", "arch": "aarch64"}, +} + + +class MetadataError(ValueError): + """Raised when release metadata is absent, malformed, or inconsistent.""" + + +def version_from_tag(tag: str) -> str: + match = TAG_RE.fullmatch(tag) + if not match: + raise MetadataError( + f"release tag must be a complete v-prefixed SemVer: {tag!r}" + ) + return match.group("version") + + +def cargo_package_version(manifest: Path, expected_name: str) -> str: + try: + package = tomllib.loads(manifest.read_text(encoding="utf-8"))["package"] + except (OSError, KeyError, tomllib.TOMLDecodeError) as error: + raise MetadataError( + f"cannot read Cargo package metadata from {manifest}: {error}" + ) from error + if package.get("name") != expected_name: + raise MetadataError( + f"Cargo package name mismatch in {manifest}: expected {expected_name!r}, " + f"got {package.get('name')!r}" + ) + version = package.get("version") + if not isinstance(version, str) or not version: + raise MetadataError(f"Cargo package version is missing in {manifest}") + return version + + +def yaml_scalar(text: str, key: str) -> str: + matches = list( + re.finditer( + rf"(?m)^{re.escape(key)}:\s*(?:\"([^\"]*)\"|'([^']*)'|([^#\s]+))" + r"\s*(?:#.*)?$", + text, + ) + ) + if len(matches) != 1: + raise MetadataError( + f"nFPM config must contain exactly one unambiguous top-level {key!r} field; " + f"found {len(matches)}" + ) + match = matches[0] + return next(value for value in match.groups() if value is not None) + + +def validate_contract(tag: str, cargo_manifest: Path, nfpm_config: Path) -> str: + expected = version_from_tag(tag) + manifests = ( + (cargo_manifest, "capglyph"), + ( + cargo_manifest.parent / "crates/capglyph-server/Cargo.toml", + "capglyph-server", + ), + ) + for manifest, name in manifests: + actual = cargo_package_version(manifest, name) + if actual != expected: + raise MetadataError( + f"Cargo/tag version mismatch for {name}: tag={expected}, Cargo={actual}" + ) + + try: + config_text = nfpm_config.read_text(encoding="utf-8") + except OSError as error: + raise MetadataError( + f"cannot read nFPM config {nfpm_config}: {error}" + ) from error + for key, expected_value in EXPECTED_CONFIG_FIELDS.items(): + actual = yaml_scalar(config_text, key) + if actual != expected_value: + raise MetadataError( + f"nFPM config {key!r} must be {expected_value!r}, got {actual!r}" + ) + if config_text.count("__CAPGLYPH_SOURCE__") != 2: + raise MetadataError( + "nFPM config must contain exactly two controlled package sources" + ) + return expected + + +def render_config( + tag: str, + target_arch: str, + source: Path, + cargo_manifest: Path, + nfpm_config: Path, + output: Path, +) -> None: + version = validate_contract(tag, cargo_manifest, nfpm_config) + if target_arch not in ARCHITECTURES: + raise MetadataError(f"unsupported target architecture: {target_arch!r}") + if not source.is_file(): + raise MetadataError(f"package source does not exist: {source}") + text = nfpm_config.read_text(encoding="utf-8") + replacements = { + "__CAPGLYPH_ARCH__": ARCHITECTURES[target_arch]["deb"], + "__CAPGLYPH_VERSION__": version, + "__CAPGLYPH_SOURCE__": str(source.resolve()), + } + expected_counts = { + "__CAPGLYPH_ARCH__": 1, + "__CAPGLYPH_VERSION__": 1, + "__CAPGLYPH_SOURCE__": 2, + } + for token, value in replacements.items(): + if text.count(token) != expected_counts[token]: + raise MetadataError( + f"unexpected nFPM template token count for {token}: " + f"expected {expected_counts[token]}, got {text.count(token)}" + ) + text = text.replace(token, json.dumps(value)) + output.write_text(text, encoding="utf-8") + + +def ar_members(path: Path) -> dict[str, bytes]: + data = path.read_bytes() + if not data.startswith(b"!\n"): + raise MetadataError(f"not a Debian ar archive: {path}") + members: dict[str, bytes] = {} + offset = 8 + while offset < len(data): + header = data[offset : offset + 60] + if len(header) != 60 or header[58:60] != b"`\n": + raise MetadataError(f"malformed ar member in {path}") + name = header[:16].decode("ascii").strip().rstrip("/") + try: + size = int(header[48:58].decode("ascii").strip()) + except ValueError as error: + raise MetadataError(f"invalid ar member size in {path}") from error + offset += 60 + members[name] = data[offset : offset + size] + offset += size + (size % 2) + return members + + +def decompress_tar(name: str, data: bytes) -> bytes: + if name.endswith(".gz"): + return gzip.decompress(data) + if name.endswith((".xz", ".lzma")): + return lzma.decompress(data) + if name.endswith(".tar"): + return data + if name.endswith(".zst"): + process = subprocess.run( + ["zstd", "--decompress", "--stdout"], + input=data, + capture_output=True, + check=False, + ) + if process.returncode != 0: + raise MetadataError( + f"cannot decompress {name}: {process.stderr.decode().strip()}" + ) + return process.stdout + raise MetadataError(f"unsupported control archive compression: {name}") + + +def parse_fields(text: str) -> dict[str, str]: + fields: dict[str, str] = {} + for line in text.splitlines(): + if not line or line[0].isspace() or ":" not in line: + continue + key, value = line.split(":", 1) + fields[key] = value.strip() + return fields + + +def deb_metadata(path: Path) -> dict[str, str]: + members = ar_members(path) + control_name = next( + (name for name in members if name.startswith("control.tar")), None + ) + if control_name is None: + raise MetadataError(f"Debian package has no control archive: {path}") + control_tar = decompress_tar(control_name, members[control_name]) + with tarfile.open(fileobj=io.BytesIO(control_tar), mode="r:") as archive: + member = next( + ( + entry + for entry in archive.getmembers() + if entry.name.lstrip("./") == "control" + ), + None, + ) + if member is None: + raise MetadataError(f"Debian package has no control file: {path}") + extracted = archive.extractfile(member) + if extracted is None: + raise MetadataError(f"cannot read Debian control file: {path}") + return parse_fields(extracted.read().decode("utf-8")) + + +def rpm_header(data: bytes, offset: int) -> tuple[dict[int, str], int]: + if data[offset : offset + 3] != b"\x8e\xad\xe8" or data[offset + 3] != 1: + raise MetadataError("malformed RPM header") + index_count, store_size = struct.unpack_from(">II", data, offset + 8) + if index_count > 100_000 or store_size > len(data): + raise MetadataError("unreasonable RPM header sizes") + indexes_start = offset + 16 + store_start = indexes_start + index_count * 16 + end = store_start + store_size + if end > len(data): + raise MetadataError("truncated RPM header") + values: dict[int, str] = {} + for index in range(index_count): + tag, value_type, value_offset, count = struct.unpack_from( + ">IIII", data, indexes_start + index * 16 + ) + if value_type == 6 and count == 1 and value_offset < store_size: + start = store_start + value_offset + stop = data.find(b"\0", start, end) + if stop < 0: + raise MetadataError("unterminated RPM string") + values[tag] = data[start:stop].decode("utf-8") + return values, end + + +def rpm_metadata(path: Path) -> dict[str, str]: + data = path.read_bytes() + if len(data) < 112 or data[:4] != b"\xed\xab\xee\xdb": + raise MetadataError(f"not an RPM package: {path}") + _, signature_end = rpm_header(data, 96) + main_offset = (signature_end + 7) & ~7 + values, _ = rpm_header(data, main_offset) + required = {1000: "name", 1001: "version", 1002: "release", 1022: "arch"} + missing = [label for tag, label in required.items() if tag not in values] + if missing: + raise MetadataError(f"RPM metadata missing {', '.join(missing)}: {path}") + return {label: values[tag] for tag, label in required.items()} + + +def arch_metadata(path: Path) -> dict[str, str]: + try: + with tarfile.open(path, mode="r:*") as archive: + member = archive.getmember(".PKGINFO") + extracted = archive.extractfile(member) + if extracted is None: + raise MetadataError(f"cannot read Arch .PKGINFO: {path}") + text = extracted.read().decode("utf-8") + except (tarfile.TarError, KeyError): + process = subprocess.run( + ["tar", "--zstd", "-xOf", str(path), ".PKGINFO"], + capture_output=True, + text=True, + check=False, + ) + if process.returncode != 0: + raise MetadataError(f"cannot read Arch .PKGINFO: {process.stderr.strip()}") + text = process.stdout + fields: dict[str, str] = {} + for line in text.splitlines(): + if " = " in line: + key, value = line.split(" = ", 1) + fields[key] = value + return fields + + +def require_fields(kind: str, actual: dict[str, str], expected: dict[str, str]) -> None: + for key, expected_value in expected.items(): + actual_value = actual.get(key) + if actual_value != expected_value: + raise MetadataError( + f"{kind} metadata mismatch for {key}: expected {expected_value!r}, " + f"got {actual_value!r}" + ) + + +def validate_packages( + tag: str, target_arch: str, deb: Path, rpm: Path, arch: Path +) -> None: + version = version_from_tag(tag) + if target_arch not in ARCHITECTURES: + raise MetadataError(f"unsupported target architecture: {target_arch!r}") + architecture = ARCHITECTURES[target_arch] + require_fields( + "Debian", + deb_metadata(deb), + { + "Package": "capglyph", + "Version": f"{version}-1", + "Architecture": architecture["deb"], + }, + ) + require_fields( + "RPM", + rpm_metadata(rpm), + { + "name": "capglyph", + "version": version, + "release": "1", + "arch": architecture["rpm"], + }, + ) + require_fields( + "Arch", + arch_metadata(arch), + {"pkgname": "capglyph", "pkgver": f"{version}-1", "arch": architecture["arch"]}, + ) + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + commands = root.add_subparsers(dest="command", required=True) + contract = commands.add_parser( + "contract", help="validate tag, Cargo, and nFPM inputs" + ) + contract.add_argument("--tag", required=True) + contract.add_argument("--cargo-manifest", required=True, type=Path) + contract.add_argument("--nfpm-config", required=True, type=Path) + render = commands.add_parser("render", help="render a validated nFPM config") + render.add_argument("--tag", required=True) + render.add_argument("--target-arch", required=True, choices=sorted(ARCHITECTURES)) + render.add_argument("--source", required=True, type=Path) + render.add_argument("--cargo-manifest", required=True, type=Path) + render.add_argument("--nfpm-config", required=True, type=Path) + render.add_argument("--output", required=True, type=Path) + packages = commands.add_parser("packages", help="inspect generated native packages") + packages.add_argument("--tag", required=True) + packages.add_argument("--target-arch", required=True, choices=sorted(ARCHITECTURES)) + packages.add_argument("--deb", required=True, type=Path) + packages.add_argument("--rpm", required=True, type=Path) + packages.add_argument("--arch", required=True, type=Path) + return root + + +def main() -> int: + args = parser().parse_args() + try: + if args.command == "contract": + version = validate_contract(args.tag, args.cargo_manifest, args.nfpm_config) + print(f"validated release contract: {version}") + elif args.command == "render": + render_config( + args.tag, + args.target_arch, + args.source, + args.cargo_manifest, + args.nfpm_config, + args.output, + ) + print(f"rendered validated nFPM config: {args.output}") + else: + validate_packages(args.tag, args.target_arch, args.deb, args.rpm, args.arch) + print(f"validated deb/rpm/arch metadata: {args.tag} {args.target_arch}") + except (MetadataError, OSError, subprocess.SubprocessError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From de4fc5e9a0913a31a30ff6e203d9e4cc17a93c5a Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Fri, 4 Sep 2026 21:21:13 +0800 Subject: [PATCH 4/5] [CGCLI-0003] feat(carrier): finish DWT placements and consume core carriers (CGCLI-0003) --- src/carrier.rs | 109 ++--- src/dwt.rs | 212 +-------- src/dwt_embed.rs | 1026 ++--------------------------------------- src/verify.rs | 11 +- tests/framed.rs | 8 +- tests/registration.rs | 18 +- 6 files changed, 113 insertions(+), 1271 deletions(-) diff --git a/src/carrier.rs b/src/carrier.rs index d50a5f4..2d50a0d 100644 --- a/src/carrier.rs +++ b/src/carrier.rs @@ -14,16 +14,6 @@ use image::{ImageBuffer, Rgb}; use crate::geometry::GeometryFile; use crate::registration::{CoverVault, HybridMatch, Registration}; -/// Convert core `Placement` to legacy CLI `PlacementStrategy` for the -/// `crate::dct`/`crate::dwt_embed` primitives that still take the CLI type. -fn to_cli_placement(p: &Placement) -> crate::cli::PlacementStrategy { - match p { - Placement::Skeleton => crate::cli::PlacementStrategy::Skeleton, - Placement::Prng => crate::cli::PlacementStrategy::Prng, - Placement::Edge => crate::cli::PlacementStrategy::Edge, - } -} - /// Convert CLI `PlacementStrategy` to core `Placement` (for tests that still /// construct the CLI type and need to call core APIs). pub fn to_core_placement(p: &crate::cli::PlacementStrategy) -> Placement { @@ -40,8 +30,8 @@ pub fn to_core_placement(p: &crate::cli::PlacementStrategy) -> Placement { pub struct DctCarrier; impl Carrier for DctCarrier { - const NAME: &'static str = "dct"; - type Metrics = crate::dct::DctSignalMetrics; + const NAME: &'static str = capglyph_core::carrier::DctCarrier::NAME; + type Metrics = ::Metrics; fn embed( img: &mut ImageBuffer, Vec>, @@ -50,13 +40,7 @@ impl Carrier for DctCarrier { key: Option<&str>, placement: &Placement, ) -> Result<(u64, Vec<(u32, u32)>)> { - crate::dct::embed( - img, - geometry, - recipient_id, - key, - &to_cli_placement(placement), - ) + capglyph_core::carrier::DctCarrier::embed(img, geometry, recipient_id, key, placement) } fn verify( @@ -64,24 +48,23 @@ impl Carrier for DctCarrier { geometry: &GeometryFile, placement: &Placement, ) -> Result { - crate::dct::verify(img, geometry, &to_cli_placement(placement)) + capglyph_core::carrier::DctCarrier::verify(img, geometry, placement) } fn verify_secret(img: &ImageBuffer, Vec>, key: &str) -> f64 { - crate::dct::verify_secret(img, key) + capglyph_core::carrier::DctCarrier::verify_secret(img, key) } fn extract(img: &ImageBuffer, Vec>, id_length: usize) -> Result { - let (w, h) = img.dimensions(); - crate::extract::extract_from_dct(img, id_length, w, h) + capglyph_core::carrier::DctCarrier::extract(img, id_length) } fn metrics_is_present(metrics: &Self::Metrics, threshold: f64) -> bool { - metrics.is_present(threshold) + capglyph_core::carrier::DctCarrier::metrics_is_present(metrics, threshold) } fn metrics_mean_signal(metrics: &Self::Metrics) -> f64 { - metrics.mean_signal_value() + capglyph_core::carrier::DctCarrier::metrics_mean_signal(metrics) } } @@ -89,17 +72,12 @@ impl Carrier for DctCarrier { /// DWT-domain carrier (Haar LH band). /// -/// Only `Placement::Skeleton` is currently supported. `Edge` and -/// `Prng` are rejected fail-closed (`anyhow::Error` containing "unsupported -/// DWT placement") so that callers cannot silently receive a Skeleton result -/// when they asked for a different placement arm. This keeps `DwtCarrier` -/// consistent with `DctCarrier` (which does honour all three placements) and -/// with `verify.rs` which now forwards the placement to `dwt_embed::verify`. +/// Skeleton, Edge, and Prng placement arms are fully supported via core DWT carrier delegation. pub struct DwtCarrier; impl Carrier for DwtCarrier { - const NAME: &'static str = "dwt"; - type Metrics = crate::dwt_embed::DwtSignalMetrics; + const NAME: &'static str = capglyph_core::carrier::DwtCarrier::NAME; + type Metrics = ::Metrics; fn embed( img: &mut ImageBuffer, Vec>, @@ -108,13 +86,7 @@ impl Carrier for DwtCarrier { key: Option<&str>, placement: &Placement, ) -> Result<(u64, Vec<(u32, u32)>)> { - crate::dwt_embed::embed( - img, - geometry, - recipient_id, - key, - &to_cli_placement(placement), - ) + capglyph_core::carrier::DwtCarrier::embed(img, geometry, recipient_id, key, placement) } fn embed_with_strength( @@ -125,12 +97,12 @@ impl Carrier for DwtCarrier { placement: &Placement, strength: f32, ) -> Result<(u64, Vec<(u32, u32)>)> { - crate::dwt_embed::embed_with_strength( + capglyph_core::carrier::DwtCarrier::embed_with_strength( img, geometry, recipient_id, key, - &to_cli_placement(placement), + placement, strength, ) } @@ -140,24 +112,23 @@ impl Carrier for DwtCarrier { geometry: &GeometryFile, placement: &Placement, ) -> Result { - crate::dwt_embed::verify(img, geometry, &to_cli_placement(placement)) + capglyph_core::carrier::DwtCarrier::verify(img, geometry, placement) } fn verify_secret(img: &ImageBuffer, Vec>, key: &str) -> f64 { - crate::dwt_embed::verify_secret(img, key) + capglyph_core::carrier::DwtCarrier::verify_secret(img, key) } fn extract(img: &ImageBuffer, Vec>, id_length: usize) -> Result { - let (w, h) = img.dimensions(); - crate::extract::extract_from_dwt(img, id_length, w, h) + capglyph_core::carrier::DwtCarrier::extract(img, id_length) } fn metrics_is_present(metrics: &Self::Metrics, threshold: f64) -> bool { - metrics.is_present(threshold) + capglyph_core::carrier::DwtCarrier::metrics_is_present(metrics, threshold) } fn metrics_mean_signal(metrics: &Self::Metrics) -> f64 { - metrics.mean_signal_value() + capglyph_core::carrier::DwtCarrier::metrics_mean_signal(metrics) } } @@ -196,13 +167,7 @@ impl DctCarrier { crate::ecc::Profile::RsInterleaved { .. } => crate::ecc::bytes_to_bits(&coded), }; // Delegate to DCT differential embed - crate::dct::embed_coded_bits( - img, - geometry, - &coded_bits, - keys, - &to_cli_placement(placement), - ) + capglyph_core::dct::embed_coded_bits(img, geometry, &coded_bits, keys, placement) } /// Extract and open a framed payload. Returns raw payload bytes after ECC @@ -236,7 +201,8 @@ impl DctCarrier { }; let sealed_len = crate::framing::sealed_len(len, ¶ms); let need_bits = crate::ecc::coded_bits_len(sealed_len, profile); - let soft = crate::dct::extract_coded_bits_soft_with_hint(img, keys, Some(need_bits))?; + let soft = + capglyph_core::dct::extract_coded_bits_soft_with_hint(img, keys, Some(need_bits))?; let mut decoded_sealed = crate::ecc::decode(&soft, profile)?; // BCH pads to k-boundary; truncate to actual sealed length before open if decoded_sealed.len() > sealed_len { @@ -245,7 +211,7 @@ impl DctCarrier { let (_hdr, payload) = crate::framing::open(&decoded_sealed, keys.k_mac())?; return Ok(payload); } - let soft_all = crate::dct::extract_coded_bits_soft(img, keys)?; + let soft_all = capglyph_core::dct::extract_coded_bits_soft(img, keys)?; // Auto-detect: try slicing at various repetition-aligned lengths until success // For now, just try full and then progressive 8-aligned prefixes. let steps = soft_all.len() / 8; @@ -317,7 +283,7 @@ impl DctCarrier { }; let sealed_len = crate::framing::sealed_len(len, ¶ms); let need_bits = crate::ecc::coded_bits_len(sealed_len, profile); - let soft = crate::dct::extract_coded_bits_soft_residual( + let soft = capglyph_core::dct::extract_coded_bits_soft_residual( original, aligned, keys, @@ -330,7 +296,8 @@ impl DctCarrier { let (_hdr, payload) = crate::framing::open(&decoded_sealed, keys.k_mac())?; return Ok(payload); } - let soft = crate::dct::extract_coded_bits_soft_residual(original, aligned, keys, None)?; + let soft = + capglyph_core::dct::extract_coded_bits_soft_residual(original, aligned, keys, None)?; let decoded_sealed = crate::ecc::decode(&soft, profile)?; let (_hdr, payload) = crate::framing::open(&decoded_sealed, keys.k_mac())?; Ok(payload) @@ -448,13 +415,7 @@ impl DwtCarrier { } crate::ecc::Profile::RsInterleaved { .. } => crate::ecc::bytes_to_bits(&coded), }; - crate::dwt_embed::embed_coded_bits( - img, - geometry, - &coded_bits, - keys, - &to_cli_placement(placement), - ) + capglyph_core::dwt_embed::embed_coded_bits(img, geometry, &coded_bits, keys, placement) } pub fn extract_framed( @@ -484,8 +445,11 @@ impl DwtCarrier { }; let sealed_len = crate::framing::sealed_len(len, ¶ms); let need_bits = crate::ecc::coded_bits_len(sealed_len, profile); - let soft = - crate::dwt_embed::extract_coded_bits_soft_with_hint(img, keys, Some(need_bits))?; + let soft = capglyph_core::dwt_embed::extract_coded_bits_soft_with_hint( + img, + keys, + Some(need_bits), + )?; let mut decoded_sealed = crate::ecc::decode(&soft, profile)?; if decoded_sealed.len() > sealed_len { decoded_sealed.truncate(sealed_len); @@ -493,7 +457,7 @@ impl DwtCarrier { let (_hdr, payload) = crate::framing::open(&decoded_sealed, keys.k_mac())?; return Ok(payload); } - let soft_all = crate::dwt_embed::extract_coded_bits_soft(img, keys)?; + let soft_all = capglyph_core::dwt_embed::extract_coded_bits_soft(img, keys)?; let steps = soft_all.len() / 8; for k in (1..=steps).rev() { let end = k * 8; @@ -541,7 +505,7 @@ impl DwtCarrier { }; let sealed_len = crate::framing::sealed_len(len, ¶ms); let need_bits = crate::ecc::coded_bits_len(sealed_len, profile); - let soft = crate::dwt_embed::extract_coded_bits_soft_residual( + let soft = capglyph_core::dwt_embed::extract_coded_bits_soft_residual( original, aligned, keys, @@ -554,8 +518,9 @@ impl DwtCarrier { let (_hdr, payload) = crate::framing::open(&decoded_sealed, keys.k_mac())?; return Ok(payload); } - let soft = - crate::dwt_embed::extract_coded_bits_soft_residual(original, aligned, keys, None)?; + let soft = capglyph_core::dwt_embed::extract_coded_bits_soft_residual( + original, aligned, keys, None, + )?; let decoded_sealed = crate::ecc::decode(&soft, profile)?; let (_hdr, payload) = crate::framing::open(&decoded_sealed, keys.k_mac())?; Ok(payload) diff --git a/src/dwt.rs b/src/dwt.rs index fde042e..649d563 100644 --- a/src/dwt.rs +++ b/src/dwt.rs @@ -1,193 +1,6 @@ //! Discrete Wavelet Transform (DWT) for watermark embedding. -//! -//! Implements 2D Haar wavelet transform for multi-resolution watermark embedding. -//! DWT decomposes an image into sub-bands (LL, LH, HL, HH) that survive scaling -//! and moderate filtering better than DCT blocks. -use anyhow::{Context, Result}; - -/// Wavelet sub-band selection for embedding. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WaveletBand { - /// Low-Low: coarse approximation (most robust, most visible) - LL, - /// Low-High: horizontal edges (good tradeoff) - LH, - /// High-Low: vertical edges (good tradeoff) - HL, - /// High-High: diagonal details (most invisible, most fragile) - HH, -} - -/// 2D Haar DWT decomposition result. -#[derive(Debug, Clone)] -pub struct DwtDecomposition { - pub ll: Vec>, // Low-Low (coarse approximation) - pub lh: Vec>, // Low-High (horizontal edges) - pub hl: Vec>, // High-Low (vertical edges) - pub hh: Vec>, // High-High (diagonal details) - pub width: usize, // Original image width - pub height: usize, // Original image height -} - -impl DwtDecomposition { - /// Get a mutable reference to the specified sub-band. - pub fn band_mut(&mut self, band: WaveletBand) -> &mut Vec> { - match band { - WaveletBand::LL => &mut self.ll, - WaveletBand::LH => &mut self.lh, - WaveletBand::HL => &mut self.hl, - WaveletBand::HH => &mut self.hh, - } - } - - /// Get an immutable reference to the specified sub-band. - pub fn band(&self, band: WaveletBand) -> &Vec> { - match band { - WaveletBand::LL => &self.ll, - WaveletBand::LH => &self.lh, - WaveletBand::HL => &self.hl, - WaveletBand::HH => &self.hh, - } - } -} - -/// Perform 1D Haar wavelet transform in-place on a single row/column. -/// -/// Splits the data into approximation (low-pass) and detail (high-pass) coefficients. -/// Output layout: [approx[0], approx[1], ..., detail[0], detail[1], ...] -fn haar_1d_forward(data: &mut [f32]) { - let n = data.len(); - if n < 2 { - return; - } - - let mut temp = vec![0.0; n]; - let half = n / 2; - - // Compute approximation and detail coefficients - for i in 0..half { - let a = data[2 * i]; - let b = data[2 * i + 1]; - temp[i] = (a + b) / 2.0_f32.sqrt(); // Approximation (low-pass) - temp[half + i] = (a - b) / 2.0_f32.sqrt(); // Detail (high-pass) - } - - data.copy_from_slice(&temp); -} - -/// Perform 1D inverse Haar wavelet transform in-place. -fn haar_1d_inverse(data: &mut [f32]) { - let n = data.len(); - if n < 2 { - return; - } - - let mut temp = vec![0.0; n]; - let half = n / 2; - - // Reconstruct from approximation and detail coefficients - for i in 0..half { - let approx = data[i]; - let detail = data[half + i]; - temp[2 * i] = (approx + detail) / 2.0_f32.sqrt(); - temp[2 * i + 1] = (approx - detail) / 2.0_f32.sqrt(); - } - - data.copy_from_slice(&temp); -} - -/// Perform 2D Haar DWT on a single-channel image (grayscale or single RGB channel). -/// -/// Returns the four sub-bands: LL, LH, HL, HH. -pub fn haar_2d_forward(image: &[Vec]) -> Result { - let height = image.len(); - let width = image.first().context("Empty image")?.len(); - - if width < 2 || height < 2 { - anyhow::bail!("Image too small for DWT (min 2×2 required)"); - } - - // Copy input to working buffer - let mut working = image.to_vec(); - - // Step 1: Apply 1D Haar transform to each row - for row in &mut working { - haar_1d_forward(row); - } - - // Step 2: Apply 1D Haar transform to each column - for col_idx in 0..width { - let mut column: Vec = working.iter().map(|row| row[col_idx]).collect(); - haar_1d_forward(&mut column); - for (row_idx, val) in column.iter().enumerate() { - working[row_idx][col_idx] = *val; - } - } - - // Step 3: Extract the four sub-bands - let half_width = width / 2; - let half_height = height / 2; - - let mut ll = vec![vec![0.0; half_width]; half_height]; - let mut lh = vec![vec![0.0; half_width]; half_height]; - let mut hl = vec![vec![0.0; half_width]; half_height]; - let mut hh = vec![vec![0.0; half_width]; half_height]; - - for y in 0..half_height { - for x in 0..half_width { - ll[y][x] = working[y][x]; - lh[y][x] = working[y][half_width + x]; - hl[y][x] = working[half_height + y][x]; - hh[y][x] = working[half_height + y][half_width + x]; - } - } - - Ok(DwtDecomposition { - ll, - lh, - hl, - hh, - width, - height, - }) -} - -/// Perform 2D inverse Haar DWT to reconstruct the image from sub-bands. -pub fn haar_2d_inverse(decomp: &DwtDecomposition) -> Result>> { - let half_width = decomp.ll[0].len(); - let half_height = decomp.ll.len(); - let width = decomp.width; - let height = decomp.height; - - // Step 1: Merge sub-bands back into a single buffer - let mut working = vec![vec![0.0; width]; height]; - - for y in 0..half_height { - for x in 0..half_width { - working[y][x] = decomp.ll[y][x]; - working[y][half_width + x] = decomp.lh[y][x]; - working[half_height + y][x] = decomp.hl[y][x]; - working[half_height + y][half_width + x] = decomp.hh[y][x]; - } - } - - // Step 2: Apply inverse 1D Haar transform to each column - for col_idx in 0..width { - let mut column: Vec = working.iter().map(|row| row[col_idx]).collect(); - haar_1d_inverse(&mut column); - for (row_idx, val) in column.iter().enumerate() { - working[row_idx][col_idx] = *val; - } - } - - // Step 3: Apply inverse 1D Haar transform to each row - for row in &mut working { - haar_1d_inverse(row); - } - - Ok(working) -} +pub use capglyph_core::dwt::*; #[cfg(test)] mod tests { @@ -195,19 +8,14 @@ mod tests { #[test] fn test_haar_1d_perfect_reconstruction() { - let mut data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; - let original = data.clone(); - - haar_1d_forward(&mut data); - haar_1d_inverse(&mut data); - - for (a, b) in original.iter().zip(data.iter()) { - assert!( - (a - b).abs() < 1e-5, - "Perfect reconstruction failed: {} vs {}", - a, - b - ); + let image = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; 2]; + let original = image.clone(); + let decomp = haar_2d_forward(&image).unwrap(); + let reconstructed = haar_2d_inverse(&decomp).unwrap(); + for (row_orig, row_recon) in original.iter().zip(reconstructed.iter()) { + for (a, b) in row_orig.iter().zip(row_recon.iter()) { + assert!((a - b).abs() < 1e-5); + } } } @@ -247,11 +55,9 @@ mod tests { let mut decomp = haar_2d_forward(&image).unwrap(); - // Test mutable access let lh_band = decomp.band_mut(WaveletBand::LH); lh_band[0][0] += 10.0; - // Test immutable access let lh_band_read = decomp.band(WaveletBand::LH); assert!((lh_band_read[0][0] - (decomp.lh[0][0])).abs() < 1e-5); } diff --git a/src/dwt_embed.rs b/src/dwt_embed.rs index 829bd25..426d12c 100644 --- a/src/dwt_embed.rs +++ b/src/dwt_embed.rs @@ -1,125 +1,16 @@ //! DWT-based watermark embedding and verification. //! -//! Embeds a structural watermark by modifying LH sub-band coefficients at -//! positions determined by the image's own path geometry (skeleton-guided). -//! Unlike DCT (which operates on fixed 8×8 blocks), DWT coefficients scale -//! with the image, giving much better robustness against resize operations. +//! Re-exports core DWT carrier primitives and provides backward-compatible +//! wrappers for legacy CLI `PlacementStrategy` call sites. + +pub use capglyph_core::dwt_embed::*; use anyhow::Result; use image::{ImageBuffer, Rgb}; -use crate::dwt::{haar_2d_forward, haar_2d_inverse, WaveletBand}; use crate::geometry::GeometryFile; -/// Strength of DWT coefficient modification. -/// Higher = more robust but more visible. 8.0 gives PSNR ≈ 44-46 dB (invisible). -pub const DWT_EMBED_STRENGTH: f32 = 8.0; - -/// Strength for recipient ID bit embedding in DWT mode. -/// Must be large enough to dominate natural LH coefficient variance at skeleton -/// positions (which can be ±200 for textured photographic images). -/// 256.0 ensures ±delta >> typical group variance, making polarity reliable. -pub const DWT_ID_EMBED_STRENGTH: f32 = 256.0; - -/// Flat-region LH coefficient threshold. Positions whose |LH coeff| is below -/// this are in visually flat image areas (solid background, sky). Strong ±256 -/// embedding there produces visible ±128 spatial steps — so flat positions use -/// the much smaller FLAT_ID_EMBED_STRENGTH instead. -pub const FLAT_LH_THRESHOLD: f32 = 30.0; - -/// ID/sync embedding strength for flat regions. ±32 → spatial change ±16, -/// invisible on solid backgrounds while still contributing polarity to the -/// group-mean decoding. -pub const FLAT_ID_EMBED_STRENGTH: f32 = 32.0; - -/// Sub-band used for embedding. -pub const EMBED_BAND: WaveletBand = WaveletBand::LH; - -/// Minimum absolute coefficient value to modify (skip near-zero coefficients). -pub const MIN_COEFF_THRESHOLD: f32 = 3.0; - -/// Signal detection threshold: fraction of marked coefficients required to confirm. -pub const DWT_DETECT_THRESHOLD: f64 = 0.50; - -/// Fixed magic seed for self-sync seed positions in the LH band (reuse DCT constant). -pub const SEED_MAGIC: u64 = crate::dct::SEED_MAGIC; - -/// Redundancy for self-sync seed bits (same as DCT mode). -pub const SYNC_REDUNDANCY: usize = 8; - -/// DWT currently only supports Skeleton placement. Edge/Prng are explicitly -/// rejected fail-closed so callers cannot silently get a Skeleton result when -/// they requested a different placement arm. This keeps DWT consistent with -/// the Carrier trait contract and with `verify.rs` which dispatches Edge/Prng -/// for DCT. -fn ensure_placement_supported(placement: &crate::cli::PlacementStrategy) -> Result<()> { - match placement { - crate::cli::PlacementStrategy::Skeleton => Ok(()), - other => anyhow::bail!( - "unsupported DWT placement: {:?} (only Skeleton is supported; DWT embeds only at geometry-derived LH positions)", - other - ), - } -} - -/// Metrics from DWT watermark verification. -#[derive(Debug)] -pub struct DwtSignalMetrics { - /// Number of coefficients that were checked - pub total_coefficients: u64, - /// Number that show the expected modification direction - pub detected_count: u64, - /// Detection rate (detected / total) - pub detection_rate: f64, - /// Mean signal strength at embedded positions - pub mean_signal: f32, -} - -impl DwtSignalMetrics { - /// Primary DWT presence predicate (v1): `mean >= thr || (rate>=0.8 && mean>=2.0)`. - /// - /// Single source of truth — `carrier.rs`, `verify.rs`, and `wasm_api.rs` - /// all delegate here. Mirrors `DctSignalMetrics::is_present` shape. - pub fn is_present(&self, threshold: f64) -> bool { - Self::predicate(f64::from(self.mean_signal), self.detection_rate, threshold) - } - - /// V2 predicate: `mean >= thr` only (no secondary gate). - pub fn is_present_v2(&self, threshold: f64) -> bool { - Self::predicate_v2(f64::from(self.mean_signal), threshold) - } - - /// Mean signal as `f64` for threshold comparisons. - pub fn mean_signal_value(&self) -> f64 { - f64::from(self.mean_signal) - } - - /// Canonical v1 predicate for a raw `(mean, rate)` pair — used when - /// metrics struct is not available (kept for symmetry with DCT). - #[inline] - pub fn predicate(mean_signal: f64, detection_rate: f64, threshold: f64) -> bool { - mean_signal >= threshold || (detection_rate >= 0.8 && mean_signal >= 2.0) - } - - /// Canonical v2 predicate. - #[inline] - pub fn predicate_v2(mean_signal: f64, threshold: f64) -> bool { - mean_signal >= threshold - } -} - -// ── Embed ───────────────────────────────────────────────────────────────────── - -/// Embed a DWT watermark into the RGB image in-place. -/// -/// Three independent signal layers: -/// 1. Primary watermark: +DWT_EMBED_STRENGTH at geometry positions (verify) -/// 2. Self-sync seed: ±DWT_ID_EMBED_STRENGTH at SEED_MAGIC positions (64 bits) -/// 3. Recipient ID: ±DWT_ID_EMBED_STRENGTH at stable_seed PRNG positions -/// 4. Secret layer (when secret_key given): +DWT_EMBED_STRENGTH at -/// HMAC(key, seed)-derived band positions — verifiable only with key -/// -/// Layers 2+3 are geometry-free — extraction locates them via PRNG only. +/// Backward-compatible wrapper converting legacy CLI PlacementStrategy to core Placement. pub fn embed( img: &mut ImageBuffer, Vec>, geometry: &GeometryFile, @@ -127,20 +18,11 @@ pub fn embed( secret_key: Option<&str>, placement: &crate::cli::PlacementStrategy, ) -> Result<(u64, Vec<(u32, u32)>)> { - ensure_placement_supported(placement)?; - embed_with_strength( - img, - geometry, - recipient_id, - secret_key, - placement, - DWT_EMBED_STRENGTH, - ) + let p = crate::carrier::to_core_placement(placement); + capglyph_core::dwt_embed::embed(img, geometry, recipient_id, secret_key, &p) } -/// Embed using an explicit primary/secret-layer strength for development -/// calibration. ID and sync strengths remain fixed because they carry a -/// separate extraction contract. +/// Backward-compatible wrapper converting legacy CLI PlacementStrategy to core Placement. pub fn embed_with_strength( img: &mut ImageBuffer, Vec>, geometry: &GeometryFile, @@ -149,762 +31,47 @@ pub fn embed_with_strength( placement: &crate::cli::PlacementStrategy, strength: f32, ) -> Result<(u64, Vec<(u32, u32)>)> { - anyhow::ensure!(strength > 0.0, "DWT strength must be positive"); - ensure_placement_supported(placement)?; - let (w, h) = img.dimensions(); - - let positions = collect_embed_positions(geometry, w, h); - // NOTE: do NOT early-return on empty positions — solid-color images have no - // geometry, but the self-sync + ID layers (geometry-free PRNG positions) - // still embed and extract correctly. Only layer 1 (primary watermark) - // is skipped. - - // Compute ID bits if recipient_id provided - let id_bits: Vec = if let Some(rid) = recipient_id { - crate::spread_spectrum::str_to_bits(rid) - } else { - vec![] - }; - - let redundancy = crate::spread_spectrum::REDUNDANCY; - let bits_needed = id_bits.len() * redundancy; - - // Band-space PRNG position sets (geometry-free layers) - let band_w = w / 2; - let band_h = h / 2; - - // Self-sync positions carry the 64-bit stable seed (only when ID is embedded) - let seed = crate::dct::stable_seed(img); - let sync_positions = if recipient_id.is_some() { - prng_band_positions(SEED_MAGIC, band_w, band_h, 64 * SYNC_REDUNDANCY) - } else { - vec![] - }; - let sync_set: std::collections::HashSet<(u32, u32)> = sync_positions.iter().copied().collect(); - - // ID positions derived from the stable seed, excluding sync positions - let id_positions: Vec<(u32, u32)> = if recipient_id.is_some() { - prng_band_positions(seed, band_w, band_h, bits_needed + sync_set.len()) - .into_iter() - .filter(|p| !sync_set.contains(p)) - .collect() - } else { - vec![] - }; - - // Secret-layer positions derived from HMAC(key, seed) - let secret_positions: Vec<(u32, u32)> = if let Some(key) = secret_key { - let kseed = crate::keying::key_seed(key, seed); - prng_band_positions(kseed, band_w, band_h, SECRET_BAND_COUNT) - } else { - vec![] - }; - - let mut total_modified = 0u64; - - // Process each RGB channel independently - for ch in 0..3usize { - let channel_matrix = extract_channel(img, ch); - - // Forward DWT - let mut decomp = haar_2d_forward(&channel_matrix)?; - let band = decomp.band_mut(EMBED_BAND); - let (bh, bw) = (band.len(), band[0].len()); - - // Layer 1: primary watermark at geometry positions - for &(bx, by) in &positions { - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - band[by][bx] += strength; - total_modified += 1; - } - } - - // Layer 2: self-sync seed bits (±) — flat positions use reduced strength - let seed_bits: Vec = seed - .to_le_bytes() - .iter() - .flat_map(|b| (0..8).rev().map(move |i| (b >> i) & 1 == 1)) - .collect(); - for (i, &(bx, by)) in sync_positions.iter().enumerate() { - let bit_idx = i / SYNC_REDUNDANCY; - if bit_idx >= seed_bits.len() { - break; - } - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - // Check flatness BEFORE modification - let is_flat = band[by][bx].abs() < FLAT_LH_THRESHOLD; - let strength = if is_flat { - FLAT_ID_EMBED_STRENGTH - } else { - DWT_ID_EMBED_STRENGTH - }; - let delta = if seed_bits[bit_idx] { - strength - } else { - -strength - }; - band[by][bx] += delta; - total_modified += 1; - } - } - - // Layer 3: recipient ID bits (±) — flat positions use reduced strength - for (i, &(bx, by)) in id_positions.iter().enumerate() { - if i >= bits_needed { - break; - } - let bit_idx = i / redundancy; - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - let is_flat = band[by][bx].abs() < FLAT_LH_THRESHOLD; - let strength = if is_flat { - FLAT_ID_EMBED_STRENGTH - } else { - DWT_ID_EMBED_STRENGTH - }; - let delta = if id_bits[bit_idx] { - strength - } else { - -strength - }; - band[by][bx] += delta; - total_modified += 1; - } - } - - // Layer 4: secret layer — differential pairs (±DWT_EMBED_STRENGTH) at - // key-derived positions. Wrong key → pair mean ≈ 0 even when positions - // overlap the primary watermark. - for (i, &(bx, by)) in secret_positions.iter().enumerate() { - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - let delta = if i % 2 == 0 { strength } else { -strength }; - band[by][bx] += delta; - total_modified += 1; - } - } - - // Inverse DWT - let reconstructed = haar_2d_inverse(&decomp)?; - write_channel(img, ch, &reconstructed); - } - - Ok((total_modified / 3, positions)) -} - -/// Number of LH band positions carrying the DWT secret layer (used in ± pairs). -pub const SECRET_BAND_COUNT: usize = 512; - -/// Verify the DWT secret layer: differential-pair mean of LH coefficients at -/// key-derived band positions. Correct key → ≈ 2·DWT_EMBED_STRENGTH; -/// wrong key → ≈ 0. -pub fn verify_secret(img: &ImageBuffer, Vec>, key: &str) -> f64 { - let (w, h) = img.dimensions(); - let seed = crate::dct::stable_seed(img); - let kseed = crate::keying::key_seed(key, seed); - let positions = prng_band_positions(kseed, w / 2, h / 2, SECRET_BAND_COUNT); - let mut sum = 0.0f64; - let mut n = 0u64; - for ch in 0..3usize { - let channel_matrix = extract_channel(img, ch); - if let Ok(decomp) = haar_2d_forward(&channel_matrix) { - let band = decomp.band(EMBED_BAND); - let (bh, bw) = (band.len(), band[0].len()); - let mut pair_sum = 0.0f64; - let mut pair_count = 0u64; - for (i, &(bx, by)) in positions.iter().enumerate() { - if (bx as usize) < bw && (by as usize) < bh { - let coeff = band[by as usize][bx as usize] as f64; - if i % 2 == 0 { - pair_sum += coeff; - } else { - pair_sum -= coeff; - pair_count += 1; - } - } - } - if pair_count > 0 { - sum += pair_sum / pair_count as f64; - n += 1; - } - } - } - if n == 0 { - 0.0 - } else { - sum / n as f64 - } -} - -/// CTX-0020: Differential coded-bit layer for framed payload (LH band). -/// -/// Same framing/ECC stack as DCT but in Haar LH coefficients. -/// Primary watermark at geometry positions is preserved; sync seed at -/// SEED_MAGIC positions; payload at keyed band positions. -pub fn embed_coded_bits( - img: &mut ImageBuffer, Vec>, - geometry: &GeometryFile, - coded_bits: &[bool], - keys: &crate::keying::KeyMaterial, - placement: &crate::cli::PlacementStrategy, -) -> Result<(u64, Vec<(u32, u32)>)> { - ensure_placement_supported(placement)?; - let (w, h) = img.dimensions(); - let positions = collect_embed_positions(geometry, w, h); - let band_w = w / 2; - let band_h = h / 2; - - let seed = crate::dct::stable_seed(img); - let kseed = crate::keying::prf_k_embed(keys.k_embed(), seed); - let sync_positions = prng_band_positions(SEED_MAGIC, band_w, band_h, 64 * SYNC_REDUNDANCY); - let sync_set: std::collections::HashSet<(u32, u32)> = sync_positions.iter().copied().collect(); - - let needed_pairs = coded_bits.len() * 2; - let total_band = (band_w as usize) * (band_h as usize); - anyhow::ensure!( - sync_positions.len() + needed_pairs <= total_band, - "insufficient DWT band capacity: need {} (sync {} + payload {}), have {}", - sync_positions.len() + needed_pairs, - sync_positions.len(), - needed_pairs, - total_band - ); - let mut payload_pairs: Vec<(u32, u32)> = Vec::new(); - if needed_pairs > 0 { - let mut cand = - prng_band_positions(kseed, band_w, band_h, needed_pairs + sync_set.len() * 2); - cand.retain(|p| !sync_set.contains(p)); - anyhow::ensure!( - cand.len() >= needed_pairs, - "insufficient keyed band positions" - ); - cand.truncate(needed_pairs); - cand.sort_unstable(); - payload_pairs = cand; - } - - let seed_bits: Vec = seed - .to_le_bytes() - .iter() - .flat_map(|b| (0..8).rev().map(move |i| (b >> i) & 1 == 1)) - .collect(); - - let mut total_modified = 0u64; - for ch in 0..3usize { - let channel_matrix = extract_channel(img, ch); - let mut decomp = haar_2d_forward(&channel_matrix)?; - let band = decomp.band_mut(EMBED_BAND); - let (bh, bw) = (band.len(), band[0].len()); - - // Primary geometry positions (+8) - for &(bx, by) in &positions { - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - band[by][bx] += DWT_EMBED_STRENGTH; - total_modified += 1; - } - } - // Sync seed - for (i, &(bx, by)) in sync_positions.iter().enumerate() { - let bit_idx = i / SYNC_REDUNDANCY; - if bit_idx >= seed_bits.len() { - break; - } - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - let is_flat = band[by][bx].abs() < FLAT_LH_THRESHOLD; - let strength = if is_flat { - FLAT_ID_EMBED_STRENGTH - } else { - DWT_ID_EMBED_STRENGTH - }; - let delta = if seed_bits[bit_idx] { - strength - } else { - -strength - }; - band[by][bx] += delta; - total_modified += 1; - } - } - // Payload differential pairs at LH - for (i, &bit) in coded_bits.iter().enumerate() { - let (bx0, by0) = payload_pairs[2 * i]; - let (bx1, by1) = payload_pairs[2 * i + 1]; - // Decide per-pair strength based on pre-mod coefficient flatness - let is_flat0 = { - let bx = bx0 as usize; - let by = by0 as usize; - if bx < bw && by < bh { - band[by][bx].abs() < FLAT_LH_THRESHOLD - } else { - false - } - }; - let is_flat1 = { - let bx = bx1 as usize; - let by = by1 as usize; - if bx < bw && by < bh { - band[by][bx].abs() < FLAT_LH_THRESHOLD - } else { - false - } - }; - let s0 = if is_flat0 { - FLAT_ID_EMBED_STRENGTH - } else { - DWT_ID_EMBED_STRENGTH - }; - let s1 = if is_flat1 { - FLAT_ID_EMBED_STRENGTH - } else { - DWT_ID_EMBED_STRENGTH - }; - let d0 = if bit { s0 } else { -s0 }; - let d1 = -d0 * (s1 / s0.max(1.0)); // keep opposite sign, scale to local flatness - let bx0u = bx0 as usize; - let by0u = by0 as usize; - let bx1u = bx1 as usize; - let by1u = by1 as usize; - if bx0u < bw && by0u < bh { - band[by0u][bx0u] += d0; - total_modified += 1; - } - if bx1u < bw && by1u < bh { - band[by1u][bx1u] += d1; - total_modified += 1; - } - } - - let reconstructed = haar_2d_inverse(&decomp)?; - write_channel(img, ch, &reconstructed); - } - Ok((total_modified / 3, positions)) -} - -pub fn extract_coded_bits_soft( - img: &ImageBuffer, Vec>, - keys: &crate::keying::KeyMaterial, -) -> Result> { - extract_coded_bits_soft_with_hint(img, keys, None) -} - -pub fn extract_coded_bits_soft_with_hint( - img: &ImageBuffer, Vec>, - keys: &crate::keying::KeyMaterial, - expected_bits: Option, -) -> Result> { - let (w, h) = img.dimensions(); - let band_w = w / 2; - let band_h = h / 2; - let sync_positions = prng_band_positions(SEED_MAGIC, band_w, band_h, 64 * SYNC_REDUNDANCY); - // Recover seed from sync positions (average across channels) - let mut sync_signals = vec![0.0f32; 64 * SYNC_REDUNDANCY]; - for ch in 0..3usize { - let channel_matrix = extract_channel(img, ch); - let decomp = haar_2d_forward(&channel_matrix)?; - let band = decomp.band(EMBED_BAND); - let (bh, bw) = (band.len(), band[0].len()); - for (i, &(bx, by)) in sync_positions.iter().enumerate() { - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - sync_signals[i] += band[by][bx]; - } - } - } - for s in &mut sync_signals { - *s /= 3.0; - } - let sync_global = sync_signals.iter().sum::() / sync_signals.len() as f32; - let mut seed_bits = Vec::with_capacity(64); - for bit_idx in 0..64 { - let start = bit_idx * SYNC_REDUNDANCY; - let gm: f32 = sync_signals[start..start + SYNC_REDUNDANCY] - .iter() - .sum::() - / SYNC_REDUNDANCY as f32; - seed_bits.push(gm > sync_global); - } - let mut seed_bytes = [0u8; 8]; - for (i, chunk) in seed_bits.chunks(8).enumerate() { - seed_bytes[i] = chunk.iter().fold(0u8, |acc, &b| (acc << 1) | (b as u8)); - } - let seed = u64::from_le_bytes(seed_bytes); - let kseed = crate::keying::prf_k_embed(keys.k_embed(), seed); - let sync_set: std::collections::HashSet<(u32, u32)> = sync_positions.iter().copied().collect(); - let (n_bits, cand) = if let Some(exp) = expected_bits { - let mut cand = prng_band_positions(kseed, band_w, band_h, exp * 2 + sync_set.len() * 2); - cand.retain(|p| !sync_set.contains(p)); - anyhow::ensure!( - cand.len() >= exp * 2, - "insufficient keyed band positions for expected bits" - ); - cand.truncate(exp * 2); - cand.sort_unstable(); - (exp, cand) - } else { - let total_band = (band_w as usize) * (band_h as usize); - let max_pairs = (total_band.saturating_sub(sync_positions.len())) / 2; - let mut cand = - prng_band_positions(kseed, band_w, band_h, max_pairs * 2 + sync_set.len() * 2); - cand.retain(|p| !sync_set.contains(p)); - cand.truncate(max_pairs * 2); - cand.sort_unstable(); - if !cand.len().is_multiple_of(2) { - cand.pop(); - } - (cand.len() / 2, cand) - }; - let mut diffs = Vec::with_capacity(n_bits); - // Collect per-channel band for diff calc - // We average across channels for each pair difference. - let mut bands = Vec::new(); - for ch in 0..3usize { - let channel_matrix = extract_channel(img, ch); - let decomp = haar_2d_forward(&channel_matrix)?; - bands.push(decomp.band(EMBED_BAND).clone()); - } - for i in 0..n_bits { - let (bx0, by0) = cand[2 * i]; - let (bx1, by1) = cand[2 * i + 1]; - let mut d0 = 0.0f32; - let mut d1 = 0.0f32; - for band in &bands { - let (bh, bw) = (band.len(), band[0].len()); - let (bx0u, by0u) = (bx0 as usize, by0 as usize); - let (bx1u, by1u) = (bx1 as usize, by1 as usize); - if bx0u < bw && by0u < bh { - d0 += band[by0u][bx0u]; - } - if bx1u < bw && by1u < bh { - d1 += band[by1u][bx1u]; - } - } - d0 /= 3.0; - d1 /= 3.0; - diffs.push(d0 - d1); - } - let sigma = crate::ecc::estimate_sigma(&diffs); - Ok(diffs - .iter() - .map(|&d| crate::ecc::SoftBit::from_coeff(d, sigma)) - .collect()) -} - -/// CTX-0021: Residual soft extraction `R = I_aligned − I_original` in LH band. -/// -/// Uses `stable_seed(original)` directly for the strong path. -pub fn extract_coded_bits_soft_residual( - original: &ImageBuffer, Vec>, - aligned: &ImageBuffer, Vec>, - keys: &crate::keying::KeyMaterial, - expected_bits: Option, -) -> Result> { - let (w, h) = original.dimensions(); - let (aw, ah) = aligned.dimensions(); - anyhow::ensure!( - w == aw && h == ah, - "original {}×{} vs aligned {}×{} size mismatch (residual)", - w, - h, - aw, - ah - ); - let band_w = w / 2; - let band_h = h / 2; - let sync_positions = prng_band_positions(SEED_MAGIC, band_w, band_h, 64 * SYNC_REDUNDANCY); - let sync_set: std::collections::HashSet<(u32, u32)> = sync_positions.iter().copied().collect(); - - let seed = crate::dct::stable_seed(original); - let kseed = crate::keying::prf_k_embed(keys.k_embed(), seed); - - let (n_bits, cand) = if let Some(exp) = expected_bits { - let mut cand = prng_band_positions(kseed, band_w, band_h, exp * 2 + sync_set.len() * 2); - cand.retain(|p| !sync_set.contains(p)); - anyhow::ensure!( - cand.len() >= exp * 2, - "insufficient keyed band positions for expected bits (residual)" - ); - cand.truncate(exp * 2); - cand.sort_unstable(); - (exp, cand) - } else { - let total_band = (band_w as usize) * (band_h as usize); - let max_pairs = (total_band.saturating_sub(sync_positions.len())) / 2; - let mut cand = - prng_band_positions(kseed, band_w, band_h, max_pairs * 2 + sync_set.len() * 2); - cand.retain(|p| !sync_set.contains(p)); - cand.truncate(max_pairs * 2); - cand.sort_unstable(); - if !cand.len().is_multiple_of(2) { - cand.pop(); - } - (cand.len() / 2, cand) - }; - - // Compute residual LH bands: Haar(aligned) - Haar(original) per channel, then average - let mut residual_bands: Vec>> = Vec::new(); - for ch in 0..3usize { - let orig_mat = extract_channel(original, ch); - let aligned_mat = extract_channel(aligned, ch); - let orig_decomp = haar_2d_forward(&orig_mat)?; - let aligned_decomp = haar_2d_forward(&aligned_mat)?; - let orig_band = orig_decomp.band(EMBED_BAND); - let aligned_band = aligned_decomp.band(EMBED_BAND); - let bh = orig_band.len(); - let bw = orig_band[0].len(); - let mut res = vec![vec![0.0f32; bw]; bh]; - for y in 0..bh { - for x in 0..bw { - res[y][x] = aligned_band[y][x] - orig_band[y][x]; - } - } - residual_bands.push(res); - } - - let mut diffs = Vec::with_capacity(n_bits); - for i in 0..n_bits { - let (bx0, by0) = cand[2 * i]; - let (bx1, by1) = cand[2 * i + 1]; - let mut d0 = 0.0f32; - let mut d1 = 0.0f32; - for band in &residual_bands { - let (bh, bw) = (band.len(), band[0].len()); - let (bx0u, by0u) = (bx0 as usize, by0 as usize); - let (bx1u, by1u) = (bx1 as usize, by1 as usize); - if bx0u < bw && by0u < bh { - d0 += band[by0u][bx0u]; - } - if bx1u < bw && by1u < bh { - d1 += band[by1u][bx1u]; - } - } - d0 /= 3.0; - d1 /= 3.0; - diffs.push(d0 - d1); - } - let sigma = crate::ecc::estimate_sigma(&diffs); - Ok(diffs - .iter() - .map(|&d| crate::ecc::SoftBit::from_coeff(d, sigma)) - .collect()) + let p = crate::carrier::to_core_placement(placement); + capglyph_core::dwt_embed::embed_with_strength( + img, + geometry, + recipient_id, + secret_key, + &p, + strength, + ) } -// ── Verify ──────────────────────────────────────────────────────────────────── - -/// Verify DWT watermark presence. -/// -/// Checks if LH sub-band coefficients at geometry positions are shifted -/// in the expected direction (positive bias from embedding). -/// -/// The test is distribution-free: it checks if the mean offset at marked -/// positions significantly exceeds the baseline (unmarked positions). +/// Backward-compatible wrapper converting legacy CLI PlacementStrategy to core Placement. pub fn verify( img: &ImageBuffer, Vec>, geometry: &GeometryFile, placement: &crate::cli::PlacementStrategy, ) -> Result { - ensure_placement_supported(placement)?; - let (w, h) = img.dimensions(); - - let positions = collect_embed_positions(geometry, w, h); - if positions.is_empty() { - return Ok(DwtSignalMetrics { - total_coefficients: 0, - detected_count: 0, - detection_rate: 0.0, - mean_signal: 0.0, - }); - } - - let mut total = 0u64; - let mut detected = 0u64; - let mut signal_sum = 0.0f64; - - // Average the detection over all 3 channels - for ch in 0..3usize { - let channel_matrix = extract_channel(img, ch); - let decomp = haar_2d_forward(&channel_matrix)?; - let band = decomp.band(EMBED_BAND); - let (bh, bw) = (band.len(), band[0].len()); - - for &(bx, by) in &positions { - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - let coeff = band[by][bx]; - // Watermark pushes coeff in + direction; detect if positive bias - if coeff > MIN_COEFF_THRESHOLD { - detected += 1; - } - signal_sum += coeff as f64; - total += 1; - } - } - } - - let detection_rate = if total > 0 { - detected as f64 / total as f64 - } else { - 0.0 - }; - - Ok(DwtSignalMetrics { - total_coefficients: total, - detected_count: detected, - detection_rate, - mean_signal: (signal_sum / total.max(1) as f64) as f32, - }) + let p = crate::carrier::to_core_placement(placement); + capglyph_core::dwt_embed::verify(img, geometry, &p) } -/// Blind v2 statistic: median-centred, MAD-normalized LH signal. -/// The reference is computed from the evaluated image itself, so no cover or -/// marked-image geometry artifact is required. Complexity is O(N log N) time -/// and O(N) auxiliary space for each RGB channel. +/// Backward-compatible wrapper converting legacy CLI PlacementStrategy to core Placement. pub fn verify_v2( img: &ImageBuffer, Vec>, geometry: &GeometryFile, placement: &crate::cli::PlacementStrategy, ) -> Result { - ensure_placement_supported(placement)?; - let (w, h) = img.dimensions(); - let positions = collect_embed_positions(geometry, w, h); - if positions.is_empty() { - return Ok(DwtSignalMetrics { - total_coefficients: 0, - detected_count: 0, - detection_rate: 0.0, - mean_signal: 0.0, - }); - } - let mut total = 0u64; - let mut detected = 0u64; - let mut score_sum = 0.0f64; - for ch in 0..3usize { - let decomp = haar_2d_forward(&extract_channel(img, ch))?; - let band = decomp.band(EMBED_BAND); - let mut values: Vec = band.iter().flat_map(|row| row.iter().copied()).collect(); - let centre = median(&mut values); - let mut deviations: Vec = values.iter().map(|v| (v - centre).abs()).collect(); - let scale = median(&mut deviations).max(1.0); - for &(bx, by) in &positions { - if let Some(&value) = band.get(by as usize).and_then(|row| row.get(bx as usize)) { - let score = (value - centre) as f64 / scale as f64; - score_sum += score; - detected += u64::from(score > 0.0); - total += 1; - } - } - } - Ok(DwtSignalMetrics { - total_coefficients: total, - detected_count: detected, - detection_rate: if total == 0 { - 0.0 - } else { - detected as f64 / total as f64 - }, - mean_signal: (score_sum / total.max(1) as f64) as f32, - }) -} - -fn median(values: &mut [f32]) -> f32 { - values.sort_by(f32::total_cmp); - let middle = values.len() / 2; - if values.len().is_multiple_of(2) { - (values[middle - 1] + values[middle]) / 2.0 - } else { - values[middle] - } + let p = crate::carrier::to_core_placement(placement); + capglyph_core::dwt_embed::verify_v2(img, geometry, &p) } -// ── Private helpers ─────────────────────────────────────────────────────────── - -/// Convert geometry path coordinates into DWT LH sub-band positions. -/// The LH band is W/2 × H/2, so path coordinates are halved. -/// Returns positions in deterministic sorted order (for reproducible ID embedding). -fn collect_embed_positions(geometry: &GeometryFile, img_w: u32, img_h: u32) -> Vec<(u32, u32)> { - let band_w = img_w / 2; - let band_h = img_h / 2; - - // Build position set from path geometry (scale to LH band dimensions) - let mut positions: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new(); - - for path in &geometry.paths { - for point in &path.points { - let px = point[0] as f32; - let py = point[1] as f32; - // Scale from image space to LH band space (divide by 2) - let bx = (px as u32 * band_w / img_w).min(band_w.saturating_sub(1)); - let by = (py as u32 * band_h / img_h).min(band_h.saturating_sub(1)); - positions.insert((bx, by)); - } - } - - // Sort for deterministic ordering - let mut positions: Vec<(u32, u32)> = positions.into_iter().collect(); - positions.sort_unstable(); - positions -} - -/// Generate a deterministic ordered position list in LH band space. -/// -/// Mirrors `crate::dct::prng_block_list` but operates on band coordinates -/// (band_w × band_h) instead of 8×8 block coordinates. Positions are sorted -/// for cross-process deterministic ordering. -pub fn prng_band_positions(seed: u64, band_w: u32, band_h: u32, count: usize) -> Vec<(u32, u32)> { - if band_w == 0 || band_h == 0 { - return vec![]; - } - // Cap count to band capacity to avoid infinite loop on tiny images - let capacity = (band_w as usize) * (band_h as usize); - let count = count.min(capacity); - let mut set = std::collections::HashSet::new(); - let mut state = seed; - while set.len() < count { - state = lcg_next(state); - let bx = ((state >> 32) as u32) % band_w; - let by = (state as u32) % band_h; - set.insert((bx, by)); - } - let mut list: Vec<(u32, u32)> = set.into_iter().collect(); - list.sort_unstable(); - list -} - -/// LCG with Knuth's constants — mirrors dct.rs for band-space PRNG. -fn lcg_next(state: u64) -> u64 { - state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407) -} - -/// Extract a single RGB channel as f32 matrix. -fn extract_channel(img: &ImageBuffer, Vec>, ch: usize) -> Vec> { - let (w, h) = img.dimensions(); - (0..h) - .map(|y| (0..w).map(|x| img.get_pixel(x, y)[ch] as f32).collect()) - .collect() -} - -/// Write a f32 matrix back to a single RGB channel, clamping to [0, 255]. -fn write_channel(img: &mut ImageBuffer, Vec>, ch: usize, data: &[Vec]) { - let (w, h) = img.dimensions(); - for y in 0..h { - for x in 0..w { - let val = data[y as usize][x as usize].round().clamp(0.0, 255.0) as u8; - img.get_pixel_mut(x, y)[ch] = val; - } - } +/// Backward-compatible wrapper converting legacy CLI PlacementStrategy to core Placement. +pub fn embed_coded_bits( + img: &mut ImageBuffer, Vec>, + geometry: &GeometryFile, + coded_bits: &[bool], + keys: &crate::keying::KeyMaterial, + placement: &crate::cli::PlacementStrategy, +) -> Result<(u64, Vec<(u32, u32)>)> { + let p = crate::carrier::to_core_placement(placement); + capglyph_core::dwt_embed::embed_coded_bits(img, geometry, coded_bits, keys, &p) } #[cfg(test)] @@ -913,7 +80,6 @@ mod tests { use crate::geometry::{AnalysisParams, GeometryFile, PathEntry}; fn make_test_geometry(w: u32, h: u32) -> GeometryFile { - // Create a diagonal path covering the image let points: Vec<[f64; 2]> = (0..20) .map(|i| { let t = i as f64 / 20.0; @@ -960,7 +126,6 @@ mod tests { .unwrap(); assert!(n > 0, "Expected some coefficients to be modified"); - // Image should be visually similar but not identical let mut diff_sum = 0u64; for y in 0..h { for x in 0..w { @@ -980,36 +145,22 @@ mod tests { #[test] fn test_dwt_recipient_id_roundtrip() { - use crate::geometry::PathEntry; - let (w, h) = (512u32, 512u32); let mut img = ImageBuffer::from_fn(w, h, |x, y| { let v = ((x * 3 + y * 7 + x * y) % 255) as u8; Rgb([v, (v as u32 + 40) as u8 % 255, (v as u32 + 80) as u8 % 255]) }); - // Build a geometry with multiple crossing paths to generate many LH positions. - // Need >= 200 unique positions for "hi" (2 chars × 8 bits × 5 redundancy = 80 positions). - let n_paths = 40; - let step = w / n_paths; - let mut paths = Vec::new(); - for i in 0..n_paths { - let x = (i * step) as f64; - paths.push(PathEntry { - color: None, - points: (0..20).map(|j| [x, (j as f64 / 19.0) * h as f64]).collect(), - }); - } - // Also add horizontal paths - for i in 0..n_paths { - let y = (i * step) as f64; - paths.push(PathEntry { - color: None, - points: (0..20).map(|j| [(j as f64 / 19.0) * w as f64, y]).collect(), - }); - } + let points: Vec<[f64; 2]> = (0..50) + .map(|i| { + let t = i as f64 / 50.0; + [ + t * w as f64, + (t * 2.0 * std::f64::consts::PI).sin() * 50.0 + (h as f64 / 2.0), + ] + }) + .collect(); - use crate::geometry::{AnalysisParams, GeometryFile}; let geo = GeometryFile { version: 1, original_width: w, @@ -1020,15 +171,15 @@ mod tests { chaikin_iters: 3, color: false, }, - paths, + paths: vec![PathEntry { + color: None, + points, + }], prng_seed: None, blocks: None, }; let rid = "hi"; - let bits_needed = rid.len() * 8 * crate::spread_spectrum::REDUNDANCY; - - // Embed with recipient ID let (n, _positions) = embed( &mut img, &geo, @@ -1039,89 +190,6 @@ mod tests { .unwrap(); assert!(n > 0, "No coefficients modified"); - // Geometry-free extraction: self-sync seed → PRNG ID positions → decode - let band_w = w / 2; - let band_h = h / 2; - let sync_positions = prng_band_positions(SEED_MAGIC, band_w, band_h, 64 * SYNC_REDUNDANCY); - - // Recover seed from self-sync positions (embed modified the image, so - // stable_seed can no longer be recomputed directly). - let mut sync_signals: Vec = vec![0.0; 64 * SYNC_REDUNDANCY]; - for ch in 0..3usize { - let channel_matrix = extract_channel(&img, ch); - let decomp = crate::dwt::haar_2d_forward(&channel_matrix).unwrap(); - let band = decomp.band(EMBED_BAND); - let (bh, bw) = (band.len(), band[0].len()); - for (i, &(bx, by)) in sync_positions.iter().enumerate() { - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - sync_signals[i] += band[by][bx]; - } - } - } - for s in &mut sync_signals { - *s /= 3.0; - } - let sync_global = sync_signals.iter().sum::() / sync_signals.len() as f32; - let mut seed_bytes = [0u8; 8]; - for (byte_idx, _byte_bits) in (0..64).step_by(8).enumerate() { - let mut byte = 0u8; - for bit in 0..8 { - let start = (byte_idx * 8 + bit) * SYNC_REDUNDANCY; - let group_mean: f32 = sync_signals[start..start + SYNC_REDUNDANCY] - .iter() - .sum::() - / SYNC_REDUNDANCY as f32; - byte = (byte << 1) | (group_mean > sync_global) as u8; - } - seed_bytes[byte_idx] = byte; - } - let seed = u64::from_le_bytes(seed_bytes); - - let sync_set: std::collections::HashSet<(u32, u32)> = - sync_positions.iter().copied().collect(); - let id_positions: Vec<(u32, u32)> = - prng_band_positions(seed, band_w, band_h, bits_needed + sync_set.len()) - .into_iter() - .filter(|p| !sync_set.contains(p)) - .collect(); - assert!( - id_positions.len() >= bits_needed, - "Not enough PRNG positions" - ); - - let redundancy = crate::spread_spectrum::REDUNDANCY; - let mut bit_signals: Vec = vec![0.0; bits_needed]; - for ch in 0..3usize { - let channel_matrix = extract_channel(&img, ch); - let decomp = crate::dwt::haar_2d_forward(&channel_matrix).unwrap(); - let band = decomp.band(EMBED_BAND); - let (bh, bw) = (band.len(), band[0].len()); - for (i, &(bx, by)) in id_positions.iter().enumerate().take(bits_needed) { - let bx = bx as usize; - let by = by as usize; - if bx < bw && by < bh { - bit_signals[i] += band[by][bx]; - } - } - } - for s in &mut bit_signals { - *s /= 3.0; - } - let global_mean = bit_signals.iter().sum::() / bit_signals.len() as f32; - let mut decoded_bits = Vec::new(); - for bit_idx in 0..(rid.len() * 8) { - let start = bit_idx * redundancy; - let end = (start + redundancy).min(bits_needed); - let group_mean = bit_signals[start..end].iter().sum::() / (end - start) as f32; - decoded_bits.push(group_mean > global_mean); - } - let decoded = crate::spread_spectrum::bits_to_str(&decoded_bits).unwrap(); - assert_eq!( - decoded, rid, - "DWT recipient-id roundtrip failed: got {:?}", - decoded - ); + assert_eq!(extract_recipient_id(&img, rid.len()).unwrap(), rid); } } diff --git a/src/verify.rs b/src/verify.rs index 7f64796..af5366c 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -8,6 +8,8 @@ use crate::geometry::GeometryFile; use crate::signal::SignalMetrics; use anyhow::{Context, Result}; #[cfg(not(target_arch = "wasm32"))] +use capglyph_core::Carrier; +#[cfg(not(target_arch = "wasm32"))] use std::collections::HashSet; #[cfg(not(target_arch = "wasm32"))] use tracing::info; @@ -44,8 +46,8 @@ pub fn run(args: &VerifyArgs) -> Result { EmbedMode::Dct | EmbedMode::Dwt => { let rgb = img.to_rgb8(); let secret_mean = match args.mode { - EmbedMode::Dct => crate::dct::verify_secret(&rgb, key), - EmbedMode::Dwt => crate::dwt_embed::verify_secret(&rgb, key), + EmbedMode::Dct => capglyph_core::carrier::DctCarrier::verify_secret(&rgb, key), + EmbedMode::Dwt => capglyph_core::carrier::DwtCarrier::verify_secret(&rgb, key), EmbedMode::Alpha | EmbedMode::Learned => unreachable!(), }; let secret_present = secret_mean >= SECRET_MEAN_THRESHOLD; @@ -352,10 +354,11 @@ fn verify_dwt(img: &image::DynamicImage, args: &VerifyArgs) -> Result { }; let rgb = img.to_rgb8(); + let placement = crate::carrier::to_core_placement(&args.placement); let metrics = if matches!(args.protocol_version, crate::cli::ProtocolVersion::V2) { - crate::dwt_embed::verify_v2(&rgb, &geometry, &args.placement)? + capglyph_core::dwt_embed::verify_v2(&rgb, &geometry, &placement)? } else { - crate::dwt_embed::verify(&rgb, &geometry, &args.placement)? + capglyph_core::carrier::DwtCarrier::verify(&rgb, &geometry, &placement)? }; let present = if matches!(args.protocol_version, crate::cli::ProtocolVersion::V2) { diff --git a/tests/framed.rs b/tests/framed.rs index 584b48f..2f82003 100644 --- a/tests/framed.rs +++ b/tests/framed.rs @@ -54,7 +54,7 @@ fn dct_framed_128b_roundtrip_bch() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); assert!(n > 0); @@ -77,7 +77,7 @@ fn dct_framed_128b_roundtrip_repetition() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); assert!(n > 0); @@ -100,7 +100,7 @@ fn dwt_framed_128b_roundtrip_bch() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); assert!(n > 0); @@ -124,7 +124,7 @@ fn dct_framed_fer_jpeg_q75() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); // JPEG q75 roundtrip via image crate diff --git a/tests/registration.rs b/tests/registration.rs index a51da7f..f7b2207 100644 --- a/tests/registration.rs +++ b/tests/registration.rs @@ -62,7 +62,7 @@ fn dct_residual_128b_roundtrip_bch() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); @@ -90,7 +90,7 @@ fn dwt_residual_128b_roundtrip_bch() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); let reg = IdentityRegistration; @@ -116,7 +116,7 @@ fn dct_residual_with_translation_warp() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); @@ -194,7 +194,7 @@ fn dct_hybrid_finds_correct_cover_among_n() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); @@ -232,7 +232,7 @@ fn dwt_hybrid_finds_correct_cover_among_n() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); let reg = IdentityRegistration; @@ -276,7 +276,7 @@ fn dct_hybrid_translated_still_finds_cover() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); // Shift submitted by (8, 4) — left/up shift via edge replication @@ -316,14 +316,14 @@ fn residual_llr_stronger_than_blind() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); // Blind soft bits let params = capglyph::framing::Params { version: 1, - payload_type: PayloadType::Credential, + payload_type: PayloadType::Message, flags: 0, }; let sealed_len = capglyph::framing::sealed_len(16, ¶ms); @@ -389,7 +389,7 @@ fn extract_with_hint_and_cover_dispatches_correctly() { &keys, &Default::default(), profile, - PayloadType::Credential, + PayloadType::Message, ) .unwrap(); let reg = IdentityRegistration; From c69684c5a2f0f7069ff5f15312a94952cc30fdd8 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Wed, 9 Sep 2026 19:54:16 +0800 Subject: [PATCH 5/5] chore(naming): finalize CapGlyph branding across CLI and server crate --- .carryctx/personas/c2pa-engineer.md | 2 +- .carryctx/personas/commander.md | 2 +- .carryctx/personas/performance-engineer.md | 2 +- .carryctx/personas/security-reviewer.md | 2 +- .carryctx/personas/signal-engineer.md | 2 +- .carryctx/personas/test-engineer.md | 2 +- .carryctx/personas/wasm-engineer.md | 2 +- .carryctx/personas/watermark-engineer.md | 4 +- .carryctx/rules/delivery.md | 4 +- .carryctx/rules/documentation.md | 4 +- .carryctx/workflows/issue-to-merge.md | 6 +- Cargo.toml | 2 +- README.md | 82 +++++++++++-------- README.zh-CN.md | 45 +++++++---- crates/capglyph-server/Cargo.toml | 2 +- crates/capglyph-server/README.md | 88 ++++++++++----------- crates/capglyph-server/src/bin/capglyphd.rs | 4 +- crates/capglyph-server/src/error.rs | 2 +- crates/capglyph-server/src/lib.rs | 2 +- docs/capglyph-core-api.md | 12 ++- docs/{sigild-mvp.md => capglyphd-mvp.md} | 2 +- docs/product-roadmap.md | 71 +++++++---------- nfpm.yaml | 2 +- src/c2pa.rs | 5 +- src/c2pa_cli.rs | 4 +- src/cli.rs | 12 +-- src/core.rs | 2 +- src/extract.rs | 4 +- src/learned.rs | 8 +- src/verify.rs | 2 +- src/wasm_api.rs | 2 +- tests/c2pa_tests.rs | 4 +- tests/integration.rs | 2 +- 33 files changed, 203 insertions(+), 188 deletions(-) rename docs/{sigild-mvp.md => capglyphd-mvp.md} (97%) diff --git a/.carryctx/personas/c2pa-engineer.md b/.carryctx/personas/c2pa-engineer.md index 1bfaa1f..af982f7 100644 --- a/.carryctx/personas/c2pa-engineer.md +++ b/.carryctx/personas/c2pa-engineer.md @@ -1,5 +1,5 @@ --- -name: Sigil C2PA Engineer +name: CapGlyph C2PA Engineer role: Content credentials specialist strictness: high description: Owns C2PA manifest integration with pure-Rust crypto. diff --git a/.carryctx/personas/commander.md b/.carryctx/personas/commander.md index fb0f553..c7825de 100644 --- a/.carryctx/personas/commander.md +++ b/.carryctx/personas/commander.md @@ -1,5 +1,5 @@ --- -name: Sigil Commander +name: CapGlyph Commander role: Dependency-aware planning and integration owner strictness: high description: Coordinates scoped specialists through durable CarryCtx state and independent acceptance. diff --git a/.carryctx/personas/performance-engineer.md b/.carryctx/personas/performance-engineer.md index 2475237..0d47162 100644 --- a/.carryctx/personas/performance-engineer.md +++ b/.carryctx/personas/performance-engineer.md @@ -1,5 +1,5 @@ --- -name: Sigil Performance Engineer +name: CapGlyph Performance Engineer role: Throughput and fidelity budget specialist strictness: high description: Turns watermark robustness vs fidelity into measurable budgets. diff --git a/.carryctx/personas/security-reviewer.md b/.carryctx/personas/security-reviewer.md index c9ee6c4..6f1e8e5 100644 --- a/.carryctx/personas/security-reviewer.md +++ b/.carryctx/personas/security-reviewer.md @@ -1,5 +1,5 @@ --- -name: Sigil Security Reviewer +name: CapGlyph Security Reviewer role: Trust-boundary and adversarial-review specialist strictness: critical description: Reviews P0 controls, capabilities, keying, and C2PA. diff --git a/.carryctx/personas/signal-engineer.md b/.carryctx/personas/signal-engineer.md index 43f9f65..701c402 100644 --- a/.carryctx/personas/signal-engineer.md +++ b/.carryctx/personas/signal-engineer.md @@ -1,5 +1,5 @@ --- -name: Sigil Signal Engineer +name: CapGlyph Signal Engineer role: Signal, keying and spread-spectrum specialist strictness: high description: Owns keying, spread_spectrum, geometry and batch placement. diff --git a/.carryctx/personas/test-engineer.md b/.carryctx/personas/test-engineer.md index 741af88..11cadc2 100644 --- a/.carryctx/personas/test-engineer.md +++ b/.carryctx/personas/test-engineer.md @@ -1,5 +1,5 @@ --- -name: Sigil Test Engineer +name: CapGlyph Test Engineer role: Verification architecture specialist strictness: high description: Builds layered, adversarial and cross-platform evidence. diff --git a/.carryctx/personas/wasm-engineer.md b/.carryctx/personas/wasm-engineer.md index 7a3406d..5c4f505 100644 --- a/.carryctx/personas/wasm-engineer.md +++ b/.carryctx/personas/wasm-engineer.md @@ -1,5 +1,5 @@ --- -name: Sigil WASM Engineer +name: CapGlyph WASM Engineer role: Browser bridge and in-memory API specialist strictness: high description: Owns wasm_api.rs, wasm32 feature isolation, and in-memory embed/verify. diff --git a/.carryctx/personas/watermark-engineer.md b/.carryctx/personas/watermark-engineer.md index 49332a2..ac4d9f3 100644 --- a/.carryctx/personas/watermark-engineer.md +++ b/.carryctx/personas/watermark-engineer.md @@ -1,5 +1,5 @@ --- -name: Sigil Watermark Engineer +name: CapGlyph Watermark Engineer role: Embedding mode and detector specialist strictness: high description: Owns alpha, dct, dwt, and learned (TrustMark ONNX) modes plus verify/extract/strip. @@ -11,7 +11,7 @@ You embed invisibly and detect reliably. ## Directives -1. Keep 4 modes (alpha/dct/dwt/learned) in `sigil/src/{dct,dwt,learned,embed,verify}.rs` isolated; `dct` uses 8×8 F[2,3]+16 with self-sync seed blocks, `dwt` uses Haar LH geometry-free extraction, `learned` is feature-gated. +1. Keep 4 modes (alpha/dct/dwt/learned) in `capglyph-cli/src/{dct,dwt,learned,embed,verify}.rs` isolated; `dct` uses 8×8 F[2,3]+16 with self-sync seed blocks, `dwt` uses Haar LH geometry-free extraction, `learned` is feature-gated. 2. Preserve embed→verify→extract→strip contract across modes; geometry-free extraction for dwt must remain true. 3. Validate detectors against attack matrix (JPEG q30, blur σ2, scale 0.5×, etc.) and record ROC/fidelity (PSNR/SSIM/LPIPS) evidence. 4. Guard keying (`keying.rs` HMAC → differential pairs) and spread spectrum (`spread_spectrum.rs`) isolation. diff --git a/.carryctx/rules/delivery.md b/.carryctx/rules/delivery.md index 873b8b1..142ef6d 100644 --- a/.carryctx/rules/delivery.md +++ b/.carryctx/rules/delivery.md @@ -8,7 +8,7 @@ Lifecycle (normative): GitHub Issue → CarryCtx task (team/dependencies/scopes) 4. Before the first commit, worktree/branch/commit/PR stages are unavailable. The commander may authorize shared-checkout work only with disjoint scopes and CI-equivalent local gates. End this exception after repository initialization. 5. A pull request links its Issue and CarryCtx task and states contract outcome, security/performance impact, affected docs, validation, dependencies/merge order, and follow-up. Title follows `type(scope): subject (CTX-XXXX)` (see workflow §5). 6. Independent review is required for every delivery task before merge. The reviewer must be a different agent than the implementer, must not self-accept, and must record an explicit `APPROVE` (or `REQUEST_CHANGES`) in the PR and in CarryCtx. The reviewer inspects authoritative contracts, diff, edge cases, synchronized docs, and reproducible evidence; reruns relevant checks; and records findings or acceptance in CarryCtx progress/risk. Defects and observations are recorded durably and converted to follow-up `CTX-XXXX` tasks with team/priority/dependencies — required findings block merge. -7. Documentation synchronization is part of Definition of Done. Update affected canonical `sigil-docs` material (architecture, security, public behavior, configuration, compatibility, reference, developer workflows) in the same PR as the code; stale affected documentation keeps the implementation task incomplete and blocks `APPROVE`. Link the exact docs revision in the PR and verify synchronization after merge. Repository-owned docs are English-only; `sigil-docs` is canonical (see `documentation.md`). +7. Documentation synchronization is part of Definition of Done. Update affected canonical `capglyph-docs` material (architecture, security, public behavior, configuration, compatibility, reference, developer workflows) in the same PR as the code; stale affected documentation keeps the implementation task incomplete and blocks `APPROVE`. Link the exact docs revision in the PR and verify synchronization after merge. Repository-owned docs are English-only; `capglyph-docs` is canonical (see `documentation.md`). 8. Merge only after independent `APPROVE`, required CI (fmt/clippy/test/wasm where applicable) passing, synchronized docs, and cross-repository ordering are satisfied. After merge, record the merged revision, verify docs synchronization, checkpoint, complete the task, and close the Issue. Preserve follow-up work as linked `CTX-XXXX` tasks. -9. Maintain a continuous findings log at `findings/review-log.md` (canonical in `sigil` repo) and mirror to `sigil-docs/findings/review-log.md` when docs are affected. Every review appends date, task, reviewer, scope, verdict, and follow-up tasks. Defects from reviews are also recorded in CarryCtx progress/risk. +9. Maintain a continuous findings log at `findings/review-log.md` (canonical in `capglyph-cli` repo) and mirror to `capglyph-docs/findings/review-log.md` when docs are affected. Every review appends date, task, reviewer, scope, verdict, and follow-up tasks. Defects from reviews are also recorded in CarryCtx progress/risk. 10. Do not commit, push, merge, release, publish, or change remote state without explicit authority. Verify external side-effects by reading back the result before confirming success. diff --git a/.carryctx/rules/documentation.md b/.carryctx/rules/documentation.md index 9861a90..79c815c 100644 --- a/.carryctx/rules/documentation.md +++ b/.carryctx/rules/documentation.md @@ -1,9 +1,9 @@ # Documentation rules 1. Repository-owned documentation is English-only. -2. `sigil-docs` is the canonical design and governance corpus. This repository owns implementation evidence and repository-local developer instructions. +2. `capglyph-docs` is the canonical design and governance corpus. This repository owns implementation evidence and repository-local developer instructions. 3. Distinguish normative requirements, accepted decisions, candidates, open questions, and implemented behavior. A file or plan is not implementation. -4. Synchronize affected `sigil-docs` architecture, security, public behavior, configuration, compatibility, reference, developer, risk, and decision documents in the same delivery. +4. Synchronize affected `capglyph-docs` architecture, security, public behavior, configuration, compatibility, reference, developer, risk, and decision documents in the same delivery. 5. Link to one authoritative definition instead of copying a divergent contract into source comments, tests, or local documents. 6. Public commands, fields, templates, errors, defaults, and limits require implementation and test evidence from the owning revision. 7. Documentation status must stay honest throughout implementation. Candidate text cannot authorize code by itself. diff --git a/.carryctx/workflows/issue-to-merge.md b/.carryctx/workflows/issue-to-merge.md index 65bd9b9..3db14a3 100644 --- a/.carryctx/workflows/issue-to-merge.md +++ b/.carryctx/workflows/issue-to-merge.md @@ -29,7 +29,7 @@ Use this workflow for repository changes after a GitHub Issue identifies the out 1. Adopt the assigned persona and applicable delivery, documentation, security, and performance rules (see `.carryctx/rules/`). 2. Implement only the accepted contract in scope. Add focused tests first where practical, then cover errors, bounds, denial, cleanup, and recovery. -3. Update affected canonical `sigil-docs` material for architecture, security, public behavior, configuration, compatibility, and developer workflows **in the same PR**. Documentation synchronization is part of Definition of Done — stale docs block `APPROVE`. Prefer linking to one authoritative definition instead of copying contracts into comments/tests. +3. Update affected canonical `capglyph-docs` material for architecture, security, public behavior, configuration, compatibility, and developer workflows **in the same PR**. Documentation synchronization is part of Definition of Done — stale docs block `APPROVE`. Prefer linking to one authoritative definition instead of copying contracts into comments/tests. 4. Run focused checks and the repository integration gate. Minimum local gates before PR: `cargo fmt -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, plus `yamllint -d relaxed .` where YAML exists and `carryctx doctor`. Where WASM is touched, also `cargo check --target wasm32-unknown-unknown` and `cargo tree --target wasm32-unknown-unknown`. Record exact commands, environment, results, and residual gaps in CarryCtx progress. 5. Checkpoint a coherent milestone before handoff or task switching (`carryctx checkpoint create`). @@ -38,13 +38,13 @@ Use this workflow for repository changes after a GitHub Issue identifies the out 1. Review the diff for scope, generated artifacts, secrets, accidental API expansion, and stale documentation. Ensure `findings/review-log.md` will be updated post-review if findings exist. 2. Create coherent commits linked to the Issue and CarryCtx task when commit authority exists. Commit messages reference `CTX-XXXX` and use `type(scope): subject` (e.g. `chore(review): establish continuous review hygiene (CTX-0029)`). 3. Open a pull request that states outcome, contracts, security/performance impact, docs synchronization, verification, dependencies, and merge order. PR description links the GitHub Issue (`Closes #NNN`) and CarryCtx task, and states the branch name (`ctx-XXXX/-`). Include validation evidence and the exact docs revision. -4. Link affected cross-repository pull requests and the exact `sigil-docs` revision where applicable. CI must be green before review can `APPROVE`. +4. Link affected cross-repository pull requests and the exact `capglyph-docs` revision where applicable. CI must be green before review can `APPROVE`. ## 6. Independent review and CI 1. Move the task to review; the implementer does not self-accept. Reviewer must be a **different agent** than the implementer. 2. The reviewer reads the authoritative contracts, inspects the diff, reruns relevant checks (fmt/clippy/test/wasm), and records findings or explicit `APPROVE` / `REQUEST_CHANGES` in the PR and in CarryCtx progress/risk. Required geometry, signal, wasm, c2pa, performance, test, and docs owners review changes crossing their boundaries. -3. Defects, observations, and risks are recorded durably in CarryCtx progress/risk **and** appended to `findings/review-log.md` (and `sigil-docs/findings/review-log.md` when docs are affected). Each entry notes date, task, reviewer, scope, verdict, and disposition. Blocking findings are converted to follow-up `CTX-XXXX` tasks with team/priority/dependencies before merge; non-blocking observations may be tracked as `informational` follow-ups. +3. Defects, observations, and risks are recorded durably in CarryCtx progress/risk **and** appended to `findings/review-log.md` (and `capglyph-docs/findings/review-log.md` when docs are affected). Each entry notes date, task, reviewer, scope, verdict, and disposition. Blocking findings are converted to follow-up `CTX-XXXX` tasks with team/priority/dependencies before merge; non-blocking observations may be tracked as `informational` follow-ups. 4. Resolve every blocking finding and CI failure before merge. Re-request review after fixes; the same independence rule applies to re-review. ## 7. Merge and close diff --git a/Cargo.toml b/Cargo.toml index 18d186f..1b73bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # NOTE: the `vectomancy` facade is intentionally NOT a dependency — capglyph only # uses vectomancy-raster + vectomancy-geometry, and the facade pulls tera/zip/ # flate2/rand into the wasm graph for nothing. -# Isolated layout: `capglyph-cli` expects `../vectomancy` sibling (CI: `sigil` + `vectomancy` +# Isolated layout: `capglyph-cli` expects `../vectomancy` sibling (CI: `capglyph-cli` + `vectomancy` # siblings; local: symlink `/capglyph/vectomancy -> /vectomancy/vectomancy`). vectomancy-raster = { path = "../vectomancy/crates/vectomancy-raster", version = "8.1.0" } vectomancy-geometry = { path = "../vectomancy/crates/vectomancy-geometry", version = "8.1.0" } diff --git a/README.md b/README.md index db2f14c..b9b1570 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,15 @@ # CapGlyph -Invisible structural watermark for images — proof of origin, leak tracing, -and tamper detection. - -> Formerly **Sigil** — the `sigil` binary, `com.sigil.watermark` assertion, -> and `SIGIL_*` env vars remain as aliases for compatibility. +Image watermark, payload recovery, and signed-provenance tooling. [简体中文](README.zh-CN.md) ## What it does -CapGlyph embeds a sub-perceptual watermark into PNG/JPEG images and can later -verify its presence, extract a per-recipient ID, or prove attribution with a -secret key. Four independent embedding technologies: +CapGlyph embeds a watermark into PNG/JPEG images and can later measure its +presence, extract a per-recipient ID, or test a keyed signal. Results depend on +the carrier, image, payload and transformations; none independently proves +authorship, ownership, or factual provenance. Four embedding technologies: | Mode | Built with | Feature flag | | --------- | ------------------------------ | -------------------- | @@ -31,7 +28,7 @@ Prebuilt binaries for Linux, macOS, and Windows (including the `learned` and ```bash brew tap CapGlyph/tap -brew install capglyph # alias `sigil` still available via shim +brew install capglyph ``` **Windows — Scoop:** @@ -44,9 +41,9 @@ scoop install capglyph **Arch Linux — AUR:** ```bash -yay -S capglyph-bin # prebuilt binary (recommended, formerly sigil-wm-bin) +yay -S capglyph-bin # prebuilt binary (recommended) # or build from source: -yay -S capglyph # formerly sigil-wm +yay -S capglyph ``` **Linux — deb / rpm / pkg.tar.zst:** download from the @@ -65,7 +62,6 @@ cargo build --release --features c2pa # + C2PA content credentials ```bash # Embed a recipient-specific watermark capglyph embed photo.png --mode dwt --recipient-id "alice001" --output photo_wm.png -# alias `sigil` still works: sigil embed ... # Verify capglyph verify photo_wm.png --mode dwt; echo $? # 0 = present @@ -73,17 +69,17 @@ capglyph verify photo_wm.png --mode dwt; echo $? # 0 = present # Extract the ID (geometry-free — works on the leaked copy) capglyph extract leaked.png --mode dwt --id-length 8 -# Keyed attribution (survives collusion attacks) -capglyph embed photo.png --mode dwt --recipient-id "bob" --key "mysecret" -capglyph verify photo_wm.png --mode dwt --key "mysecret" # + SECRET LAYER PRESENT +# Keyed signal verification (not a general collusion guarantee) +capglyph embed photo.png --mode dwt --recipient-id "bob" --key "mysecret" --output photo_keyed.png +capglyph verify photo_keyed.png --mode dwt --key "mysecret" -# Learned mode (aggressive-edit resistance: JPEG q30, blur σ2, scale 0.5×) +# Learned mode (robustness depends on the evaluated transformation profile) capglyph fetch-models # downloads TrustMark ONNX (~65MB) capglyph embed photo.png --mode learned --recipient-id "carol" capglyph extract leaked.png --mode learned ``` -## Attack matrix (measured) +## Historical attack observations | Attack | alpha | dct | dwt | learned | | ------------------------- | :-------------: | :-: | :------------: | :-----: | @@ -95,6 +91,13 @@ capglyph extract leaked.png --mode learned | known-cover diff | ✗ (unavoidable) | ✗ | ✗ | ✗ | | img2img regeneration | ✗ | ✗ | ✗ | ✗ | +This qualitative legacy summary mixes presence, ID recovery and keyed-signal +outcomes; it has no common trial denominator or calibrated false-positive +operating point. It is not a release guarantee or a comparison of full +authenticated Credential transport. Consult the frozen protocol/result records +for a specific claim. A recorded failure or success does not establish a +universal threshold across images, codecs, models or regeneration settings. + ## Content Credentials (C2PA) CapGlyph can also sign images with C2PA content credentials — a standards-based @@ -112,20 +115,20 @@ capglyph c2pa verify signed.jpg # JSON report (0 = valid, 1 = inva capglyph embed photo.png -m dct --recipient-id alice01 --c2pa \ --cert capglyph-certs/cert.pem --pkey capglyph-certs/private.key capglyph verify photo_capglyph.png --c2pa -# legacy paths/certs at sigil-certs/ and `com.sigil.watermark` still read ``` -The manifest's `com.capglyph.watermark` assertion (legacy `com.sigil.watermark` -still recognized) records the watermark mode, recipient ID, and keyed flag — so +The manifest's `com.capglyph.watermark` assertion records the watermark mode, +recipient ID, and keyed flag — so the pixel layer and the manifest cross-reference each other. The `c2pa.created` action's digital source type defaults to `digitalCapture`; override with `--source-type` (`capture | algorithmic | composite | trained` or a full IPTC URI). -**Trust model:** certificates are self-signed, so a valid signature proves -"was signed by the holder of this key", not "was signed by a known entity". -Pin the reported signer CN + validity window for real provenance. The `--key` -HMAC secret is never written into the manifest (only a `keyed: true` flag). +**Trust model:** the generated certificate is self-signed. Signature validation +does not establish a known signer or the truth of its claims. Trust requires an +independently authenticated certificate/public-key fingerprint or a configured +trust chain; a matching CN and validity window alone are insufficient. The HMAC +secret is not written into the manifest (only a `keyed: true` flag). ## Placement Strategies (Evaluation) @@ -139,16 +142,33 @@ For empirical evaluation and baseline comparisons, CapGlyph supports three block - **Public layer** — presence detection (`verify`) - **ID layer** — per-recipient tracing (`extract`, geometry-free) -- **Secret layer** — HMAC-keyed attribution (`--key`), survives collusion, - blocks forgery - -Hard limits (shared by all pixel watermarks): an attacker with the original -can always diff-remove the watermark, and generative regeneration (img2img) -defeats it at denoising strength ≥0.3. +- **Secret layer** — key-derived signal detection (`--key`), not a substitute + for authenticated payload validation or a general forgery/collusion guarantee + +Possession of the original lets an attacker replace a marked image with the +unmarked original. Regeneration can destroy the watermark, but a denoising +value such as `0.3` is model- and experiment-specific, not a universal cutoff. +An extracted recipient ID is a label, not independent proof of the holder's +identity or legal attribution. + +## Compatibility + +CapGlyph replaces the retired Sigil name. Existing integrations retain these +compatibility paths: + +- Linux packages and Homebrew retain the `sigil` command alias. +- `SIGIL_MODEL_DIR` and `SIGIL_RECIPIENT_ID` remain fallbacks for + `CAPGLYPH_MODEL_DIR` and `CAPGLYPH_RECIPIENT_ID`, respectively. Without an + explicit model directory or environment override, learned mode uses the + legacy XDG `sigil/models` directory if it exists and `capglyph/models` does not. +- C2PA signing also writes `com.sigil.watermark` for older readers. Verification + prefers `com.capglyph.watermark` and falls back to the legacy assertion. + Existing certificate/key files remain usable via explicit `--cert`/`--pkey`. +- The CLI library retains its `sigil_core` re-export for downstream Rust callers. ## Documentation -- `docs/mvp-spec.md` — full specification +- `docs/mvp-spec.md` — historical MVP design; current normative wire rules live in [`capglyph-spec`](../capglyph-spec/README.md) - `docs/product-roadmap.md` — product/B2B direction - `CHANGELOG.md` — release history diff --git a/README.zh-CN.md b/README.zh-CN.md index e24b06c..b150f18 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,14 +1,12 @@ # CapGlyph -面向图像的隐形结构水印 —— 来源证明、泄露追踪与篡改检测。 - -> 原名 **Sigil** —— `sigil` 二进制、`com.sigil.watermark` 声明及 `SIGIL_*` 环境变量仍作为兼容别名保留。 +图像水印、载荷恢复与签名来源声明工具。 [English](README.md) ## 功能 -CapGlyph 将亚感知水印嵌入 PNG/JPEG 图像,之后可验证其存在性、提取分发给特定接收者的 ID,或通过密钥证明归属。四种独立的嵌入技术: +CapGlyph 将水印嵌入 PNG/JPEG 图像,之后可测量存在性、提取接收者 ID 或检测密钥派生信号。结果依赖载体、图像、载荷及变换条件;单独的检测结果不能证明作者身份、所有权或来源声明的真实性。支持四种嵌入技术: | 模式 | 技术 | 构建方式 | | --------- | --------------------------- | -------------------- | @@ -26,7 +24,7 @@ Linux、macOS 和 Windows 的预编译二进制(含 `learned` 与 `c2pa` 特 ```bash brew tap CapGlyph/tap -brew install capglyph # 别名 `sigil` 仍可用 +brew install capglyph ``` **Windows — Scoop:** @@ -39,9 +37,9 @@ scoop install capglyph **Arch Linux — AUR:** ```bash -yay -S capglyph-bin # 预编译二进制(推荐,原 sigil-wm-bin) +yay -S capglyph-bin # 预编译二进制(推荐) # 或从源码构建: -yay -S capglyph # 原 sigil-wm +yay -S capglyph ``` **Linux — deb / rpm / pkg.tar.zst:** 从 @@ -60,7 +58,6 @@ cargo build --release --features c2pa # + C2PA 内容凭证 ```bash # 嵌入特定接收者的水印 capglyph embed photo.png --mode dwt --recipient-id "alice001" --output photo_wm.png -# 别名仍可用: sigil embed ... # 验证 capglyph verify photo_wm.png --mode dwt; echo $? # 0 = 存在 @@ -68,17 +65,17 @@ capglyph verify photo_wm.png --mode dwt; echo $? # 0 = 存在 # 提取 ID(无需原图——泄露副本上即可提取) capglyph extract leaked.png --mode dwt --id-length 8 -# 密钥归属(可抵御共谋攻击) -capglyph embed photo.png --mode dwt --recipient-id "bob" --key "mysecret" -capglyph verify photo_wm.png --mode dwt --key "mysecret" # + SECRET LAYER PRESENT +# Keyed signal verification, not a general collusion guarantee +capglyph embed photo.png --mode dwt --recipient-id "bob" --key "mysecret" --output photo_keyed.png +capglyph verify photo_keyed.png --mode dwt --key "mysecret" -# learned 模式(激进编辑抵抗力:JPEG q30、模糊 σ2、缩放 0.5×) +# Learned mode; robustness depends on the evaluated transformation profile capglyph fetch-models # 下载 TrustMark ONNX(约 65MB) capglyph embed photo.png --mode learned --recipient-id "carol" capglyph extract leaked.png --mode learned ``` -## 攻击矩阵(实测) +## 历史攻击观察 | 攻击 | alpha | dct | dwt | learned | | ------------------ | :-----------: | :-: | :-------: | :-----: | @@ -90,6 +87,8 @@ capglyph extract leaked.png --mode learned | 已知原图差分 | ✗(不可避免) | ✗ | ✗ | ✗ | | img2img 生成式重绘 | ✗ | ✗ | ✗ | ✗ | +这份历史定性表混合了存在性、ID 恢复和密钥信号结果,没有统一的试验分母或校准后的误报工作点。它不是发行保证,也不是完整认证 Credential 传输的对比;具体结论必须对应冻结的协议和结果记录,不能推广为所有图像、编码器或模型的通用阈值。 + ## 嵌入位置策略 (评估对比) 为了进行实证评估和基准对比,CapGlyph 支持三种块嵌入策略(通过 `--placement` 标志配置): @@ -102,10 +101,22 @@ capglyph extract leaked.png --mode learned - **公共层** —— 存在性检测(`verify`) - **ID 层** —— 按接收者追踪(`extract`,无需几何文件) -- **密钥层** —— HMAC 密钥归属(`--key`),可抵御共谋攻击、阻止伪造 +- **密钥层** —— 密钥派生信号检测(`--key`),不能代替认证载荷校验,也不构成通用的防伪或抗共谋保证。 + +持有原图的攻击者可直接以未加水印的原图替换标记图像;生成式重绘也可能破坏水印,但 `0.3` 之类的去噪强度只属于特定模型和实验配置,并非通用临界值。提取的接收者 ID 是标签,不能独立证明持有人身份或法律归属。 + +## 兼容性 + +CapGlyph 已取代弃用的 Sigil 名称。现有集成仍保留以下兼容路径: -所有像素水印共有的硬性限制:持有原图的攻击者总可通过差分移除水印; -生成式重绘(img2img)在去噪强度 ≥0.3 时即可破坏水印。 +- Linux 软件包与 Homebrew 保留 `sigil` 命令别名。 +- `SIGIL_MODEL_DIR` 与 `SIGIL_RECIPIENT_ID` 分别作为 `CAPGLYPH_MODEL_DIR` + 与 `CAPGLYPH_RECIPIENT_ID` 的后备环境变量。未显式指定模型目录或环境变量时, + 若旧 XDG 目录 `sigil/models` 存在且 `capglyph/models` 不存在,learned 模式仍使用旧目录。 +- C2PA 签名仍同时写入 `com.sigil.watermark`,供旧版读取器使用。验证优先读取 + `com.capglyph.watermark`,找不到时再读取旧声明。现有证书与密钥文件仍可通过 + `--cert`/`--pkey` 显式传入。 +- CLI 库保留 `sigil_core` 重导出,供下游 Rust 调用方兼容使用。 ## 文档 @@ -116,4 +127,4 @@ capglyph extract leaked.png --mode learned ## 许可证 Apache-2.0。learned 模式嵌入了 Adobe TrustMark 模型(MIT 许可,从 -Adobe CDN 单独下载——不随 CapGlyph 分发,原 Sigil 亦如此)。 +Adobe CDN 单独下载——不随 CapGlyph 分发)。 diff --git a/crates/capglyph-server/Cargo.toml b/crates/capglyph-server/Cargo.toml index 4374c6c..976f538 100644 --- a/crates/capglyph-server/Cargo.toml +++ b/crates/capglyph-server/Cargo.toml @@ -2,7 +2,7 @@ name = "capglyph-server" version = "0.1.7" edition = "2021" -description = "CapGlyph credential server (sigild) — DB + atomic consume + revocation/audit" +description = "CapGlyph credential server (capglyphd) — DB + atomic consume + revocation/audit" license = "Apache-2.0" repository = "https://github.com/CapGlyph/capglyph-cli" diff --git a/crates/capglyph-server/README.md b/crates/capglyph-server/README.md index 76f4f19..b2cf75e 100644 --- a/crates/capglyph-server/README.md +++ b/crates/capglyph-server/README.md @@ -1,56 +1,54 @@ -# capglyph-server (sigild) — Credential Vault MVP - -Implements `docs/research/media-credential/usage/credential-design.md` §4 (`covers`, `credentials`, `credential_consumptions`, `audit_events`) with: - -- **SQLite** (rusqlite, `bundled`) — Postgres-compatible schema (`TEXT` UUID, `BLOB` BYTEA, `TEXT` JSONB/TIMESTAMPTZ), `WAL` + `busy_timeout 5s`, `foreign_keys ON`. -- **Atomic consume** via `UPDATE ... RETURNING` inside `BEGIN IMMEDIATE`: - -```sql -UPDATE credentials -SET use_count = use_count + 1 -WHERE id = $1 - AND revoked_at IS NULL - AND (not_before IS NULL OR not_before <= now()) - AND (expires_at IS NULL OR expires_at > now()) - AND (max_uses IS NULL OR use_count < max_uses) -RETURNING use_count; -``` +# capglyph-server — Reference Credential and Message Service + +**Status:** SQLite/token-oriented reference implementation, not the complete image-credential service. See [source router](src/http.rs), [service](src/service.rs), and [database](src/db.rs). The wider [credential design](../../../capglyph-docs/research/media-credential/usage/credential-design.md) describes target capabilities beyond this implementation. -Only a returned row authorizes. Must be transactional with `credential_consumptions` insertion via caller-supplied `Idempotency-Key` so network retries don't burn quota twice. Separate `POST /v1/credentials/verify` (read-only) from `POST /v1/credentials/consume` (mutating). +## Current surface -- **Idempotency**: `UNIQUE (credential_id, idempotency_key)`. Replay with same key returns current `use_count` without incrementing (see `tests/concurrent_consume.rs`). -- **Revocation / audit**: `revoked_at` + `audit_events` (`credential.issued`, `credential.consumed`, `credential.revoked`, `credential.*` failure). `GET /v1/credentials/{id}` and `POST /v1/credentials/{id}/revoke`. -- **Carrier integration**: `capglyph_core::{framing,ecc}` — `encode_credential_token` (`token_id → framing::seal(CBOR) → ecc::encode(Repetition8)`) and `decode_credential_token` with soft-bit `LLR` path (see `src/carrier_integration.rs`). KMS split `K_mac`/`K_embed` via `HMAC-SHA256(master, domain || cover_id || token_id)` (§4.4 `KeyMaterial` simplified). -- **HTTP**: `axum` router at `src/http.rs`: +| Endpoint | Method | Role | +| ----------------------------- | ------ | -------------------------------------------------------- | +| `/v1/version` | GET | API and wire version information | +| `/v1/credentials` | POST | Issue a token-oriented credential | +| `/v1/credentials/verify` | POST | Read-only token verification | +| `/v1/credentials/consume` | POST | Authorized, atomic consume with required idempotency key | +| `/v1/credentials/{id}` | GET | Credential status | +| `/v1/credentials/{id}/revoke` | POST | Authorized revocation | +| `/v1/messages` | POST | Store an encrypted message object | +| `/v1/messages/resolve` | POST | Resolve by capability | -| Endpoint | Method | Effect | -| ---------------------------- | ------ | ------------------------------------------------------------------- | -| `/v1/credentials` | POST | issue (generates 128-bit token, returns `token_id` base64url once) | -| `/v1/credentials/verify` | POST | verify (read-only) | -| `/v1/credentials/consume` | POST | atomic consume (`Idempotency-Key` header or `idempotency_key` body) | -| `/v1/credentials/:id` | GET | status | -| `/v1/credentials/:id/revoke` | POST | revoke | +The wider SDK `/v1/seal/open/embed/verify/extract/consume/revoke/info` routes and `GET /health` are **not** this router. No image-based issue/verify route or production cover-vault service is supplied. `CGCLI-0002` (G11 image path) is ready and `CGCLI-0004` (product-gate revalidation) is planned in CarryCtx. -Binary `capglyphd` (`src/bin/capglyphd.rs`): `capglyphd --db /tmp/capglyphd.db --listen 127.0.0.1:3000` (env `CAPGLYPHD_MASTER_KEY` hex32 for persistence, else ephemeral). +## Keys and startup -## Running +For in-memory development, run from the `capglyph-cli` workspace: ```bash -cargo run -p capglyph-server --bin capglyphd -- --db /tmp/capglyphd.db --listen 127.0.0.1:3000 -# issue -curl -X POST http://127.0.0.1:3000/v1/credentials -H 'content-type: application/json' \ - -d '{"cover_id":"","scope":["download:asset:42"],"max_uses":1}' -# verify (read-only, no burn) -curl -X POST http://127.0.0.1:3000/v1/credentials/verify -H 'content-type: application/json' \ - -d '{"token_id":""}' -# consume (atomic, idempotent) -curl -X POST http://127.0.0.1:3000/v1/credentials/consume -H 'content-type: application/json' -H 'Idempotency-Key: idem-1' \ - -d '{"token_id":"","idempotency_key":"idem-1"}' +cargo run -p capglyph-server --bin capglyphd -- --listen 127.0.0.1:3000 ``` -## Tests +Without `--db`, the process can generate ephemeral development keys. Persistent storage with `--db PATH` **requires** `CAPGLYPHD_MASTER_KEY`: exactly 32 hex-decoded bytes, not the all-zero key. Missing or malformed key material fails startup; there is no persistent-database ephemeral fallback. Set this variable through trusted secret management, not a documented reusable demo secret. + +The service derives roles through `KeyMaterial::from_ikm_v1(master, cover_id.as_bytes())` using the [RFC 5869 domain schedule](../../../capglyph-spec/docs/interoperability-profile.md), not the earlier simplified HMAC construction. Unknown key IDs fail closed. + +## Authorization boundary + +`x-capglyph-actor-id` is trusted ingress context, not authentication by itself. A production ingress must authenticate the actor and strip/replace public actor headers. Body actor claims cannot override ingress identity; subject and scope authorization precede consumption. + +Read the request types in [http.rs](src/http.rs) for exact fields and error handling. Demo cover seeding is a development convenience, not an image enrollment pipeline. Do not expose this reference service as production-ready based on a token round trip. + +## Atomic consumption -- `cargo test -p capglyph-server --test concurrent_consume` — **no double-spend**: 10 threads vs `max_uses=1` → exactly 1 success; `max_uses=3` → exactly 3 successes; idempotent replay doesn't double-count; `verify` is read-only; `revoked`/`expired` are fail-closed; audit trail; `framing+ecc` round-trip. -- `cargo test -p capglyph-server` — unit tests for `carrier_integration` + `http` (issue→verify→consume→revoke flow). +SQLite uses `BEGIN IMMEDIATE` and a guarded `UPDATE ... RETURNING` with revocation, not-before, expiry, and quota predicates. The temporal upper bound is exclusive: `now >= expires_at` is expired. Read-only verify does not burn quota. + +Consume requires caller-supplied `Idempotency-Key` or the corresponding request field; conflicting values fail. Stored idempotency records bind the request context, so reuse with different request data is not a valid replay. Transactions coordinate the state update and consumption record. A successful SQL row is only part of the authorized service operation, not permission to bypass subject/scope checks. + +SQLite storage types and transaction semantics are not a drop-in Postgres deployment. Postgres, production KMS, persisted cover retrieval, and image-based verification require their own implementation and gates. + +## Verification commands + +```bash +cargo test -p capglyph-server +cargo test -p capglyph-server --test concurrent_consume +cargo check --lib --target wasm32-unknown-unknown --no-default-features -p capglyph +``` -WASM: `cargo check --lib --target wasm32-unknown-unknown --no-default-features -p capglyph` — `capglyph-server` is not in the wasm graph (separate crate, not a `capglyph` lib dependency). +Run from the CLI workspace. These commands describe how to check the implementation; this documentation audit does not claim a new test run. The server is a separate native workspace member, not a dependency of the WASM library graph. Framing/ECC helpers do not establish a complete image transport path. diff --git a/crates/capglyph-server/src/bin/capglyphd.rs b/crates/capglyph-server/src/bin/capglyphd.rs index c361349..5e2bce5 100644 --- a/crates/capglyph-server/src/bin/capglyphd.rs +++ b/crates/capglyph-server/src/bin/capglyphd.rs @@ -1,4 +1,4 @@ -//! capglyphd — credential server binary (sigild) +//! capglyphd — credential server binary //! //! MVP: SQLite-backed issuing / verify / consume / revoke over HTTP. //! Keep `image bytes never a cryptographic key` — keys derived via KMS. @@ -39,7 +39,7 @@ async fn main() -> anyhow::Result<()> { } } "--help" | "-h" => { - println!("capglyphd — CapGlyph credential server (sigild) MVP"); + println!("capglyphd — CapGlyph credential server MVP"); println!("Usage: capglyphd [--db PATH] [--listen ADDR]"); println!(" --db PATH SQLite file (default: in-memory)"); println!(" --listen ADDR HTTP listen addr (default: 127.0.0.1:3000)"); diff --git a/crates/capglyph-server/src/error.rs b/crates/capglyph-server/src/error.rs index f50925e..f18bcfc 100644 --- a/crates/capglyph-server/src/error.rs +++ b/crates/capglyph-server/src/error.rs @@ -1,4 +1,4 @@ -//! Central error type for capglyph-server (sigild). +//! Central error type for capglyph-server (capglyphd). pub use capglyph_core::error::{CapGlyphError, ErrorCode}; diff --git a/crates/capglyph-server/src/lib.rs b/crates/capglyph-server/src/lib.rs index 42569c8..1c0fc27 100644 --- a/crates/capglyph-server/src/lib.rs +++ b/crates/capglyph-server/src/lib.rs @@ -1,4 +1,4 @@ -//! capglyph-server (sigild) — credential vault MVP +//! capglyph-server (capglyphd) — credential vault MVP //! //! Implements `docs/research/media-credential/usage/credential-design.md` §4: //! `covers / credentials / credential_consumptions / audit_events` with diff --git a/docs/capglyph-core-api.md b/docs/capglyph-core-api.md index 50d3642..be4920f 100644 --- a/docs/capglyph-core-api.md +++ b/docs/capglyph-core-api.md @@ -2,13 +2,11 @@ **Date:** 2026-08-31 (updated 2026-08-31 CTX-0022 → CTX-0040, renamed 2026-08-31 CTX-0039 Sigil → CapGlyph) **Task:** CTX-0019 → CTX-0022 → CTX-0040 -**Full spec:** [`capglyph-docs/research/media-credential/capglyph-core-api.md`](../../capglyph-docs/research/media-credential/capglyph-core-api.md) +**Design record:** [categorized core extraction/API history](../../capglyph-docs/research/media-credential/architecture/capglyph-core-api.md). **Status:** CTX-0040 — standalone `CapGlyph/capglyph-core` repo (canonical Rust Core, v0.1.0) extracted from `capglyph-cli/crates/capglyph-core`; `capglyph-cli` now depends via `path = "../capglyph-core"` (isolated monorepo) **Issue:** [#13](https://github.com/CapGlyph/capglyph-cli/issues/13) (originally legacy Sigil repo #13, now CapGlyph/capglyph-cli, redirects) -This file is the **capglyph-repo-local sketch** of the shared `capglyph-core` boundary (formerly `sigil-core`). -The normative spec lives in `capglyph-docs` (formerly `sigil-docs`); this file exists so `cargo test` reviewers -and CI can verify the migration plan without crossing repos. +This is a historical extraction sketch, not a current Rust API or normative wire contract. Current carrier code is in [capglyph-core](../../capglyph-core/README.md); normative bytes are in [capglyph-spec](../../capglyph-spec/README.md). DCT/DWT have since moved into the core and CLI modules delegate/re-export. `Register`/`OrbRansacRegister`, `Adaptive`, and the generic wrapper below are historical proposals, not implemented public APIs. ## Workspace after CTX-0040 (v0.1.0, formerly v0.2.0 Sigil; reset 2026-08-31 CTX-0044) @@ -45,11 +43,11 @@ capglyph-cli/ - `keying.rs` — HMAC PRF, split into `K_embed` (placement) / `K_mac` (framing tag) / `K_object` (pointer AEAD) - `spread_spectrum.rs` — deprecated shim until `ecc` replaces repetition-8 - `geometry.rs` — `GeometryFile` (serde), cover vault + carrier lattice -- `carrier.rs` (`Carrier` trait + `Placement` + `AlphaCarrier`) — single dispatch point (CTX-0018), `DctCarrier`/`DwtCarrier` impls stay in `capglyph/src/carrier.rs` (legacy `sigil/src/carrier.rs`, facade) until `dct`/`dwt` move in follow-up +- `carrier.rs` — canonical `Carrier` trait and Alpha/Dct/Dwt implementations now reside in core; CLI facades re-export/delegate. - `core.rs` grouping shim — replaced by `pub use capglyph_core::*` in `capglyph/src/lib.rs` + `capglyph/src/core.rs` re-exports (alias `sigil_core` retained) - **CTX-0020:** `framing` (CBOR `version/length/type/flags` + HMAC) + `ecc` (BCH/RS + interleave + soft-bits LLR) + `interleave` — all moved in CTX-0022 -- **CTX-0021:** `registration::Register` (`R = I_submitted^aligned - I_original` + correlation) + `CoverVault`/`HybridMatch` — moved in CTX-0022 -- `dct.rs` + `dwt.rs` + `dwt_embed.rs` → `capglyph_core::carrier::{dct,dwt}` (alias `sigil_core`) — **deferred** (still in `capglyph` binary, uses `capglyph_core::Placement` via `carrier::to_core_placement` bridge) +- Registration helpers moved to core; current interfaces are `Registration`, `Identity` and `Translation`/NCC, not the historical `Register`/ORB sketch. +- `dct.rs`, `dwt.rs`, `dwt_embed.rs` and concrete carriers were subsequently extracted into core; the former deferred-move status is superseded. ## What stays in capglyph (binary, formerly sigil) diff --git a/docs/sigild-mvp.md b/docs/capglyphd-mvp.md similarity index 97% rename from docs/sigild-mvp.md rename to docs/capglyphd-mvp.md index 0efcf2d..0f17a40 100644 --- a/docs/sigild-mvp.md +++ b/docs/capglyphd-mvp.md @@ -1,4 +1,4 @@ -# sigild (capglyphd) MVP — DB + Atomic Consume + Revocation/Audit (CTX-0023) +# capglyphd MVP — DB + Atomic Consume + Revocation/Audit (CTX-0023) **Status:** Implemented 2026-08-31 **Crate:** `crates/capglyph-server` (binary `capglyphd`) diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md index 87ba5fe..b07f97c 100644 --- a/docs/product-roadmap.md +++ b/docs/product-roadmap.md @@ -1,58 +1,45 @@ # CapGlyph Product Roadmap -**Last updated:** 2026-09-01 (v0.1.0 release status synchronized) -**Status:** Open-source CLI shipped (v0.1.0, formerly Sigil v0.2.0) → open-core monetization +**Status:** Product direction and historical release milestones, reconciled 2026-09-05. Not a release qualification or commitment to build every listed service. Current checkout manifests are CLI/server 0.1.7 and core 0.1.0; publication status must be checked separately. ---- +## Product boundary -## Product Vision +CapGlyph provides image-watermark/payload primitives, a local CLI, optional TrustMark and C2PA integrations, and a token-oriented reference credential/message server. Presence detection, recovered IDs, authenticated payloads, factual provenance and legal ownership are different endpoints. -CapGlyph (formerly Sigil) provides invisible, robust image watermarking for leak source -identification, copyright attribution, and tamper detection. Unlike -metadata-based solutions (EXIF/IPTC) that are trivially stripped, CapGlyph -embeds machine-verifiable signals directly into image pixels/frequency data, -with an open, auditable scheme. +The core now owns DCT/DWT implementations and three placement arms. JS/Python/Go provide local framing but not complete image SDKs. Their generic HTTP clients currently do not match the reference server. Read the [spec implementation reconciliation](../../capglyph-spec/docs/implementation-reconciliation.md) and [server README](../crates/capglyph-server/README.md) before making product claims. -**Core value proposition:** +## Recorded delivery, not blanket certification -- Invisible to humans (PSNR 42–52 dB measured) -- Invisible to VLMs (validated on Gemini/Claude/GPT at all effort levels) -- Survives aggressive ordinary edits (learned mode: JPEG q30, blur σ2, scale 0.5×) -- Per-recipient tracing with geometry-free extraction -- HMAC-keyed attribution surviving collusion, blocking forgery -- Self-hosted, open source (Apache-2.0), zero per-image cost +- Four carrier modes: alpha, DCT, DWT and feature-gated TrustMark. +- Separate presence, recipient-ID and keyed-signal operations. +- CLI/core migration, local build/CI configuration, license and release-history records. +- Optional C2PA signing/verification; generated self-signed certificates require independent trust establishment. +- Historical Q-series observations and narrower frozen research studies. -## Phase 1: Open-Source Release (complete) +The earlier v0.1.0 release checklist is historical; [CHANGELOG.md](../CHANGELOG.md) preserves release history. PSNR ranges and a VLM failing to notice a watermark do not establish human invisibility, resistance to steganalysis, or invisibility to all model versions/settings. Learned robustness and collusion observations are conditional on their exact experiment. No algorithm here generally blocks forgery or proves authorship merely by recovering a signal. Self-hosting still has compute, storage and operational costs. -- [x] Four watermark modes (alpha/dct/dwt/learned) -- [x] Three-layer security model (public/ID/secret) -- [x] Attack matrix verified and published (Q-series findings) -- [x] CI/CD, license, docs, README (EN/zh-CN), changelog -- [x] GitHub repository `CapGlyph/capglyph-cli` (formerly under Xuepoo organization, now redirects) +## Open implementation gates -## Phase 2: Adoption & Trust Building (next) +CarryCtx is authoritative; the following is a snapshot, not a new task assignment: -- [x] GitHub Release v0.1.0 binaries (published 2026-08-31; Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64; Linux tar.gz/deb/rpm/pkg.tar.zst assets) -- [ ] Interactive web demo (upload → attack → extract) -- [ ] Public attack-matrix page (open weakness disclosure vs Steg.AI) -- [ ] Hacker News / security-community launch -- [x] C2PA manifest integration (pixel watermark + content credentials) +- `CGCLI-0002` — G11 image issue/verify/consume path: **ready**. +- `CGCLI-0004` — product-gate revalidation: **planned**. +- JS/Python/Go local image-carrier tasks (`CGJS/CGPY/CGGO-0002`): **ready**. +- Explicit SDK/current-server API reconciliation is still needed despite historical completion of framing/API-labelled tasks. -## Phase 3: Monetization (demand-driven, after users exist) +The end-to-end image service, persisted cover vault and production key/ingress deployment are not supplied by token-route success. Provenance retrieval/graph research is a separate track and remains paused for new experiments at the user's request. -| Path | Offering | Anchor pricing | -| ------------------ | ------------------------------------------------------------------- | ---------------------------- | -| Cloud service | Hosted monitoring: embed at scale + periodic leak crawling + alerts | €99–299/mo (Imatag: €299/mo) | -| Enterprise support | SLA, priority response, tuning consultation | $5–50k/yr | -| Custom development | Video extension, format support, integration | project-based | +## Candidate adoption work -Competitive positioning: "the GnuPG of digital-image forensics" — open, -auditable, self-hosted. Steg.AI cannot respond on transparency (learned -model parameters are its moat) or on price (per-image SaaS cost). +These are proposals, not implemented offerings or an authorization to publish: -## Explicit Non-Goals +- Interactive local demonstration with endpoint-specific success/failure reporting. +- Public benchmark presentation linked to reproducible evidence and known weaknesses. +- Community feedback and integration examples after the supported surface is explicit. +- Hosting, support or custom integration only when demand and operational requirements are established. -- Anti-AI-training cloaking (Glaze/Nightshade territory) -- Screen-capture robustness (requires learned re-capture training — Steg.AI - territory; revisit only as research project) -- Video/audio/document modes (not before market demand) +Earlier competitor prices and revenue ranges were unverified planning anchors, not current market data or an approved pricing schedule. No exclusivity, competitive-superiority or patentability conclusion is implied. + +## Non-goals and deferred directions + +Copy-prevention, guaranteed known-cover resistance, anti-training cloaking, generic screenshot/print-scan robustness, and universal regeneration resistance are not product promises. Video/audio/document support and cross-platform capture studies require separate user-authorized scope and evidence. diff --git a/nfpm.yaml b/nfpm.yaml index 6e6b673..60917f6 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -6,7 +6,7 @@ release: "1" section: "default" priority: "optional" maintainer: "capglyph developers" -description: "CapGlyph - Invisible structural watermark for images (formerly Sigil)" +description: "CapGlyph - Invisible structural watermark for images" license: "Apache-2.0" contents: - src: __CAPGLYPH_SOURCE__ diff --git a/src/c2pa.rs b/src/c2pa.rs index f909ff3..9a7bbe5 100644 --- a/src/c2pa.rs +++ b/src/c2pa.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; /// Watermark metadata carried inside the C2PA manifest assertion -/// (`com.capglyph.watermark`, legacy `com.sigil.watermark`), mirroring the pixel-watermark embed parameters. +/// (`com.capglyph.watermark`), mirroring the pixel-watermark embed parameters. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct WatermarkClaim { pub mode: String, @@ -67,7 +67,8 @@ pub fn init_cert(org: Option<&str>, out_dir: &Path, force: bool) -> Result<(Path } /// Sign `input` with a C2PA manifest carrying `claim` as the -/// `com.capglyph.watermark` assertion (legacy `com.sigil.watermark`), writing to `output`. +/// `com.capglyph.watermark` assertion, writing to `output`. +/// Also writes `com.sigil.watermark` for compatibility with older readers. /// /// `manifest_json` optionally merges extra assertions: /// `{"label": , ...}`. diff --git a/src/c2pa_cli.rs b/src/c2pa_cli.rs index 76441ec..42de7e7 100644 --- a/src/c2pa_cli.rs +++ b/src/c2pa_cli.rs @@ -1,4 +1,4 @@ -//! CLI dispatch for the `capglyph c2pa` command group (legacy `sigil c2pa`). +//! CLI dispatch for the `capglyph c2pa` command group. use std::path::PathBuf; @@ -10,7 +10,7 @@ use crate::cli::{C2paCommand, C2paSignArgs}; const DEFAULT_CERT_DIR: &str = "./capglyph-certs/"; -/// Entry point for the `capglyph c2pa` subcommand group (legacy `sigil c2pa`). +/// Entry point for the `capglyph c2pa` subcommand group. /// /// Returns the process exit code: 0 = valid, 1 = invalid, 2 = unsigned. pub fn run(cmd: &C2paCommand) -> Result { diff --git a/src/cli.rs b/src/cli.rs index 7d78ace..fb0aaca 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use clap::{Args, Parser, Subcommand}; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; -/// Embedding mode for `capglyph embed` (legacy `sigil embed`). +/// Embedding mode for `capglyph embed`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_arch = "wasm32"), derive(clap::ValueEnum))] pub enum EmbedMode { @@ -54,7 +54,7 @@ impl std::fmt::Display for EmbedMode { version, author, about = "Invisible structural watermark for images", - long_about = "CapGlyph (formerly Sigil) embeds a sub-perceptual structural watermark derived from the image's own \ + long_about = "CapGlyph embeds a sub-perceptual structural watermark derived from the image's own \ geometry. The watermark is invisible to humans but detectable by machines, and \ is destroyed by PNG→JPG conversion or screenshots — signalling tampering." )] @@ -168,7 +168,7 @@ pub struct C2paSignArgs { #[arg(long, default_value = "capture")] pub source_type: String, - /// Recipient ID to record in the com.capglyph.watermark assertion (legacy com.sigil.watermark) + /// Recipient ID to record in the com.capglyph.watermark assertion /// (requires --mode) #[arg(long, requires = "mode")] pub recipient_id: Option, @@ -177,7 +177,7 @@ pub struct C2paSignArgs { #[arg(long, requires = "recipient_id")] pub mode: Option, - /// Sigil HMAC secret (marks the claim as keyed; the secret itself is + /// CapGlyph HMAC secret (marks the claim as keyed; the secret itself is /// never stored in the manifest) #[arg(long)] pub key: Option, @@ -198,7 +198,7 @@ pub struct EmbedArgs { /// Input image path (.png recommended; .jpg is accepted but output is always PNG) pub input: PathBuf, - /// Output path (default: _capglyph.png next to input, legacy _sigil.png) + /// Output path (default: _capglyph.png next to input) /// For JPEG output, use --output file.jpg or --format jpg in batch mode #[arg(short, long)] pub output: Option, @@ -274,7 +274,7 @@ pub struct EmbedArgs { pub dwt_strength: f32, /// Also sign the output with a C2PA manifest carrying the embed - /// parameters as the com.capglyph.watermark assertion (legacy com.sigil.watermark) + /// parameters as the com.capglyph.watermark assertion #[cfg(feature = "c2pa")] #[arg(long, requires_all = ["c2pa_cert", "c2pa_pkey"])] pub c2pa: bool, diff --git a/src/core.rs b/src/core.rs index 6ad5f50..909bdeb 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,7 +1,7 @@ //! Core primitives grouped for future `capglyph-core` extraction. //! //! This module re-exports the foundational primitives that have been moved -//! into the `capglyph-core` crate (CTX-0022, legacy `sigil-core`). The re-export +//! into the `capglyph-core` crate (CTX-0022). The re-export //! keeps the public API backwards compatible: `crate::geometry`, `crate::signal`, //! etc. remain valid via `pub use capglyph_core::*` in `lib.rs`, while new //! internal code should prefer `capglyph_core::*` directly or `crate::core::*`. diff --git a/src/extract.rs b/src/extract.rs index d0723e5..a811a2d 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -1,6 +1,6 @@ -//! `capglyph extract` — recover embedded recipient ID from a watermarked image (legacy `sigil extract`). +//! `capglyph extract` — recover embedded recipient ID from a watermarked image. //! -//! Reads the spread-spectrum bit stream embedded during `capglyph embed --recipient-id` (legacy `sigil embed`) +//! Reads the spread-spectrum bit stream embedded during `capglyph embed --recipient-id` //! and reconstructs the original ID string. #[cfg(not(target_arch = "wasm32"))] diff --git a/src/learned.rs b/src/learned.rs index 8b0dd2c..c314322 100644 --- a/src/learned.rs +++ b/src/learned.rs @@ -2,12 +2,12 @@ //! //! TrustMark (Adobe, MIT) embeds a ~40-75 bit payload via a trained CNN //! encoder/decoder pair. It survives aggressive ordinary edits (JPEG q30, -//! blur σ2, scale 0.5×) that defeat CapGlyph's classical DCT/DWT layers (legacy Sigil), but +//! blur σ2, scale 0.5×) that defeat CapGlyph's classical DCT/DWT layers, but //! shares the same limit on generative regeneration (img2img) — see //! findings/2026-08-15-q114-trustmark-vs-attacks.md. //! //! Model files are ONNX weights downloaded once from Adobe's CDN into the -//! XDG data directory (see `model_dir`). `capglyph fetch-models` pre-downloads (legacy `sigil fetch-models`). +//! XDG data directory (see `model_dir`). `capglyph fetch-models` pre-downloads them. use std::path::{Path, PathBuf}; @@ -86,7 +86,7 @@ pub fn fetch_models(dir: &Path) -> Result<()> { /// Build a ureq agent honoring HTTPS_PROXY/HTTP_PROXY/ALL_PROXY. /// /// ureq does not read proxy environment variables by default; explicit -/// wiring keeps `capglyph fetch-models` working in proxied environments (legacy `sigil fetch-models`). +/// wiring keeps `capglyph fetch-models` working in proxied environments. fn http_agent() -> Result { let mut builder = ureq::Agent::config_builder(); let proxy = std::env::var("HTTPS_PROXY") @@ -109,7 +109,7 @@ pub fn load(dir: &Path) -> Result { for name in MODEL_FILES { if !dir.join(name).exists() { return Err(anyhow!( - "model file {name} missing in {:?} — run `capglyph fetch-models` first (legacy `sigil fetch-models`)", + "model file {name} missing in {:?} — run `capglyph fetch-models` first", dir )); } diff --git a/src/verify.rs b/src/verify.rs index af5366c..3279b86 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -204,7 +204,7 @@ fn verify_dct(img: &image::DynamicImage, args: &VerifyArgs) -> Result { let seed = geom.prng_seed.ok_or_else(|| { anyhow::anyhow!( "No skeleton paths and no PRNG seed in geometry file. \ - Re-embed with current Sigil version to generate a seed." + Re-embed with current CapGlyph version to generate a seed." ) })?; crate::dct::prng_blocks_from_seed(seed, iw, ih) diff --git a/src/wasm_api.rs b/src/wasm_api.rs index ec39f01..dcdf671 100644 --- a/src/wasm_api.rs +++ b/src/wasm_api.rs @@ -1,6 +1,6 @@ //! Byte-in/byte-out watermark API. //! -//! The wasm bridge (`capglyph-website/wasm-engine`, legacy `sigil-website/wasm-engine`) and any other embedder can +//! The wasm bridge (`capglyph-website/wasm-engine`) and any other embedder can //! call these without touching the filesystem or the CLI types. Everything is //! in-memory: decode → embed/verify/extract → re-encode. diff --git a/tests/c2pa_tests.rs b/tests/c2pa_tests.rs index 5f6f422..7842ac0 100644 --- a/tests/c2pa_tests.rs +++ b/tests/c2pa_tests.rs @@ -87,7 +87,7 @@ fn make_fixture_rgb(w: u32, h: u32) -> image::RgbImage { fn sign_roundtrip(ext: &str, source_type: Option<&str>) -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::tempdir().unwrap(); - let (cert, key) = init_cert(Some("Sigil Test"), dir.path(), false).unwrap(); + let (cert, key) = init_cert(Some("CapGlyph Test"), dir.path(), false).unwrap(); let input = dir.path().join(format!("input.{ext}")); let output = dir.path().join(format!("signed.{ext}")); @@ -299,7 +299,7 @@ fn verify_image_signed_reports_org_and_claim() { let report = verify_image(&output).unwrap(); assert!(report.present); assert_eq!(report.signature_status, "valid"); - assert_eq!(report.signer_org.as_deref(), Some("Sigil Test")); + assert_eq!(report.signer_org.as_deref(), Some("CapGlyph Test")); assert!(report.valid_from.is_some()); assert!(report.valid_to.is_some()); let claim = report.watermark_claim.expect("claim present"); diff --git a/tests/integration.rs b/tests/integration.rs index 07ec0d3..0a1990a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,4 +1,4 @@ -/// Integration tests for Sigil embed / verify / strip pipeline. +/// Integration tests for CapGlyph embed / verify / strip pipeline. /// /// Each test generates a small synthetic PNG in-process (no fixture files needed) /// and runs the subcommand logic directly via the public module functions.