Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
433 changes: 433 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ keywords = ["watermark", "forensics", "image", "security", "tracing"]
categories = ["command-line-utilities", "multimedia::images"]
readme = "README.md"

[workspace]
members = ["crates/capglyph-server"]
resolver = "2"

[lib]
name = "capglyph"
path = "src/lib.rs"
Expand Down
44 changes: 44 additions & 0 deletions crates/capglyph-server/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[package]
name = "capglyph-server"
version = "0.1.0"
edition = "2021"
description = "CapGlyph credential server (sigild) — DB + atomic consume + revocation/audit"
license = "Apache-2.0"

[dependencies]
capglyph-core = { path = "../../../capglyph-core" }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
hmac = "0.13"
rusqlite = { version = "0.32", features = ["bundled"] }
uuid = { version = "1.8", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
tracing = "0.1"
tracing-subscriber = "0.3"
hex = "0.4"
base64 = "0.22"
ciborium = "0.2"
serde_bytes = "0.11"
thiserror = "2"
tempfile = "3.27.0"

axum = { version = "0.7" }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5" }
tower-http = { version = "0.6", features = ["trace"] }

[[bin]]
name = "capglyphd"
path = "src/bin/capglyphd.rs"

[features]
default = []

[dev-dependencies]
tempfile = "3.27"
tokio = { version = "1", features = ["full"] }
axum = "0.7"
tower = "0.5"
56 changes: 56 additions & 0 deletions crates/capglyph-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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;
```

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).

- **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 | 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 |

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).

## Running

```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":"<uuid>","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":"<base64url>"}'
# 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":"<base64url>","idempotency_key":"idem-1"}'
```

## Tests

- `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).

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).
64 changes: 64 additions & 0 deletions crates/capglyph-server/migrations/001_initial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- 001_initial.sql — sigild MVP schema per docs/research/media-credential/usage/credential-design.md §4
-- Covers / credentials / credential_consumptions / audit_events
-- Postgres normative; SQLite-compatible (TEXT for UUID, BLOB for BYTEA, TEXT for JSONB/TIMESTAMPTZ)

PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;

CREATE TABLE IF NOT EXISTS covers (
id TEXT PRIMARY KEY,
sha256 BLOB NOT NULL UNIQUE,
object_uri TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format TEXT NOT NULL,
family_id TEXT,
issuance_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);

CREATE TABLE IF NOT EXISTS credentials (
id TEXT PRIMARY KEY,
token_hash BLOB NOT NULL UNIQUE,
cover_id TEXT NOT NULL REFERENCES covers(id),
subject_id TEXT,
scope TEXT NOT NULL,
mode TEXT NOT NULL,
schema_version INTEGER NOT NULL,
key_id TEXT NOT NULL,
embed_params TEXT NOT NULL,
output_sha256 BLOB NOT NULL,
not_before TEXT,
expires_at TEXT,
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_credentials_token_hash ON credentials(token_hash);
CREATE INDEX IF NOT EXISTS idx_credentials_cover_id ON credentials(cover_id);

CREATE TABLE IF NOT EXISTS credential_consumptions (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL REFERENCES credentials(id),
idempotency_key TEXT NOT NULL,
actor_id TEXT,
consumed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
request_hash BLOB,
outcome TEXT NOT NULL,
UNIQUE (credential_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS idx_consumptions_credential ON credential_consumptions(credential_id);

CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
object_id TEXT,
actor_id TEXT,
event_data TEXT,
occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
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);
99 changes: 99 additions & 0 deletions crates/capglyph-server/src/bin/capglyphd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! capglyphd — credential server binary (sigild)
//!
//! MVP: SQLite-backed issuing / verify / consume / revoke over HTTP.
//! Keep `image bytes never a cryptographic key` — keys derived via KMS.

#[cfg(not(target_arch = "wasm32"))]
use std::net::SocketAddr;
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;

#[cfg(not(target_arch = "wasm32"))]
use capglyph_server::{Db, Kms, Service};

#[cfg(target_arch = "wasm32")]
fn main() {
eprintln!("capglyphd does not run on wasm32");
}

#[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`
let args: Vec<String> = std::env::args().collect();
let mut db_path: Option<PathBuf> = None;
let mut listen: String = "127.0.0.1:3000".to_string();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--db" => {
i += 1;
if i < args.len() {
db_path = Some(PathBuf::from(&args[i]));
}
}
"--listen" => {
i += 1;
if i < args.len() {
listen = args[i].clone();
}
}
"--help" | "-h" => {
println!("capglyphd — CapGlyph credential server (sigild) 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)");
println!("Env: CAPGLYPHD_MASTER_KEY (hex 32 bytes) or random if unset");
return Ok(());
}
_ => {}
}
i += 1;
}

tracing_subscriber::fmt::init();

let db = if let Some(p) = db_path {
eprintln!("capglyphd: opening db at {:?}", p);
Db::new(p)?
} else {
eprintln!("capglyphd: using in-memory db (ephemeral)");
Db::new_in_memory()?
};

// KMS: load master from env or generate
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");
}
} 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)"
);
}

let svc = Service::new(db, kms);

// Ensure demo cover exists so `POST /v1/credentials` works without pre-seeding
let _ = svc.ensure_demo_cover();

let app = capglyph_server::router(svc);
let addr: SocketAddr = listen.parse()?;
eprintln!("capglyphd: listening on http://{}", addr);
eprintln!(" POST /v1/credentials — issue");
eprintln!(" POST /v1/credentials/verify — verify (read-only)");
eprintln!(" POST /v1/credentials/consume — consume (atomic, Idempotency-Key)");
eprintln!(" GET /v1/credentials/:id — status");
eprintln!(" POST /v1/credentials/:id/revoke — revoke");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
115 changes: 115 additions & 0 deletions crates/capglyph-server/src/carrier_integration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! Carrier integration helpers — demonstrates `capglyph_core::framing` + `ecc` usage
//! as required by CTX-0023 acceptance.
//!
//! The opaque token flow is:
//! token_id (16 bytes, CSPRNG, base64url outside carrier)
//! → CBOR frame (framing::seal with K_mac)
//! → ECC encode (ecc::encode, Repetition8 baseline, RS+interleave for larger)
//! → Carrier lattice (DCT/DWT/… via Carrier::embed) — not exercised in DB tests
//! → Image (W = O + Δ(payload))
//! Extraction is the inverse plus `registration::align` for original-assisted.

use capglyph_core::ecc::{self, Profile};
use capglyph_core::framing::{self, Params, PayloadType};

/// Encode a credential token_id into a carrier-ready byte vector.
///
/// Steps: `token_id → framing::seal (CBOR + HMAC) → ecc::encode`
pub fn encode_credential_token(token_id: &[u8; 16], k_mac: &[u8; 32]) -> Vec<u8> {
let params = Params {
version: 1,
payload_type: PayloadType::Credential,
flags: 0,
};
let sealed = framing::seal(token_id, &params, k_mac);
ecc::encode(&sealed, Profile::Repetition8)
}

/// Decode a carrier byte vector back to token_id, verifying HMAC.
///
/// Steps: `ecc::decode (soft_bits) → framing::open → token_id`
pub fn decode_credential_token(coded: &[u8], k_mac: &[u8; 32]) -> anyhow::Result<[u8; 16]> {
// For MVP we use hard-bit path; real server uses soft_bits via SignalMetrics.
let bits: Vec<bool> = 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)
}

/// Soft-bit decode path (demonstrates `magnitude → LLR` integration).
/// Carrier would produce `SoftBit` via `SignalMetrics::soft_bits()` or
/// `ecc::soft_bits_from_coeffs`. Here we just wrap hard bits with LLR.
pub fn decode_credential_token_soft(
soft: &[ecc::SoftBit],
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)
}

#[cfg(test)]
mod tests {
use super::*;

fn test_k_mac() -> [u8; 32] {
[0x42u8; 32]
}

#[test]
fn roundtrip_hard() {
let token = [0x11u8; 16];
let coded = encode_credential_token(&token, &test_k_mac());
let decoded = decode_credential_token(&coded, &test_k_mac()).unwrap();
assert_eq!(decoded, token);
}

#[test]
fn wrong_key_fails() {
let token = [0x22u8; 16];
let coded = encode_credential_token(&token, &test_k_mac());
let wrong = [0x00u8; 32];
assert!(decode_credential_token(&coded, &wrong).is_err());
}

#[test]
fn tamper_detected() {
let token = [0x33u8; 16];
let mut coded = encode_credential_token(&token, &test_k_mac());
// Flip a byte in the middle (repetition-8 tolerates 1 flip per group, so flip many)
for b in coded.iter_mut().take(20) {
*b ^= 1;
}
// Even with tamper, ecc may correct single flips, but heavy tamper should break framing
// We don't assert strict failure because Repetition8 corrects 1/8; just ensure decode either succeeds with correct token or fails
if let Ok(decoded) = decode_credential_token(&coded, &test_k_mac()) {
// If it still decodes, it must be the original token (correction worked)
assert_eq!(decoded, token);
}
}

#[test]
fn soft_roundtrip() {
let token = [0xAAu8; 16];
let coded = encode_credential_token(&token, &test_k_mac());
let soft: Vec<ecc::SoftBit> = coded
.iter()
.map(|&b| {
let coeff = if b != 0 { 10.0 } else { -10.0 };
ecc::SoftBit::new(b != 0, coeff)
})
.collect();
let decoded = decode_credential_token_soft(&soft, &test_k_mac()).unwrap();
assert_eq!(decoded, token);
}
}
Loading