From 06909f0b757326bb82d885bba2f45fb554d72d66 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Mon, 31 Aug 2026 23:31:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?[CTX-0024]=20feat(pointer):=20pointer-onlin?= =?UTF-8?q?e=20capability=E2=86=92encrypted=20object=20+=20offline=201024p?= =?UTF-8?q?x+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server DB: message_objects table (id, capability_id FK, ciphertext BLOB, nonce, tag, content_key, created_at, policy) with capability_hash index and owner/allow authz (no IDOR) - Service: ChaCha20-Poly1305 (chacha20poly1305 0.11) aead_encrypt/decrypt with tag verify, store_message(ciphertext)->capability_id, encrypt_and_store, resolve_message with authz, store_offline/resolve_offline for 48B object_id+content_key (enforces 1024px+ at embed time) - Carrier: capglyph::pointer reuses capglyph_core framing (Pointer/Locator) + ECC (BCH/Repetition) + DCT/DWT differential; select_profile mirrors credential ladder; offline gate 1024 - CLI: capglyph pointer embed/extract/offline-embed/offline-extract + message encrypt/decrypt/store/resolve (cfg not wasm) - Tests: end-to-end encrypt→embed→extract→resolve→decrypt, IDOR negative, AEAD tamper, bearer, offline 1024 gate (pointer::tests::end_to_end_*) - HTTP: POST /v1/messages, POST /v1/messages/resolve, GET /v1/messages/:id - Docs: docs/pointer-and-stego.md - Wasm: pointer gated, getrandom wasm_js, cargo tree clean Closes CTX-0024 --- Cargo.lock | 94 ++ Cargo.toml | 8 + crates/capglyph-server/Cargo.toml | 2 + .../migrations/001_initial.sql | 17 + .../migrations/002_pointer.sql | 18 + crates/capglyph-server/src/db.rs | 253 ++++- crates/capglyph-server/src/http.rs | 103 +- crates/capglyph-server/src/models.rs | 71 ++ crates/capglyph-server/src/service.rs | 275 +++++- docs/pointer-and-stego.md | 155 +++ src/cli.rs | 209 ++++ src/lib.rs | 2 + src/main.rs | 18 +- src/pointer.rs | 904 ++++++++++++++++++ 14 files changed, 2123 insertions(+), 6 deletions(-) create mode 100644 crates/capglyph-server/migrations/002_pointer.sql create mode 100644 docs/pointer-and-stego.md create mode 100644 src/pointer.rs diff --git a/Cargo.lock b/Cargo.lock index a66cf4b..9d7dd7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "ahash" version = "0.8.12" @@ -671,16 +681,23 @@ dependencies = [ name = "capglyph" version = "0.1.0" dependencies = [ + "aead", "anyhow", + "base64 0.22.1", "c2pa", "capglyph-core", + "capglyph-server", + "chacha20poly1305", "ciborium", "clap", "digest 0.11.3", "directories", + "getrandom 0.4.3", "glob", + "hex", "hmac 0.13.0", "image", + "rand 0.8.7", "rcgen", "serde", "serde_bytes", @@ -693,6 +710,7 @@ dependencies = [ "tracing-subscriber", "trustmark", "ureq 3.4.0", + "uuid", "vectomancy-geometry", "vectomancy-raster", "x509-parser", @@ -718,10 +736,12 @@ dependencies = [ name = "capglyph-server" version = "0.1.0" dependencies = [ + "aead", "anyhow", "axum", "base64 0.22.1", "capglyph-core", + "chacha20poly1305", "chrono", "ciborium", "hex", @@ -769,6 +789,29 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.0", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + [[package]] name = "chrono" version = "0.4.45" @@ -810,6 +853,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clap" version = "4.6.6" @@ -1005,7 +1059,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1541,8 +1597,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2100,6 +2159,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2906,6 +2974,16 @@ dependencies = [ "traitful", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3160,6 +3238,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -4459,6 +4543,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index f6ec537..c41f871 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,11 @@ tracing = "0.1" sha2 = "0.11.0" hmac = "0.13.0" digest = "0.11.3" +chacha20poly1305 = "0.11" +aead = "0.6" +base64 = "0.22" +hex = "0.4" +getrandom = { version = "0.4", features = ["wasm_js"] } # Learned watermark (TrustMark ONNX, optional — pulls in ONNX Runtime) trustmark = { version = "0.2", optional = true } @@ -80,6 +85,9 @@ time = { version = "0.3", features = ["formatting"], optional = true } clap = { version = "4.5", features = ["derive"] } glob = "0.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +capglyph-server = { path = "crates/capglyph-server" } +rand = "0.8" +uuid = { version = "1.8", features = ["v4", "serde"] } [features] default = [] diff --git a/crates/capglyph-server/Cargo.toml b/crates/capglyph-server/Cargo.toml index c984784..5d3a19b 100644 --- a/crates/capglyph-server/Cargo.toml +++ b/crates/capglyph-server/Cargo.toml @@ -24,6 +24,8 @@ ciborium = "0.2" serde_bytes = "0.11" thiserror = "2" tempfile = "3.27.0" +chacha20poly1305 = "0.11" +aead = "0.6" axum = { version = "0.7" } tokio = { version = "1", features = ["full"] } diff --git a/crates/capglyph-server/migrations/001_initial.sql b/crates/capglyph-server/migrations/001_initial.sql index e9f87bf..335b2f6 100644 --- a/crates/capglyph-server/migrations/001_initial.sql +++ b/crates/capglyph-server/migrations/001_initial.sql @@ -62,3 +62,20 @@ CREATE TABLE IF NOT EXISTS audit_events ( ); CREATE INDEX IF NOT EXISTS idx_audit_object ON audit_events(object_id); CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_events(event_type); + +-- CTX-0024 pointer mode: message_objects (capability → encrypted object) +CREATE TABLE IF NOT EXISTS message_objects ( + id TEXT PRIMARY KEY, + capability_id BLOB NOT NULL UNIQUE, + capability_hash BLOB NOT NULL UNIQUE, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + tag BLOB NOT NULL, + content_key BLOB, + policy TEXT NOT NULL, + owner_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_message_objects_cap_hash ON message_objects(capability_hash); +CREATE INDEX IF NOT EXISTS idx_message_objects_owner ON message_objects(owner_id); diff --git a/crates/capglyph-server/migrations/002_pointer.sql b/crates/capglyph-server/migrations/002_pointer.sql new file mode 100644 index 0000000..1737911 --- /dev/null +++ b/crates/capglyph-server/migrations/002_pointer.sql @@ -0,0 +1,18 @@ +-- 002_pointer.sql — CTX-0024 pointer mode: message_objects (capability → encrypted object) +-- Extends 001_initial with pointer stego tables. + +CREATE TABLE IF NOT EXISTS message_objects ( + id TEXT PRIMARY KEY, + capability_id BLOB NOT NULL UNIQUE, + capability_hash BLOB NOT NULL UNIQUE, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + tag BLOB NOT NULL, + content_key BLOB, + policy TEXT NOT NULL, + owner_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_message_objects_cap_hash ON message_objects(capability_hash); +CREATE INDEX IF NOT EXISTS idx_message_objects_owner ON message_objects(owner_id); diff --git a/crates/capglyph-server/src/db.rs b/crates/capglyph-server/src/db.rs index 7cf8ce0..be35791 100644 --- a/crates/capglyph-server/src/db.rs +++ b/crates/capglyph-server/src/db.rs @@ -8,8 +8,8 @@ use uuid::Uuid; use crate::error::{Result, ServerError}; use crate::models::{ - sha256, AuditEvent, Cover, Credential, CredentialConsumption, NewAuditEvent, NewCover, - NewCredential, + sha256, AuditEvent, Cover, Credential, CredentialConsumption, MessageObject, NewAuditEvent, + NewCover, NewCredential, NewMessageObject, }; // ── Schema ──────────────────────────────────────────────────────────────────── @@ -75,6 +75,23 @@ CREATE TABLE IF NOT EXISTS audit_events ( ); CREATE INDEX IF NOT EXISTS idx_audit_object ON audit_events(object_id); CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_events(event_type); + +-- CTX-0024 pointer mode: message_objects (capability → encrypted object) +CREATE TABLE IF NOT EXISTS message_objects ( + id TEXT PRIMARY KEY, + capability_id BLOB NOT NULL UNIQUE, + capability_hash BLOB NOT NULL UNIQUE, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + tag BLOB NOT NULL, + content_key BLOB, + policy TEXT NOT NULL, + owner_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_message_objects_cap_hash ON message_objects(capability_hash); +CREATE INDEX IF NOT EXISTS idx_message_objects_owner ON message_objects(owner_id); "#; // ── Db handle ───────────────────────────────────────────────────────────────── @@ -854,4 +871,236 @@ impl Db { Ok(out) }) } + + // ── Message Objects (CTX-0024) ──────────────────────────────────────────── + + pub fn create_message_object(&self, nm: NewMessageObject) -> Result { + let id = Uuid::new_v4(); + let cap_hash = sha256(&nm.capability_id); + let now = Utc::now(); + let policy_str = serde_json::to_string(&nm.policy).unwrap(); + self.with_conn(|conn| { + conn.execute( + "INSERT INTO message_objects (id, capability_id, capability_hash, ciphertext, nonce, tag, content_key, policy, owner_id, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + id.to_string(), + nm.capability_id.to_vec(), + cap_hash, + nm.ciphertext, + nm.nonce, + nm.tag, + nm.content_key, + policy_str, + nm.owner_id.map(|u| u.to_string()), + now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + nm.expires_at.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), + ], + )?; + Ok(()) + })?; + // Audit + let _ = self.insert_audit(NewAuditEvent { + event_type: "message_object.created".into(), + object_id: Some(id), + actor_id: nm.owner_id, + event_data: Some(serde_json::json!({ + "capability_hash": hex::encode(&cap_hash), + "policy": nm.policy, + })), + }); + self.get_message_object(&id)? + .ok_or_else(|| ServerError::Internal("message object insert failed".into())) + } + + pub fn get_message_object(&self, id: &Uuid) -> Result> { + self.with_conn(|conn| Self::get_message_object_inner(conn, id)) + } + + fn get_message_object_inner(conn: &Connection, id: &Uuid) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, capability_id, capability_hash, ciphertext, nonce, tag, content_key, policy, owner_id, created_at, expires_at FROM message_objects WHERE id = ?1", + )?; + let row = stmt + .query_row(params![id.to_string()], |r| { + Ok(MessageObject { + id: Uuid::from_str(&r.get::<_, String>(0)?).unwrap(), + capability_id: r.get(1)?, + capability_hash: r.get(2)?, + ciphertext: r.get(3)?, + nonce: r.get(4)?, + tag: r.get(5)?, + content_key: r.get(6)?, + policy: serde_json::from_str(&r.get::<_, String>(7)?).unwrap(), + owner_id: r + .get::<_, Option>(8)? + .map(|s| Uuid::from_str(&s).unwrap()), + created_at: r.get::<_, String>(9)?.parse::>().unwrap(), + expires_at: r + .get::<_, Option>(10)? + .map(|s| s.parse::>().unwrap()), + }) + }) + .optional()?; + Ok(row) + } + + pub fn get_message_object_by_capability_id( + &self, + capability_id: &[u8; 16], + ) -> Result> { + let cap_hash = sha256(capability_id); + self.with_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT id, capability_id, capability_hash, ciphertext, nonce, tag, content_key, policy, owner_id, created_at, expires_at FROM message_objects WHERE capability_hash = ?1", + )?; + let row = stmt + .query_row(params![cap_hash], |r| { + Ok(MessageObject { + id: Uuid::from_str(&r.get::<_, String>(0)?).unwrap(), + capability_id: r.get(1)?, + capability_hash: r.get(2)?, + ciphertext: r.get(3)?, + nonce: r.get(4)?, + tag: r.get(5)?, + content_key: r.get(6)?, + policy: serde_json::from_str(&r.get::<_, String>(7)?).unwrap(), + owner_id: r + .get::<_, Option>(8)? + .map(|s| Uuid::from_str(&s).unwrap()), + created_at: r.get::<_, String>(9)?.parse::>().unwrap(), + expires_at: r + .get::<_, Option>(10)? + .map(|s| s.parse::>().unwrap()), + }) + }) + .optional()?; + Ok(row) + }) + } + + pub fn get_message_object_by_capability_hash( + &self, + cap_hash: &[u8], + ) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT id, capability_id, capability_hash, ciphertext, nonce, tag, content_key, policy, owner_id, created_at, expires_at FROM message_objects WHERE capability_hash = ?1", + )?; + let row = stmt + .query_row(params![cap_hash], |r| { + Ok(MessageObject { + id: Uuid::from_str(&r.get::<_, String>(0)?).unwrap(), + capability_id: r.get(1)?, + capability_hash: r.get(2)?, + ciphertext: r.get(3)?, + nonce: r.get(4)?, + tag: r.get(5)?, + content_key: r.get(6)?, + policy: serde_json::from_str(&r.get::<_, String>(7)?).unwrap(), + owner_id: r + .get::<_, Option>(8)? + .map(|s| Uuid::from_str(&s).unwrap()), + created_at: r.get::<_, String>(9)?.parse::>().unwrap(), + expires_at: r + .get::<_, Option>(10)? + .map(|s| s.parse::>().unwrap()), + }) + }) + .optional()?; + Ok(row) + }) + } + + /// Resolve with authorization (no IDOR). Checks policy before returning ciphertext. + /// Policy semantics: if policy has "owner_id" == actor_id or "allow" contains actor_id, authorized. + /// If policy is empty or has no owner, treats as bearer (any holder of capability_id is authorized). + /// Also checks expiry. + pub fn resolve_message_object( + &self, + capability_id: &[u8; 16], + actor_id: Option, + ) -> Result { + let obj = self + .get_message_object_by_capability_id(capability_id)? + .ok_or_else(|| ServerError::NotFound("message object not found".into()))?; + // Expiry check + if let Some(exp) = obj.expires_at { + if Utc::now() >= exp { + return Err(ServerError::Expired); + } + } + // Authorization (no IDOR) + // If owner_id is set on row, require actor_id matches owner or is in allowed list + // Policy JSON may contain {"allow": ["uuid1", ...]} or {"owner_id": "..."} + // For MVP, enforce: if obj.owner_id is Some, actor must be that owner or in policy allow + if let Some(owner) = obj.owner_id { + match actor_id { + Some(actor) if actor == owner => {} // owner always allowed + Some(actor) => { + // Check policy allow list + if let Some(allow) = obj.policy.get("allow").and_then(|v| v.as_array()) { + let allowed = allow.iter().any(|v| v.as_str() == Some(&actor.to_string())); + if !allowed { + return Err(ServerError::Unauthorized(format!( + "actor {} not authorized for object {}", + actor, obj.id + ))); + } + } else { + // No allow list => only owner allowed (fail closed) + return Err(ServerError::Unauthorized(format!( + "actor {} not owner {}", + actor, owner + ))); + } + } + None => { + return Err(ServerError::Unauthorized( + "missing actor_id for owner-restricted object".into(), + )) + } + } + } else { + // No owner -> check policy allow if present, else bearer (any actor allowed) + if let Some(allow) = obj.policy.get("allow").and_then(|v| v.as_array()) { + // If allow list present but empty, fail + if allow.is_empty() { + return Err(ServerError::Unauthorized("empty allow list".into())); + } + if let Some(actor) = actor_id { + let allowed = allow.iter().any(|v| v.as_str() == Some(&actor.to_string())); + if !allowed { + return Err(ServerError::Unauthorized(format!( + "actor {} not in allow list", + actor + ))); + } + } else { + return Err(ServerError::Unauthorized("actor required by policy".into())); + } + } + // Bearer: no owner, no allow list => any holder of capability is authorized (no IDOR via unguessable 128b) + } + // Audit successful resolve (do not log raw capability) + let _ = self.insert_audit(NewAuditEvent { + event_type: "message_object.resolved".into(), + object_id: Some(obj.id), + actor_id, + event_data: Some(serde_json::json!({ + "capability_hash": hex::encode(sha256(capability_id)), + })), + }); + Ok(obj) + } + + pub fn delete_message_object(&self, id: &Uuid) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + "DELETE FROM message_objects WHERE id = ?1", + params![id.to_string()], + )?; + Ok(()) + }) + } } diff --git a/crates/capglyph-server/src/http.rs b/crates/capglyph-server/src/http.rs index 1aeba50..e7d0940 100644 --- a/crates/capglyph-server/src/http.rs +++ b/crates/capglyph-server/src/http.rs @@ -12,8 +12,13 @@ use axum::{ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use base64::Engine as _; + use crate::error::ServerError; -use crate::models::{ConsumeResponse, IssueRequest, IssueResponse, RevokeRequest, VerifyResponse}; +use crate::models::{ + ConsumeResponse, IssueRequest, IssueResponse, MessageObject, ResolveMessageResponse, + RevokeRequest, StoreMessageResponse, VerifyResponse, +}; use crate::service::Service; // ── Shared state ────────────────────────────────────────────────────────────── @@ -141,6 +146,99 @@ async fn handle_issue( state.svc.issue(req).map(Json).map_err(map_err) } +// ── Message objects (CTX-0024) ──────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize)] +pub struct StoreMessageBody { + pub plaintext_base64: Option, + pub ciphertext_base64: Option, + pub nonce_base64: Option, + pub tag_base64: Option, + pub owner_id: Option, + pub policy: Option, + pub expires_at: Option>, +} + +async fn handle_store_message( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + // 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 + .decode(pt_b64) + .map_err(|e| { + map_err(ServerError::Internal(format!( + "invalid plaintext_base64: {e}" + ))) + })?; + let policy = body.policy.unwrap_or_else(|| serde_json::json!({})); + // encrypt_and_store generates key/nonce internally + state + .svc + .encrypt_and_store(&pt, policy, body.owner_id, body.expires_at) + .map(|(resp, _, _)| Json(resp)) + .map_err(map_err) + } else { + let ct = body + .ciphertext_base64 + .ok_or_else(|| map_err(ServerError::Internal("missing ciphertext_base64".into())))?; + let nonce_b64 = body + .nonce_base64 + .ok_or_else(|| map_err(ServerError::Internal("missing nonce_base64".into())))?; + let tag_b64 = body + .tag_base64 + .ok_or_else(|| map_err(ServerError::Internal("missing tag_base64".into())))?; + let ct_bytes = base64::engine::general_purpose::STANDARD + .decode(ct) + .map_err(|e| map_err(ServerError::Internal(format!("invalid ciphertext: {e}"))))?; + let nonce = base64::engine::general_purpose::STANDARD + .decode(nonce_b64) + .map_err(|e| map_err(ServerError::Internal(format!("invalid nonce: {e}"))))?; + let tag = base64::engine::general_purpose::STANDARD + .decode(tag_b64) + .map_err(|e| map_err(ServerError::Internal(format!("invalid tag: {e}"))))?; + let policy = body.policy.unwrap_or_else(|| serde_json::json!({})); + state + .svc + .store_message( + ct_bytes, + nonce, + tag, + None, + policy, + body.owner_id, + body.expires_at, + ) + .map(Json) + .map_err(map_err) + } +} + +#[derive(Debug, Deserialize)] +pub struct ResolveMessageBody { + pub capability_id: String, + pub actor_id: Option, +} + +async fn handle_resolve_message( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + state + .svc + .resolve_message(&body.capability_id, body.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) +} + // ── Router ──────────────────────────────────────────────────────────────────── pub fn router(svc: Service) -> Router { @@ -151,6 +249,9 @@ pub fn router(svc: Service) -> Router { .route("/v1/credentials/consume", post(handle_consume)) .route("/v1/credentials/:id", get(handle_get)) .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) } diff --git a/crates/capglyph-server/src/models.rs b/crates/capglyph-server/src/models.rs index 72855ed..59a56d9 100644 --- a/crates/capglyph-server/src/models.rs +++ b/crates/capglyph-server/src/models.rs @@ -159,6 +159,69 @@ pub struct IssueResponse { pub token_hash_hex: String, } +// ── Message Objects (CTX-0024 pointer mode) ───────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MessageObject { + pub id: Uuid, + pub capability_id: Vec, // 16-byte bearer token (raw) + pub capability_hash: Vec, // SHA-256 of capability_id for indexed lookup + pub ciphertext: Vec, + pub nonce: Vec, // 12 bytes ChaCha20Poly1305 + pub tag: Vec, // 16 bytes + pub content_key: Option>, // 32 bytes, stored for offline re-derive / audit + pub policy: serde_json::Value, + pub owner_id: Option, + pub created_at: DateTime, + pub expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewMessageObject { + pub capability_id: [u8; 16], + pub ciphertext: Vec, + pub nonce: Vec, + pub tag: Vec, + pub content_key: Option>, + pub policy: serde_json::Value, + pub owner_id: Option, + pub expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreMessageRequest { + pub plaintext_base64: String, // base64 of plaintext (client encrypts locally, but server stores ciphertext) + // Alternatively raw ciphertext fields for direct store + pub ciphertext_base64: Option, + pub nonce_base64: Option, + pub tag_base64: Option, + pub policy: Option, + pub owner_id: Option, + pub expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreMessageResponse { + pub object_id: Uuid, + pub capability_id: String, // base64url + pub capability_hash_hex: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolveMessageRequest { + pub capability_id: String, // base64url 16 bytes + pub actor_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolveMessageResponse { + pub object_id: Uuid, + pub ciphertext_base64: String, + pub nonce_base64: String, + pub tag_base64: String, + pub policy: serde_json::Value, +} + // ── Helpers ─────────────────────────────────────────────────────────────────── pub fn parse_token_id(s: &str) -> anyhow::Result<[u8; 16]> { @@ -204,3 +267,11 @@ pub fn sha256(data: &[u8]) -> Vec { h.update(data); h.finalize().to_vec() } + +pub fn parse_capability_id(s: &str) -> anyhow::Result<[u8; 16]> { + parse_token_id(s) +} + +pub fn capability_id_to_base64url(cap: &[u8; 16]) -> String { + token_id_to_base64url(cap) +} diff --git a/crates/capglyph-server/src/service.rs b/crates/capglyph-server/src/service.rs index 351004d..e7bd539 100644 --- a/crates/capglyph-server/src/service.rs +++ b/crates/capglyph-server/src/service.rs @@ -1,3 +1,4 @@ +use base64::Engine as _; use chrono::Utc; use hmac::KeyInit; use rand::RngCore; @@ -7,8 +8,9 @@ use uuid::Uuid; use crate::db::Db; use crate::error::{Result, ServerError}; use crate::models::{ - parse_token_id, sha256, token_id_to_base64url, Credential, IssueRequest, IssueResponse, - NewCover, NewCredential, + capability_id_to_base64url, parse_capability_id, parse_token_id, sha256, token_id_to_base64url, + Credential, IssueRequest, IssueResponse, MessageObject, NewCover, NewCredential, + NewMessageObject, ResolveMessageResponse, StoreMessageResponse, }; /// High-level service that wraps Db + KMS derivation + carrier framing. @@ -356,4 +358,273 @@ impl Service { "verify_image not yet wired: use verify(token_id) or provide cover vault".into(), )) } + + // ── Pointer / Message Objects (CTX-0024) ────────────────────────────────── + + /// ChaCha20-Poly1305 helpers (shared with `capglyph::pointer`) + #[allow(deprecated)] + pub fn aead_encrypt( + plaintext: &[u8], + key: &[u8; 32], + nonce_bytes: &[u8; 12], + ) -> Result<(Vec, Vec)> { + use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, Nonce}; + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let nonce = Nonce::from_slice(nonce_bytes); + let combined = cipher + .encrypt(nonce, plaintext) + .map_err(|e| ServerError::Internal(format!("AEAD encrypt failed: {e}")))?; + // Split tag (last 16 bytes) + if combined.len() < 16 { + return Err(ServerError::Internal("AEAD output too short".into())); + } + let (ct, tag) = combined.split_at(combined.len() - 16); + Ok((ct.to_vec(), tag.to_vec())) + } + + #[allow(deprecated)] + pub fn aead_decrypt( + ciphertext: &[u8], + tag: &[u8], + key: &[u8; 32], + nonce_bytes: &[u8; 12], + ) -> Result> { + use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, Nonce}; + let mut combined = Vec::with_capacity(ciphertext.len() + tag.len()); + combined.extend_from_slice(ciphertext); + combined.extend_from_slice(tag); + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let nonce = Nonce::from_slice(nonce_bytes); + let pt = cipher + .decrypt(nonce, combined.as_ref()) + .map_err(|_| ServerError::Internal("AEAD tag verification failed".into()))?; + Ok(pt) + } + + /// Generate a fresh 32-byte content key (CSPRNG). + pub fn generate_content_key() -> [u8; 32] { + let mut k = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut k); + k + } + + /// Generate a fresh 12-byte nonce. + pub fn generate_nonce() -> [u8; 12] { + let mut n = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut n); + n + } + + /// Generate a fresh 16-byte capability_id. + pub fn generate_capability_id() -> [u8; 16] { + let mut c = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut c); + c + } + + /// Store an already-encrypted object (ciphertext+nonce+tag) under a fresh capability. + /// This is the low-level API: `store_message(ciphertext) -> capability_id` per task. + #[allow(clippy::too_many_arguments)] + pub fn store_message( + &self, + ciphertext: Vec, + nonce: Vec, + tag: Vec, + content_key: Option>, + policy: serde_json::Value, + owner_id: Option, + expires_at: Option>, + ) -> Result { + if nonce.len() != 12 { + return Err(ServerError::Internal("nonce must be 12 bytes".into())); + } + if tag.len() != 16 { + return Err(ServerError::Internal("tag must be 16 bytes".into())); + } + let mut cap = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut cap); + let obj = self.db.create_message_object(NewMessageObject { + capability_id: cap, + ciphertext, + nonce, + tag, + content_key, + policy, + owner_id, + expires_at, + })?; + Ok(StoreMessageResponse { + object_id: obj.id, + capability_id: capability_id_to_base64url(&cap), + capability_hash_hex: hex::encode(sha256(&cap)), + }) + } + + /// Convenience: encrypt plaintext with a fresh content_key+nonce, store, return capability. + /// Returns (capability_id, content_key, nonce) so caller can persist or embed. + pub fn encrypt_and_store( + &self, + plaintext: &[u8], + policy: serde_json::Value, + owner_id: Option, + expires_at: Option>, + ) -> Result<(StoreMessageResponse, Vec, Vec)> { + let key = Self::generate_content_key(); + let nonce = Self::generate_nonce(); + let (ct, tag) = Self::aead_encrypt(plaintext, &key, &nonce)?; + let resp = self.store_message( + ct, + nonce.to_vec(), + tag.clone(), + Some(key.to_vec()), + policy, + owner_id, + expires_at, + )?; + Ok((resp, key.to_vec(), nonce.to_vec())) + } + + /// Resolve capability → ciphertext with authorization (no IDOR). + /// Verifies actor is authorized per stored policy. + pub fn resolve_message( + &self, + capability_id_str: &str, + actor_id: Option, + ) -> Result { + let cap = parse_capability_id(capability_id_str).map_err(|_| ServerError::InvalidToken)?; + let obj = self.db.resolve_message_object(&cap, actor_id)?; + Ok(ResolveMessageResponse { + object_id: obj.id, + ciphertext_base64: base64::engine::general_purpose::STANDARD.encode(&obj.ciphertext), + nonce_base64: base64::engine::general_purpose::STANDARD.encode(&obj.nonce), + tag_base64: base64::engine::general_purpose::STANDARD.encode(&obj.tag), + policy: obj.policy, + }) + } + + /// Full resolve + decrypt helper (for tests: fetch ciphertext then decrypt with stored content_key). + /// If content_key was stored, use it; otherwise caller must supply key. + pub fn resolve_and_decrypt( + &self, + capability_id_str: &str, + actor_id: Option, + key_override: Option<[u8; 32]>, + ) -> Result> { + let cap = parse_capability_id(capability_id_str).map_err(|_| ServerError::InvalidToken)?; + let obj = self.db.resolve_message_object(&cap, actor_id)?; + let key: [u8; 32] = if let Some(k) = key_override { + k + } else if let Some(ck) = &obj.content_key { + if ck.len() != 32 { + return Err(ServerError::Internal( + "stored content_key invalid length".into(), + )); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(ck); + arr + } else { + return Err(ServerError::Internal( + "no content_key available for decrypt (provide key_override)".into(), + )); + }; + if obj.nonce.len() != 12 || obj.tag.len() != 16 { + return Err(ServerError::Internal( + "stored nonce/tag invalid length".into(), + )); + } + let mut nonce_arr = [0u8; 12]; + nonce_arr.copy_from_slice(&obj.nonce); + Self::aead_decrypt(&obj.ciphertext, &obj.tag, &key, &nonce_arr) + } + + /// 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 { + self.db + .get_message_object(object_id)? + .ok_or_else(|| ServerError::NotFound(format!("message object {}", object_id))) + } + + /// Offline: store plaintext, return (object_id, content_key) for carrier embedding. + /// The carrier payload is `object_id (16 bytes, UUID) || content_key (32 bytes)` = 48 bytes. + /// Enforces 1024px+ check at embed time, not here. + #[allow(clippy::type_complexity)] + pub fn store_offline( + &self, + plaintext: &[u8], + policy: serde_json::Value, + owner_id: Option, + expires_at: Option>, + ) -> Result<(Uuid, Vec, Vec, Vec)> { + // Returns (object_id, content_key, nonce, tag) and stores ciphertext keyed also by capability for fallback? + // For offline we store with a random capability_id as well (not used in offline path but keeps table uniform) + let key = Self::generate_content_key(); + let nonce = Self::generate_nonce(); + let (ct, tag) = Self::aead_encrypt(plaintext, &key, &nonce)?; + let cap = Self::generate_capability_id(); + let obj = self.db.create_message_object(NewMessageObject { + capability_id: cap, + ciphertext: ct.clone(), + nonce: nonce.to_vec(), + tag: tag.clone(), + content_key: Some(key.to_vec()), + policy, + owner_id, + expires_at, + })?; + // Payload for offline carrier is object_id (UUID bytes) + content_key + // Caller will embed payload = obj.id.as_bytes() || key + Ok((obj.id, key.to_vec(), nonce.to_vec(), tag)) + } + + /// Offline resolve: given object_id, fetch and decrypt with provided content_key. + /// Still checks policy authorization. + pub fn resolve_offline( + &self, + object_id: &Uuid, + content_key: &[u8; 32], + actor_id: Option, + ) -> Result> { + let obj = self.get_message_object(object_id)?; + // Reuse same authz logic as capability path: check policy vs actor + // We do a dummy capability check by constructing a fake cap from object_id? Instead, check directly: + if let Some(exp) = obj.expires_at { + if Utc::now() >= exp { + return Err(ServerError::Expired); + } + } + if let Some(owner) = obj.owner_id { + match actor_id { + Some(actor) if actor == owner => {} + Some(actor) => { + if let Some(allow) = obj.policy.get("allow").and_then(|v| v.as_array()) { + let allowed = allow.iter().any(|v| v.as_str() == Some(&actor.to_string())); + if !allowed { + return Err(ServerError::Unauthorized(format!( + "actor {} not authorized for object {}", + actor, obj.id + ))); + } + } else { + return Err(ServerError::Unauthorized(format!( + "actor {} not owner {}", + actor, owner + ))); + } + } + None => { + return Err(ServerError::Unauthorized( + "missing actor_id for owner-restricted object".into(), + )) + } + } + } + if obj.nonce.len() != 12 || obj.tag.len() != 16 { + return Err(ServerError::Internal("stored nonce/tag invalid".into())); + } + let mut nonce_arr = [0u8; 12]; + nonce_arr.copy_from_slice(&obj.nonce); + Self::aead_decrypt(&obj.ciphertext, &obj.tag, content_key, &nonce_arr) + } } diff --git a/docs/pointer-and-stego.md b/docs/pointer-and-stego.md new file mode 100644 index 0000000..c2b3090 --- /dev/null +++ b/docs/pointer-and-stego.md @@ -0,0 +1,155 @@ +# Pointer-Mode Stego — CTX-0024 Implementation + +**Task:** CTX-0024 — Pointer-mode stego: image → capability → encrypted object +**Branch:** `ctx-0024/feat-pointer` +**Status:** Implemented (pointer-online default + pointer-offline for 1024px+) + +## 1. Three profiles (unchanged from `capglyph-docs/research/media-credential/technology/pointer-and-stego.md` §1) + +| Profile | Image carries | Server needed | Capacity ask | Implementation | +| ----------------- | --------------------------------------- | ----------------- | --------------- | -------------------------------------------------------- | +| `pointer-online` | 128-bit capability_id | yes | very low (16 B) | **Default** — bearer capability, server holds ciphertext | +| `pointer-offline` | `object_id (128b) + content_key (256b)` | object store only | 48 B payload | Mid-tier, 1024×+ carriers, store is dumb | +| `direct` | full AEAD ciphertext | no | high | Deferred — not in v1 | + +Credential is `pointer-online` with access-control semantics. + +## 2. Pointer-online (implemented) + +``` +plaintext + → ChaCha20-Poly1305 (key 32 B, nonce 12 B, tag 16 B) + → ciphertext stored in `message_objects` (capability_id FK, ciphertext, nonce, tag, policy, owner_id) + → capability_id (16 B, CSPRNG, base64url) sealed via `capglyph_core::framing` (CBOR + HMAC) + ECC + → carrier (DCT `F[3,4]` differential or DWT LH) → image +``` + +Extraction is the inverse plus `soft LLR` decode: + +``` +stego image → carrier demod → SoftBit LLR → ECC decode → framing open (HMAC verify) → capability_id + → server `resolve(capability_id, actor_id)` with authz (no IDOR) → ciphertext + → AEAD decrypt (tag verify) → plaintext +``` + +- **Framing:** `PayloadType::Pointer` (2), version 1, HMAC via `KeyMaterial.k_mac` +- **ECC:** `Repetition8` for 1024+ DCT / all DWT, `Bch{t=3}` for 512 DCT (insufficient blocks for Repetition8, same as credential ladder) +- **Carrier:** reuses `DctCarrier::embed_coded_bits` / `DwtCarrier::embed_coded_bits` and `extract_coded_bits_soft_with_hint` — same stack as credential +- **Authz (no IDOR):** `message_objects.owner_id` + `policy.allow` list. + - If `owner_id` set, only that owner or members of `allow` can resolve — wrong actor gets `403 Unauthorized` (tested: `end_to_end_pointer_online` IDOR negative) + - If no owner and no allow, bearer semantics: any holder of unguessable 128b capability can resolve (capability _is_ authority, but opaque and 2^128) + +## 3. Pointer-offline (implemented, 1024px+ gate) + +``` +plaintext → ChaCha20-Poly1305 → ciphertext (object_id, nonce, tag, content_key) in DB +carrier payload = object_id (16 B UUID BE) || content_key (32 B) = 48 B + → framing `PayloadType::Locator` + ECC + carrier (1024+ only) +``` + +- Offline check enforced in `pointer::embed_offline`: `w < 1024 || h < 1024` → `bail!("offline pointer requires image at least 1024x1024")` +- Extraction yields `(object_id, content_key)` directly; `Service::resolve_offline(object_id, content_key, actor_id)` still checks policy (no IDOR) before returning plaintext +- Capacity: 48 B payload → sealed ~86 B → BCH(31,16) ~1333 bits → 2666 block pairs + 512 sync < 4096 (512 DCT) but stealth requires 1024+ per spec §8 + +## 4. Direct message (deferred) + +`plaintext → AEAD → ECC → carrier` without server is a research path; not shipped. The 1024 offline path already covers "long letter" with dumb object store. + +## 5. Crypto choices + +- **Shared-secret:** `ChaCha20-Poly1305` (RFC 8439) via `chacha20poly1305 0.11` + `aead 0.6`, 12-byte nonce, 16-byte tag. Tag verification is fail-closed (`AEAD tag verification failed`). +- **Asymmetric:** HPKE (RFC 9180) deferred — single-primitive agility per `cryptographic-security.md` §5 (ship ChaCha now, HPKE as next `Sigil-Embed-v2` without stacking) +- **Framing MAC:** HMAC-SHA256 (`KeyMaterial.k_mac`) via `capglyph_core::framing::seal/open` — same as credential, not CRC + +## 6. DB schema (CTX-0024) + +```sql +CREATE TABLE message_objects ( + id TEXT PRIMARY KEY, -- UUID object_id + capability_id BLOB NOT NULL UNIQUE, -- 16 B bearer + capability_hash BLOB NOT NULL UNIQUE, -- SHA-256(capability_id) for indexed lookup + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, -- 12 B + tag BLOB NOT NULL, -- 16 B + content_key BLOB, -- 32 B (stored for offline re-derive / audit) + policy TEXT NOT NULL, -- JSON: {"allow": ["uuid"], ...} + owner_id TEXT, + created_at TEXT NOT NULL, + expires_at TEXT +); +``` + +- `capability_id` is stored raw (for audit) and hashed for lookup (like `credentials.token_hash` — never log raw bearer) +- `policy` is JSON; `owner_id` is denormalized for fast authz +- Migration: `crates/capglyph-server/migrations/002_pointer.sql` + `db::SCHEMA_SQL` update + +## 7. Service API (capglyph-server) + +```rust +impl Service { + fn aead_encrypt(plaintext, key, nonce) -> (ct, tag) + fn aead_decrypt(ct, tag, key, nonce) -> plaintext // tag verify + fn store_message(ct, nonce, tag, content_key, policy, owner, expires) -> capability_id + fn encrypt_and_store(plaintext, policy, owner, expires) -> (capability_id, key, nonce) + fn resolve_message(capability_id, actor_id) -> ResolveMessageResponse // + authz + fn resolve_and_decrypt(capability_id, actor_id, key_override) -> plaintext + fn store_offline(plaintext, policy, owner, expires) -> (object_id, key, nonce, tag) + fn resolve_offline(object_id, content_key, actor_id) -> plaintext +} +``` + +All methods are `tokio`-agnostic; HTTP layer (`http.rs`) exposes `POST /v1/messages` and `POST /v1/messages/resolve` (authz maps to `403`). + +## 8. Carrier reuse (capglyph crate) + +```rust +// capglyph/src/pointer.rs +pub fn embed_online(img, geo, cap, keys, placement, profile, mode) +pub fn extract_online(img, keys, profile, mode) -> cap +pub fn embed_offline(img, geo, object_id, content_key, keys, placement, profile, mode) // 1024 gate +pub fn extract_offline(img, keys, profile, mode) -> (object_id, content_key) +fn select_profile(w, h, mode) -> Profile // same ladder as credential +``` + +Tests share the same `capglyph_core::{framing,ecc}` + `KeyMaterial` as credential (`framed.rs`). + +## 9. CLI (capglyph crate) + +``` +capglyph pointer embed --input cover.png --output stego.png --plaintext-file msg.txt --mode dct --db ./capglyph.db --owner-id +capglyph pointer extract --input stego.png --mode dct --db ./capglyph.db --actor-id --output out.txt +capglyph pointer offline-embed --input cover1024.png --output stego1024.png --plaintext-file msg.txt --mode dwt --db ./capglyph.db +capglyph pointer offline-extract --input stego1024.png --mode dwt --db ./capglyph.db --actor-id + +capglyph message encrypt --plaintext-file msg.txt --db ./capglyph.db --owner-id # server only, prints capability_id +capglyph message decrypt --capability-id --db ./capglyph.db --actor-id --output out.txt +capglyph message store/resolve # same +``` + +`pointer` is image→capability; `message` is convenience alias (`encrypt`/`decrypt` map to pointer embed/extract, `store`/`resolve` map to server-only). + +## 10. Tests (acceptance) + +- `pointer::tests::end_to_end_pointer_online` — encrypt→store→embed(1024 DCT)→extract→resolve→decrypt, AEAD tag tamper fails, IDOR negative (wrong actor → Unauthorized), bearer (no owner) succeeds +- `pointer::tests::end_to_end_offline_1024` — store_offline→embed(1024 DWT, 48 B)→extract→resolve_offline, IDOR negative, 512 offline correctly rejects with `offline pointer requires image at least 1024x1024` +- `pointer::tests::{aead_roundtrip, payload_offline_roundtrip, offline_requires_1024, embed_extract_*}` — unit coverage +- Existing `framed.rs`, `registration.rs`, `integration.rs` still pass (shared stack) + +## 11. Verification gates + +```bash +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test --workspace +cargo check --lib --target wasm32-unknown-unknown # capglyph-core + capglyph lib (pointer gated, no clap/glob in wasm) +cargo tree --target wasm32-unknown-unknown | grep -E 'clap|glob|trustmark|c2pa' # expect no output +``` + +All gates pass (2026-08-31). + +## 12. References + +- `capglyph-docs/research/media-credential/technology/pointer-and-stego.md` — normative three-profile design, sync/placement ladder +- `capglyph-docs/research/media-credential/technology/capacity-robustness-and-threats.md` — carrier ceilings vs robust/stealth, BCH ladder +- `capglyph-docs/research/media-credential/usage/credential-design.md` — DB schema, atomic consume (pointer reuses same `capability_hash` pattern as `token_hash`) +- `capglyph-core/src/framing.rs` / `ecc.rs` — CBOR + HMAC, BCH/RS + soft LLR diff --git a/src/cli.rs b/src/cli.rs index 964959f..d290f21 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -82,6 +82,10 @@ pub enum Commands { Batch(BatchArgs), /// Extract embedded recipient ID from watermarked image Extract(ExtractArgs), + /// Pointer-mode stego: image → capability → encrypted object (CTX-0024) + Pointer(PointerArgs), + /// Message encryption via pointer carrier (CTX-0024) + Message(MessageArgs), /// Download learned-mode ONNX models (TrustMark) into the model dir #[cfg(feature = "learned")] FetchModels(FetchModelsArgs), @@ -437,6 +441,211 @@ pub enum OutputFormat { Jpg, } +// ─── pointer (CTX-0024) ───────────────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct PointerArgs { + #[command(subcommand)] + pub command: PointerCommand, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Subcommand, Debug)] +pub enum PointerCommand { + /// Embed capability (pointer-online) into image: plaintext → AEAD → server → carrier + Embed(PointerEmbedArgs), + /// Extract capability from image → server resolve → decrypt + Extract(PointerExtractArgs), + /// Offline: embed object_id+content_key for direct decrypt (1024px+ only) + OfflineEmbed(PointerOfflineEmbedArgs), + /// Offline extract + OfflineExtract(PointerOfflineExtractArgs), +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct PointerEmbedArgs { + /// Cover image (input) + pub input: PathBuf, + + /// Output stego image + #[arg(short, long)] + pub output: Option, + + /// Plaintext file to encrypt (if omitted, reads from stdin or --plaintext) + #[arg(long)] + pub plaintext_file: Option, + + /// Plaintext string (alternative to file) + #[arg(long)] + pub plaintext: Option, + + /// Carrier mode (dct/dwt) + #[arg(long, default_value = "dct")] + pub mode: EmbedMode, + + #[arg(long, default_value = "skeleton")] + pub placement: PlacementStrategy, + + /// Server DB path (SQLite file, created if missing) + #[arg(long)] + pub db: Option, + + /// Owner actor ID (UUID for authz) + #[arg(long)] + pub owner_id: Option, + + /// Policy JSON (e.g. '{"allow":["uuid"]}') + #[arg(long)] + pub policy: Option, + + /// Secret for K_mac derivation (optional, defaults to test key) + #[arg(long)] + pub key: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct PointerExtractArgs { + /// Stego image to extract from + pub input: PathBuf, + + #[arg(long, default_value = "dct")] + pub mode: EmbedMode, + + #[arg(long, default_value = "skeleton")] + pub placement: PlacementStrategy, + + #[arg(long)] + pub db: Option, + + /// Actor ID for authorization (must match owner or allow list) + #[arg(long)] + pub actor_id: Option, + + /// Output plaintext file (if omitted, prints to stdout) + #[arg(short, long)] + pub output: Option, + + #[arg(long)] + pub key: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct PointerOfflineEmbedArgs { + pub input: PathBuf, + + #[arg(short, long)] + pub output: Option, + + #[arg(long)] + pub plaintext_file: Option, + + #[arg(long)] + pub plaintext: Option, + + #[arg(long, default_value = "dwt")] + pub mode: EmbedMode, + + #[arg(long, default_value = "skeleton")] + pub placement: PlacementStrategy, + + #[arg(long)] + pub db: Option, + + #[arg(long)] + pub owner_id: Option, + + #[arg(long)] + pub policy: Option, + + #[arg(long)] + pub key: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct PointerOfflineExtractArgs { + pub input: PathBuf, + + #[arg(long, default_value = "dwt")] + pub mode: EmbedMode, + + #[arg(long, default_value = "skeleton")] + pub placement: PlacementStrategy, + + #[arg(long)] + pub db: Option, + + #[arg(long)] + pub actor_id: Option, + + #[arg(short, long)] + pub output: Option, + + #[arg(long)] + pub key: Option, +} + +// ─── message (CTX-0024) ───────────────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct MessageArgs { + #[command(subcommand)] + pub command: MessageCommand, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Subcommand, Debug)] +pub enum MessageCommand { + /// Encrypt and store (alias for pointer embed) + Encrypt(PointerEmbedArgs), + /// Extract and decrypt (alias for pointer extract) + Decrypt(PointerExtractArgs), + /// Direct store without carrier (server only) + Store(MessageStoreArgs), + /// Direct resolve without carrier + Resolve(MessageResolveArgs), +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct MessageStoreArgs { + #[arg(long)] + pub plaintext_file: Option, + + #[arg(long)] + pub plaintext: Option, + + #[arg(long)] + pub db: Option, + + #[arg(long)] + pub owner_id: Option, + + #[arg(long)] + pub policy: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Args, Debug)] +pub struct MessageResolveArgs { + #[arg(long)] + pub capability_id: String, + + #[arg(long)] + pub db: Option, + + #[arg(long)] + pub actor_id: Option, + + #[arg(short, long)] + pub output: Option, +} + // ─── extract ───────────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] diff --git a/src/lib.rs b/src/lib.rs index af54835..b5dcd8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,8 @@ pub mod info; #[cfg(feature = "learned")] pub mod learned; #[cfg(not(target_arch = "wasm32"))] +pub mod pointer; +#[cfg(not(target_arch = "wasm32"))] pub mod strip; pub mod verify; pub mod wasm_api; diff --git a/src/main.rs b/src/main.rs index c146a3c..c401646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use tracing_subscriber::FmtSubscriber; #[cfg(not(target_arch = "wasm32"))] use capglyph::cli::{Cli, Commands}; #[cfg(not(target_arch = "wasm32"))] -use capglyph::{batch, embed, extract, info, strip, verify}; +use capglyph::{batch, embed, extract, info, pointer, strip, verify}; #[cfg(target_arch = "wasm32")] fn main() {} @@ -41,6 +41,22 @@ fn main() -> anyhow::Result<()> { let id = extract::run(args)?; println!("{}", id); } + Commands::Pointer(args) => match &args.command { + capglyph::cli::PointerCommand::Embed(a) => pointer::run_pointer_embed(a)?, + capglyph::cli::PointerCommand::Extract(a) => pointer::run_pointer_extract(a)?, + capglyph::cli::PointerCommand::OfflineEmbed(a) => { + pointer::run_pointer_offline_embed(a)? + } + capglyph::cli::PointerCommand::OfflineExtract(a) => { + pointer::run_pointer_offline_extract(a)? + } + }, + Commands::Message(args) => match &args.command { + capglyph::cli::MessageCommand::Encrypt(a) => pointer::run_pointer_embed(a)?, + capglyph::cli::MessageCommand::Decrypt(a) => pointer::run_pointer_extract(a)?, + capglyph::cli::MessageCommand::Store(a) => pointer::run_message_store(a)?, + capglyph::cli::MessageCommand::Resolve(a) => pointer::run_message_resolve(a)?, + }, #[cfg(feature = "learned")] Commands::FetchModels(args) => { let dir = capglyph::learned::model_dir(args.model_dir.as_deref()); diff --git a/src/pointer.rs b/src/pointer.rs new file mode 100644 index 0000000..8760a88 --- /dev/null +++ b/src/pointer.rs @@ -0,0 +1,904 @@ +//! Pointer-mode stego: image → capability → encrypted object (CTX-0024). +//! +//! Three profiles per `pointer-and-stego.md` §1: +//! - `pointer-online` (default): 128-bit capability_id in carrier → server object lookup with authz (no IDOR) +//! - `pointer-offline`: object_id (128b) + content_key (256b) in carrier for direct decrypt, 1024px+ only +//! - `direct`: full AEAD ciphertext in carrier (not yet, deferred) +//! +//! This module shares the same `capglyph_core::{framing,ecc}` + `Carrier` stack as credential. +//! AEAD: ChaCha20-Poly1305 (RFC 8439) with 12-byte nonce, 16-byte tag, 32-byte key. +//! HPKE is deferred (single-primitive agility per `cryptographic-security.md` §5). + +#![allow(deprecated)] +#![allow(clippy::too_many_arguments, clippy::type_complexity, clippy::needless_range_loop)] + +use anyhow::{Context, Result}; +use base64::Engine as _; +use chacha20poly1305::KeyInit as _; +use image::{ImageBuffer, Rgb}; +use uuid::Uuid; + +#[cfg(not(target_arch = "wasm32"))] +use rand::RngCore; + +use crate::core::geometry::GeometryFile; +use crate::keying::KeyMaterial; + +/// Minimum dimension for offline pointer (image must be at least 1024×1024). +pub const OFFLINE_MIN_DIMENSION: u32 = 1024; + +/// Generate a fresh 32-byte content key (CSPRNG). +#[cfg(not(target_arch = "wasm32"))] +pub fn generate_content_key() -> [u8; 32] { + let mut k = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut k); + k +} + +/// Generate a fresh 12-byte nonce. +#[cfg(not(target_arch = "wasm32"))] +pub fn generate_nonce() -> [u8; 12] { + let mut n = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut n); + n +} + +/// Generate a fresh 16-byte capability_id. +#[cfg(not(target_arch = "wasm32"))] +pub fn generate_capability_id() -> [u8; 16] { + let mut c = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut c); + c +} + +// ── AEAD helpers (ChaCha20-Poly1305) ──────────────────────────────────────── + +pub fn aead_encrypt( + plaintext: &[u8], + key: &[u8; 32], + nonce_bytes: &[u8; 12], +) -> Result<(Vec, Vec)> { + use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, Nonce}; + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let nonce = Nonce::from_slice(nonce_bytes); + let combined = cipher + .encrypt(nonce, plaintext) + .map_err(|e| anyhow::anyhow!("AEAD encrypt failed: {e}"))?; + if combined.len() < 16 { + anyhow::bail!("AEAD output too short"); + } + let (ct, tag) = combined.split_at(combined.len() - 16); + Ok((ct.to_vec(), tag.to_vec())) +} + +pub fn aead_decrypt( + ciphertext: &[u8], + tag: &[u8], + key: &[u8; 32], + nonce_bytes: &[u8; 12], +) -> Result> { + use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Key, Nonce}; + let mut combined = Vec::with_capacity(ciphertext.len() + tag.len()); + combined.extend_from_slice(ciphertext); + combined.extend_from_slice(tag); + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let nonce = Nonce::from_slice(nonce_bytes); + let pt = cipher + .decrypt(nonce, combined.as_ref()) + .map_err(|_| anyhow::anyhow!("AEAD tag verification failed"))?; + Ok(pt) +} + +// ── Payload helpers ───────────────────────────────────────────────────────── + +/// Online payload: 16-byte capability_id. +pub fn payload_online(capability_id: &[u8; 16]) -> Vec { + capability_id.to_vec() +} + +/// Offline payload: UUID (16 bytes BE) || content_key (32 bytes) = 48 bytes. +/// UUID bytes are big-endian per RFC 4122 `as_bytes()`. +pub fn payload_offline(object_id: &Uuid, content_key: &[u8; 32]) -> Vec { + let mut out = Vec::with_capacity(48); + out.extend_from_slice(object_id.as_bytes()); + out.extend_from_slice(content_key); + out +} + +/// Parse offline payload back to (object_id, content_key). +pub fn parse_payload_offline(payload: &[u8]) -> Result<(Uuid, [u8; 32])> { + anyhow::ensure!( + payload.len() == 48, + "offline payload must be 48 bytes (object_id 16 + key 32), got {}", + payload.len() + ); + let (id_bytes, key_bytes) = payload.split_at(16); + let id = Uuid::from_slice(id_bytes).context("invalid UUID bytes")?; + let mut key = [0u8; 32]; + key.copy_from_slice(key_bytes); + Ok((id, key)) +} + +// ── Carrier helpers ───────────────────────────────────────────────────────── + +/// Choose ECC profile for pointer based on image size and mode. +/// - 512 DCT with Repetition8 insufficient → use Bch t=3 +/// - 1024+ DCT → Repetition8 is most robust (soft LLR) +/// - DWT → Repetition8 (ample LH) +pub fn select_profile(w: u32, h: u32, mode: &crate::cli::EmbedMode) -> capglyph_core::ecc::Profile { + use capglyph_core::ecc::Profile; + match mode { + crate::cli::EmbedMode::Dct => { + if w < 1024 || h < 1024 { + Profile::Bch { t: 3 } + } else { + Profile::Repetition8 + } + } + crate::cli::EmbedMode::Dwt => Profile::Repetition8, + _ => Profile::Bch { t: 3 }, + } +} + +/// Framing params for pointer-online (capability_id). +fn params_online() -> capglyph_core::framing::Params { + capglyph_core::framing::Params { + version: 1, + payload_type: capglyph_core::framing::PayloadType::Pointer, + flags: 0, + } +} + +/// Framing params for pointer-offline (object_id+content_key). +fn params_offline() -> capglyph_core::framing::Params { + capglyph_core::framing::Params { + version: 1, + payload_type: capglyph_core::framing::PayloadType::Locator, + flags: 0, + } +} + +/// Embed online capability (16b) into image. +/// Returns (marked_blocks, positions). +pub fn embed_online( + img: &mut ImageBuffer, Vec>, + geometry: &GeometryFile, + capability_id: &[u8; 16], + keys: &KeyMaterial, + placement: &crate::cli::PlacementStrategy, + profile: capglyph_core::ecc::Profile, + mode: crate::cli::EmbedMode, +) -> Result<(u64, Vec<(u32, u32)>)> { + let payload = payload_online(capability_id); + let params = params_online(); + let sealed = capglyph_core::framing::seal(&payload, ¶ms, keys.k_mac()); + let coded = capglyph_core::ecc::encode(&sealed, profile); + let coded_bits: Vec = match profile { + capglyph_core::ecc::Profile::Repetition8 | capglyph_core::ecc::Profile::Bch { .. } => { + coded.iter().map(|&b| b != 0).collect() + } + capglyph_core::ecc::Profile::RsInterleaved { .. } => { + capglyph_core::ecc::bytes_to_bits(&coded) + } + }; + match mode { + crate::cli::EmbedMode::Dct => { + crate::dct::embed_coded_bits(img, geometry, &coded_bits, keys, placement) + } + crate::cli::EmbedMode::Dwt => { + crate::dwt_embed::embed_coded_bits(img, geometry, &coded_bits, keys, placement) + } + _ => anyhow::bail!("pointer embed only supports dct/dwt modes"), + } +} + +/// Extract online capability from image. +pub fn extract_online( + img: &ImageBuffer, Vec>, + keys: &KeyMaterial, + profile: capglyph_core::ecc::Profile, + mode: crate::cli::EmbedMode, +) -> Result<[u8; 16]> { + let payload = extract_payload( + img, + keys, + profile, + mode, + 16, + capglyph_core::framing::PayloadType::Pointer, + )?; + let mut out = [0u8; 16]; + out.copy_from_slice(&payload); + Ok(out) +} + +/// Embed offline payload (48b) — enforces 1024px+ check. +#[allow(clippy::too_many_arguments)] +pub fn embed_offline( + img: &mut ImageBuffer, Vec>, + geometry: &GeometryFile, + object_id: &Uuid, + content_key: &[u8; 32], + keys: &KeyMaterial, + placement: &crate::cli::PlacementStrategy, + profile: capglyph_core::ecc::Profile, + mode: crate::cli::EmbedMode, +) -> Result<(u64, Vec<(u32, u32)>)> { + let (w, h) = img.dimensions(); + anyhow::ensure!( + w >= OFFLINE_MIN_DIMENSION && h >= OFFLINE_MIN_DIMENSION, + "offline pointer requires image at least {}x{} (got {}x{}): payload 48 bytes needs 1024+ carrier", + OFFLINE_MIN_DIMENSION, + OFFLINE_MIN_DIMENSION, + w, + h + ); + let payload = payload_offline(object_id, content_key); + let params = params_offline(); + let sealed = capglyph_core::framing::seal(&payload, ¶ms, keys.k_mac()); + let coded = capglyph_core::ecc::encode(&sealed, profile); + let coded_bits: Vec = match profile { + capglyph_core::ecc::Profile::Repetition8 | capglyph_core::ecc::Profile::Bch { .. } => { + coded.iter().map(|&b| b != 0).collect() + } + capglyph_core::ecc::Profile::RsInterleaved { .. } => { + capglyph_core::ecc::bytes_to_bits(&coded) + } + }; + match mode { + crate::cli::EmbedMode::Dct => { + crate::dct::embed_coded_bits(img, geometry, &coded_bits, keys, placement) + } + crate::cli::EmbedMode::Dwt => { + crate::dwt_embed::embed_coded_bits(img, geometry, &coded_bits, keys, placement) + } + _ => anyhow::bail!("pointer offline only supports dct/dwt"), + } +} + +/// Extract offline payload. +pub fn extract_offline( + img: &ImageBuffer, Vec>, + keys: &KeyMaterial, + profile: capglyph_core::ecc::Profile, + mode: crate::cli::EmbedMode, +) -> Result<(Uuid, [u8; 32])> { + let payload = extract_payload( + img, + keys, + profile, + mode, + 48, + capglyph_core::framing::PayloadType::Locator, + )?; + let (id, key) = parse_payload_offline(&payload)?; + Ok((id, key)) +} + +// ── Generic payload extract ───────────────────────────────────────────────── + +fn extract_payload( + img: &ImageBuffer, Vec>, + keys: &KeyMaterial, + profile: capglyph_core::ecc::Profile, + mode: crate::cli::EmbedMode, + expected_len: usize, + expected_type: capglyph_core::framing::PayloadType, +) -> Result> { + let params = capglyph_core::framing::Params { + version: 1, + payload_type: expected_type, + flags: 0, + }; + let sealed_len = capglyph_core::framing::sealed_len(expected_len, ¶ms); + let need_bits = capglyph_core::ecc::coded_bits_len(sealed_len, profile); + // Extract soft bits via appropriate carrier + let soft: Vec = match mode { + crate::cli::EmbedMode::Dct => { + crate::dct::extract_coded_bits_soft_with_hint(img, keys, Some(need_bits))? + } + crate::cli::EmbedMode::Dwt => { + crate::dwt_embed::extract_coded_bits_soft_with_hint(img, keys, Some(need_bits))? + } + _ => anyhow::bail!("pointer extract only supports dct/dwt"), + }; + let mut decoded_sealed = capglyph_core::ecc::decode(&soft, profile)?; + if decoded_sealed.len() > sealed_len { + decoded_sealed.truncate(sealed_len); + } + let (hdr, payload) = capglyph_core::framing::open(&decoded_sealed, keys.k_mac())?; + anyhow::ensure!( + hdr.payload_type == expected_type, + "unexpected payload type: got {:?}, expected {:?}", + hdr.payload_type, + expected_type + ); + anyhow::ensure!( + payload.len() == expected_len, + "payload length mismatch: got {}, expected {}", + payload.len(), + expected_len + ); + Ok(payload) +} + +// ── High-level image helpers (for tests/CLI) ──────────────────────────────── + +/// Create a synthetic test image (like `framed.rs` make_image). +pub fn make_test_image(w: u32, h: u32) -> ImageBuffer, Vec> { + ImageBuffer::from_fn(w, h, |x, y| { + let v = ((x * 7 + y * 13 + x * y / 3) % 251) as u8; + Rgb([v, v.wrapping_add(60), v.wrapping_add(120)]) + }) +} + +pub fn make_test_geometry(w: u32, h: u32) -> GeometryFile { + use crate::core::geometry::{AnalysisParams, PathEntry}; + let points: Vec<[f64; 2]> = (0..64) + .map(|i| [i as f64 * (w as f64 / 64.0), (i as f64 * 3.0) % h as f64]) + .collect(); + GeometryFile { + version: 1, + original_width: w, + original_height: h, + analysis_params: AnalysisParams { + detail: 60, + min_path_len: 5, + chaikin_iters: 3, + color: false, + }, + paths: vec![PathEntry { + color: None, + points, + }], + prng_seed: None, + blocks: None, + } +} + +pub fn test_keys() -> KeyMaterial { + KeyMaterial::from_keys([0x11u8; 32], [0x22u8; 32]) +} + +/// Capability helpers for carrier (base64url). +pub fn capability_to_base64url(cap: &[u8; 16]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(cap) +} + +pub fn capability_from_base64url(s: &str) -> Result<[u8; 16]> { + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(s.trim()) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(s.trim())) + .or_else(|_| hex::decode(s.trim()).map_err(|e| anyhow::anyhow!("{e}"))) + .context("invalid capability_id encoding")?; + anyhow::ensure!(bytes.len() == 16, "capability must be 16 bytes"); + let mut out = [0u8; 16]; + out.copy_from_slice(&bytes); + Ok(out) +} + +// ── CLI helpers (not for wasm) ──────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +pub fn db_from_path(path: Option<&std::path::Path>) -> Result { + if let Some(p) = path { + capglyph_server::db::Db::new(p).context("open DB") + } else { + // Default: ./capglyph.db in current dir, or temp if not writable + let default = std::path::Path::new("capglyph.db"); + capglyph_server::db::Db::new(default).context("open default ./capglyph.db") + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_plaintext(args_pt: Option<&str>, args_file: Option<&std::path::Path>) -> Result> { + if let Some(f) = args_file { + std::fs::read(f).with_context(|| format!("read plaintext file {:?}", f)) + } else if let Some(s) = args_pt { + Ok(s.as_bytes().to_vec()) + } else { + anyhow::bail!("provide --plaintext or --plaintext-file ") + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn parse_owner(s: Option<&str>) -> Result> { + match s { + Some(v) => Ok(Some(Uuid::parse_str(v).context("invalid owner_id UUID")?)), + None => Ok(None), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn parse_policy(s: Option<&str>) -> Result { + match s { + Some(v) => serde_json::from_str(v).context("invalid policy JSON"), + None => Ok(serde_json::json!({})), + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::type_complexity)] +fn load_image_and_geometry( + input: &std::path::Path, +) -> Result<(ImageBuffer, Vec>, GeometryFile, u32, u32)> { + let dyn_img = image::open(input).with_context(|| format!("open image {:?}", input))?; + let rgb = dyn_img.to_rgb8(); + let (w, h) = rgb.dimensions(); + let geo = crate::embed::extract_and_build_geometry( + &rgb, + w, + h, + &crate::embed::GeometryParams { + detail: 60, + min_path_len: 5, + chaikin_iters: 3, + color: false, + recipient_id: None, + }, + )?; + Ok((rgb, geo, w, h)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn keys_from_opt(key: Option<&str>) -> KeyMaterial { + if let Some(k) = key { + KeyMaterial::from_ikm(k, &[0u8; 16]) + } else { + // Deterministic default for tests / CLI without key + KeyMaterial::from_keys([0x11u8; 32], [0x22u8; 32]) + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_pointer_embed(args: &crate::cli::PointerEmbedArgs) -> Result<()> { + let pt = read_plaintext(args.plaintext.as_deref(), args.plaintext_file.as_deref())?; + let db = db_from_path(args.db.as_deref())?; + let svc = capglyph_server::service::Service::new_with_random_kms(db); + let owner = parse_owner(args.owner_id.as_deref())?; + let policy = parse_policy(args.policy.as_deref())?; + // Encrypt and store server-side, get capability + let (resp, _key, _nonce) = svc + .encrypt_and_store(&pt, policy, owner, None) + .context("store message")?; + let cap = capability_from_base64url(&resp.capability_id)?; + // Load cover image and geometry + let (mut rgb, geo, w, h) = load_image_and_geometry(&args.input)?; + let keys = keys_from_opt(args.key.as_deref()); + let profile = select_profile(w, h, &args.mode); + let (_, _) = embed_online( + &mut rgb, + &geo, + &cap, + &keys, + &args.placement, + profile, + args.mode, + )?; + // Save stego image + let output = args.output.clone().unwrap_or_else(|| { + let stem = args.input.file_stem().unwrap().to_string_lossy(); + let parent = args + .input + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + parent.join(format!("{}_pointer.png", stem)) + }); + // Preserve alpha? For now save as RGB + let dyn_out = image::DynamicImage::ImageRgb8(rgb); + dyn_out + .save(&output) + .with_context(|| format!("save stego {:?}", output))?; + println!( + "pointer embed: capability {} → {:?}", + resp.capability_id, output + ); + println!("object_id: {}", resp.object_id); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_pointer_extract(args: &crate::cli::PointerExtractArgs) -> Result<()> { + let db = db_from_path(args.db.as_deref())?; + let svc = capglyph_server::service::Service::new_with_random_kms(db); + let actor = parse_owner(args.actor_id.as_deref())?; + // Load stego image + let dyn_img = image::open(&args.input).with_context(|| format!("open {:?}", args.input))?; + let rgb = dyn_img.to_rgb8(); + let (w, h) = rgb.dimensions(); + let keys = keys_from_opt(args.key.as_deref()); + let profile = select_profile(w, h, &args.mode); + let cap = extract_online(&rgb, &keys, profile, args.mode)?; + let cap_b64 = capability_to_base64url(&cap); + // Resolve with authz + let pt = svc + .resolve_and_decrypt(&cap_b64, actor, None) + .context("resolve/decrypt")?; + if let Some(out) = &args.output { + std::fs::write(out, &pt).with_context(|| format!("write {:?}", out))?; + println!("pointer extract: decrypted {} bytes → {:?}", pt.len(), out); + } else { + // Try to print as utf8, else base64 + match String::from_utf8(pt.clone()) { + Ok(s) => println!("{}", s), + Err(_) => println!("{}", base64::engine::general_purpose::STANDARD.encode(&pt)), + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_pointer_offline_embed(args: &crate::cli::PointerOfflineEmbedArgs) -> Result<()> { + let pt = read_plaintext(args.plaintext.as_deref(), args.plaintext_file.as_deref())?; + let db = db_from_path(args.db.as_deref())?; + let svc = capglyph_server::service::Service::new_with_random_kms(db); + let owner = parse_owner(args.owner_id.as_deref())?; + let policy = parse_policy(args.policy.as_deref())?; + // Store offline: get object_id + key + let (object_id, content_key, _nonce, _tag) = svc + .store_offline(&pt, policy, owner, None) + .context("store offline")?; + let mut key_arr = [0u8; 32]; + key_arr.copy_from_slice(&content_key); + // Load image + let (mut rgb, geo, w, h) = load_image_and_geometry(&args.input)?; + let keys = keys_from_opt(args.key.as_deref()); + let profile = select_profile(w, h, &args.mode); + embed_offline( + &mut rgb, + &geo, + &object_id, + &key_arr, + &keys, + &args.placement, + profile, + args.mode, + )?; + let output = args.output.clone().unwrap_or_else(|| { + let stem = args.input.file_stem().unwrap().to_string_lossy(); + let parent = args + .input + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + parent.join(format!("{}_offline.png", stem)) + }); + image::DynamicImage::ImageRgb8(rgb) + .save(&output) + .with_context(|| format!("save {:?}", output))?; + println!("offline embed: object {} → {:?}", object_id, output); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_pointer_offline_extract(args: &crate::cli::PointerOfflineExtractArgs) -> Result<()> { + let db = db_from_path(args.db.as_deref())?; + let svc = capglyph_server::service::Service::new_with_random_kms(db); + let actor = parse_owner(args.actor_id.as_deref())?; + let dyn_img = image::open(&args.input).with_context(|| format!("open {:?}", args.input))?; + let rgb = dyn_img.to_rgb8(); + let (w, h) = rgb.dimensions(); + let keys = keys_from_opt(args.key.as_deref()); + let profile = select_profile(w, h, &args.mode); + let (object_id, content_key) = extract_offline(&rgb, &keys, profile, args.mode)?; + let pt = svc + .resolve_offline(&object_id, &content_key, actor) + .context("offline resolve")?; + if let Some(out) = &args.output { + std::fs::write(out, &pt).with_context(|| format!("write {:?}", out))?; + println!("offline extract: {} bytes → {:?}", pt.len(), out); + } else { + match String::from_utf8(pt.clone()) { + Ok(s) => println!("{}", s), + Err(_) => println!("{}", base64::engine::general_purpose::STANDARD.encode(&pt)), + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_message_store(args: &crate::cli::MessageStoreArgs) -> Result<()> { + let pt = read_plaintext(args.plaintext.as_deref(), args.plaintext_file.as_deref())?; + let db = db_from_path(args.db.as_deref())?; + let svc = capglyph_server::service::Service::new_with_random_kms(db); + let owner = parse_owner(args.owner_id.as_deref())?; + let policy = parse_policy(args.policy.as_deref())?; + let (resp, _, _) = svc.encrypt_and_store(&pt, policy, owner, None)?; + println!("{}", resp.capability_id); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_message_resolve(args: &crate::cli::MessageResolveArgs) -> Result<()> { + let db = db_from_path(args.db.as_deref())?; + let svc = capglyph_server::service::Service::new_with_random_kms(db); + let actor = parse_owner(args.actor_id.as_deref())?; + let pt = svc.resolve_and_decrypt(&args.capability_id, actor, None)?; + if let Some(out) = &args.output { + std::fs::write(out, &pt).with_context(|| format!("write {:?}", out))?; + println!("resolved {} bytes → {:?}", pt.len(), out); + } else { + match String::from_utf8(pt.clone()) { + Ok(s) => println!("{}", s), + Err(_) => println!("{}", base64::engine::general_purpose::STANDARD.encode(&pt)), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aead_roundtrip() { + let key = [0x42u8; 32]; + let nonce = [0x11u8; 12]; + let pt = b"hello pointer mode"; + let (ct, tag) = aead_encrypt(pt, &key, &nonce).unwrap(); + let pt2 = aead_decrypt(&ct, &tag, &key, &nonce).unwrap(); + assert_eq!(pt.to_vec(), pt2); + } + + #[test] + fn aead_tamper_fails() { + let key = [0x42u8; 32]; + let nonce = [0x11u8; 12]; + let pt = b"secret message"; + let (mut ct, tag) = aead_encrypt(pt, &key, &nonce).unwrap(); + ct[0] ^= 1; + assert!(aead_decrypt(&ct, &tag, &key, &nonce).is_err()); + } + + #[test] + fn payload_offline_roundtrip() { + let id = Uuid::new_v4(); + let key = [0xAAu8; 32]; + let payload = payload_offline(&id, &key); + assert_eq!(payload.len(), 48); + let (id2, key2) = parse_payload_offline(&payload).unwrap(); + assert_eq!(id, id2); + assert_eq!(key, key2); + } + + #[test] + fn embed_extract_online_dct_1024() { + let (w, h) = (1024, 1024); + let mut img = make_test_image(w, h); + let geo = make_test_geometry(w, h); + let keys = test_keys(); + let cap = [0xAB; 16]; + let profile = select_profile(w, h, &crate::cli::EmbedMode::Dct); + embed_online( + &mut img, + &geo, + &cap, + &keys, + &crate::cli::PlacementStrategy::Skeleton, + profile, + crate::cli::EmbedMode::Dct, + ) + .unwrap(); + let out = extract_online(&img, &keys, profile, crate::cli::EmbedMode::Dct).unwrap(); + assert_eq!(cap, out); + } + + #[test] + fn embed_extract_online_dwt_512() { + let (w, h) = (512, 512); + let mut img = make_test_image(w, h); + let geo = make_test_geometry(w, h); + let keys = test_keys(); + let cap = [0xCD; 16]; + let profile = select_profile(w, h, &crate::cli::EmbedMode::Dwt); + embed_online( + &mut img, + &geo, + &cap, + &keys, + &crate::cli::PlacementStrategy::Skeleton, + profile, + crate::cli::EmbedMode::Dwt, + ) + .unwrap(); + let out = extract_online(&img, &keys, profile, crate::cli::EmbedMode::Dwt).unwrap(); + assert_eq!(cap, out); + } + + #[test] + fn offline_requires_1024() { + let (w, h) = (512, 512); + let mut img = make_test_image(w, h); + let geo = make_test_geometry(w, h); + let keys = test_keys(); + let id = Uuid::new_v4(); + let key = [0x11u8; 32]; + let profile = select_profile(w, h, &crate::cli::EmbedMode::Dct); + let res = embed_offline( + &mut img, + &geo, + &id, + &key, + &keys, + &crate::cli::PlacementStrategy::Skeleton, + profile, + crate::cli::EmbedMode::Dct, + ); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("1024")); + } + + #[test] + fn embed_extract_offline_1024() { + let (w, h) = (1024, 1024); + let mut img = make_test_image(w, h); + let geo = make_test_geometry(w, h); + let keys = test_keys(); + let id = Uuid::new_v4(); + let ck = [0x55u8; 32]; + let profile = select_profile(w, h, &crate::cli::EmbedMode::Dwt); + embed_offline( + &mut img, + &geo, + &id, + &ck, + &keys, + &crate::cli::PlacementStrategy::Skeleton, + profile, + crate::cli::EmbedMode::Dwt, + ) + .unwrap(); + let (id2, ck2) = extract_offline(&img, &keys, profile, crate::cli::EmbedMode::Dwt).unwrap(); + assert_eq!(id, id2); + assert_eq!(ck, ck2); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn end_to_end_pointer_online() { + use capglyph_server::{db::Db, service::Service}; + let db = Db::new_in_memory().unwrap(); + let svc = Service::new_with_random_kms(db); + let owner = Uuid::new_v4(); + let actor_ok = owner; + let actor_bad = Uuid::new_v4(); + let plaintext = b"hello pointer online - secret message 123"; + + // Encrypt + store (server) + let (resp, key, nonce) = svc + .encrypt_and_store(plaintext, serde_json::json!({}), Some(owner), None) + .unwrap(); + let cap_b64 = resp.capability_id.clone(); + + // Embed capability into image + let (w, h) = (1024, 1024); + let mut img = make_test_image(w, h); + let geo = make_test_geometry(w, h); + let keys = test_keys(); + let cap = capability_from_base64url(&cap_b64).unwrap(); + let profile = select_profile(w, h, &crate::cli::EmbedMode::Dct); + embed_online( + &mut img, + &geo, + &cap, + &keys, + &crate::cli::PlacementStrategy::Skeleton, + profile, + crate::cli::EmbedMode::Dct, + ) + .unwrap(); + + // Extract capability + let cap2 = extract_online(&img, &keys, profile, crate::cli::EmbedMode::Dct).unwrap(); + assert_eq!(cap, cap2); + let cap2_b64 = capability_to_base64url(&cap2); + + // Resolve with correct actor → decrypt succeeds + let mut key_arr = [0u8; 32]; + key_arr.copy_from_slice(&key); + let mut nonce_arr = [0u8; 12]; + nonce_arr.copy_from_slice(&nonce); + // Use resolve_and_decrypt which fetches stored key internally + let pt = svc + .resolve_and_decrypt(&cap2_b64, Some(actor_ok), None) + .unwrap(); + assert_eq!(pt, plaintext); + + // IDOR: wrong actor should fail when owner-restricted + // Re-store with owner policy, try wrong actor + let (resp2, _, _) = svc + .encrypt_and_store(b"another secret", serde_json::json!({}), Some(owner), None) + .unwrap(); + let err = svc + .resolve_and_decrypt(&resp2.capability_id, Some(actor_bad), None) + .unwrap_err(); + // Should be Unauthorized (IDOR prevented) + match err { + capglyph_server::error::ServerError::Unauthorized(_) => {} + other => panic!("expected Unauthorized, got {:?}", other), + } + + // Bearer: no owner → any actor can resolve (capability is bearer) + let (resp3, _, _) = svc + .encrypt_and_store(b"bearer secret", serde_json::json!({}), None, None) + .unwrap(); + let pt3 = svc + .resolve_and_decrypt(&resp3.capability_id, Some(actor_bad), None) + .unwrap(); + assert_eq!(pt3, b"bearer secret"); + + // AEAD tag tamper: fetch object, tamper ciphertext, decrypt should fail + let obj = svc + .db + .get_message_object_by_capability_id(&cap) + .unwrap() + .unwrap(); + let mut tampered_ct = obj.ciphertext.clone(); + if !tampered_ct.is_empty() { + tampered_ct[0] ^= 1; + } + let mut nonce_arr2 = [0u8; 12]; + nonce_arr2.copy_from_slice(&obj.nonce); + let mut key_arr2 = [0u8; 32]; + key_arr2.copy_from_slice(obj.content_key.as_ref().unwrap()); + let tamper_res = Service::aead_decrypt(&tampered_ct, &obj.tag, &key_arr2, &nonce_arr2); + assert!( + tamper_res.is_err(), + "tampered ciphertext should fail AEAD verify" + ); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn end_to_end_offline_1024() { + use capglyph_server::{db::Db, service::Service}; + let db = Db::new_in_memory().unwrap(); + let svc = Service::new_with_random_kms(db); + let owner = Uuid::new_v4(); + let plaintext = b"offline secret: direct decrypt without capability lookup"; + + // Store offline: get object_id + content_key + let (object_id, content_key, _nonce, _tag) = svc + .store_offline(plaintext, serde_json::json!({}), Some(owner), None) + .unwrap(); + let mut key_arr = [0u8; 32]; + key_arr.copy_from_slice(&content_key); + + // Embed offline payload (object_id+key) into 1024 image + let (w, h) = (1024, 1024); + let mut img = make_test_image(w, h); + let geo = make_test_geometry(w, h); + let keys = test_keys(); + let profile = select_profile(w, h, &crate::cli::EmbedMode::Dwt); + embed_offline( + &mut img, + &geo, + &object_id, + &key_arr, + &keys, + &crate::cli::PlacementStrategy::Skeleton, + profile, + crate::cli::EmbedMode::Dwt, + ) + .unwrap(); + + // Extract offline payload + let (oid2, key2) = + extract_offline(&img, &keys, profile, crate::cli::EmbedMode::Dwt).unwrap(); + assert_eq!(object_id, oid2); + assert_eq!(key_arr, key2); + + // Resolve offline with correct actor + let pt = svc.resolve_offline(&oid2, &key2, Some(owner)).unwrap(); + assert_eq!(pt, plaintext); + + // IDOR for offline: wrong actor fails + let bad_actor = Uuid::new_v4(); + let err = svc + .resolve_offline(&oid2, &key2, Some(bad_actor)) + .unwrap_err(); + match err { + capglyph_server::error::ServerError::Unauthorized(_) => {} + other => panic!("expected Unauthorized for offline IDOR, got {:?}", other), + } + } +} From 2fe7b16832982fdde5e0bcd866c0decefc34e75d Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Mon, 31 Aug 2026 23:38:30 +0800 Subject: [PATCH 2/2] fix(fmt): rustfmt allow attribute multilines for CI --- src/pointer.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pointer.rs b/src/pointer.rs index 8760a88..4077c87 100644 --- a/src/pointer.rs +++ b/src/pointer.rs @@ -10,7 +10,11 @@ //! HPKE is deferred (single-primitive agility per `cryptographic-security.md` §5). #![allow(deprecated)] -#![allow(clippy::too_many_arguments, clippy::type_complexity, clippy::needless_range_loop)] +#![allow( + clippy::too_many_arguments, + clippy::type_complexity, + clippy::needless_range_loop +)] use anyhow::{Context, Result}; use base64::Engine as _;