Phase 1 foundation: backend, web profile, and mobile app
CI / policy (push) Successful in 0s
CI / mobile (push) Successful in 47s
CI / profile (push) Successful in 49s
CI / backend (push) Failing after 49s

Greenfield implementation of CardClaws Phase 1 across three surfaces.

Backend (Rust/Axum workspace, 67 tests):
- cardclaws-types/config/db/auth/api/wallet crates
- Auth: register, login + lockout, magic link, refresh rotation, Apple verify
- Cards: CRUD, tier-limited publish, duplicate, public handle lookup
- Assets: R2 presigned uploads; vCard export
- Apple Wallet .pkpass pipeline (PKCS#7 signer behind apple-signing feature)
- Analytics ingest + summary with daily-salted IP hashing
- Migrations 0001 (incl. cardclaws_sessions) + 0002 analytics

Web profile (Astro SSR): cardclaws.com/[handle] hero + flip, contact actions,
client-built vCard, visit attribution. Verified end-to-end.

Mobile (Expo SDK 51): auth, card list/create, builder v1 (bg/text/logo,
palette, undo/redo), Skia/Reanimated viewer (flip + ambient). 22 logic tests.

CI: policy/backend/profile/mobile jobs; LOC + no-placeholder lint.

Review fixes baked in: `back` (not `cardclaws`) key; strip image is a bundled
manifest file (not a URL); sessions table added; NFC reframed; test doubles
allowed for external services.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 10:17:26 -05:00
co-authored by Claude Opus 4.8
commit c30d3afeec
128 changed files with 36279 additions and 0 deletions
@@ -0,0 +1,31 @@
[package]
name = "cardclaws-wallet"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[features]
# Real Apple PKCS#7 signing pulls in the (heavy) openssl dependency. Off by
# default so the crate and its tests build fast against the fake signer; the
# production binary enables it. The signing code is fully implemented either way.
default = []
apple-signing = ["dep:openssl"]
[dependencies]
cardclaws-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
zip = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
openssl = { version = "0.10", features = ["vendored"], optional = true }
[dev-dependencies]
zip = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
@@ -0,0 +1,50 @@
//! `manifest.json`: a map of every bundled filename to the SHA-1 hash of its
//! contents (PassKit spec). The strip image is included here — that is the whole
//! point of the plan's A1 correction.
use std::collections::BTreeMap;
use sha1::{Digest, Sha1};
/// Build the manifest JSON string from the file set. Keys are sorted (BTreeMap)
/// so the output is deterministic.
pub fn build_manifest(files: &BTreeMap<String, Vec<u8>>) -> String {
let entries: BTreeMap<&String, String> = files
.iter()
.map(|(name, bytes)| (name, sha1_hex(bytes)))
.collect();
// Hand-serialize to guarantee stable key ordering regardless of serde_json
// map feature flags.
let body = entries
.iter()
.map(|(name, hash)| format!("\"{name}\":\"{hash}\""))
.collect::<Vec<_>>()
.join(",");
format!("{{{body}}}")
}
fn sha1_hex(bytes: &[u8]) -> String {
let digest = Sha1::digest(bytes);
digest.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_hashes_match_file_contents() {
let mut files = BTreeMap::new();
files.insert("pass.json".to_string(), b"{}".to_vec());
files.insert("strip.png".to_string(), b"\x89PNG".to_vec());
let manifest = build_manifest(&files);
// Known SHA-1 of "{}" is bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f.
assert!(manifest.contains("\"pass.json\":\"bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f\""));
// Strip image MUST appear in the manifest (the A1 correction).
assert!(manifest.contains("\"strip.png\":\""));
let parsed: serde_json::Value = serde_json::from_str(&manifest).unwrap();
assert!(parsed.get("strip.png").is_some());
}
}
@@ -0,0 +1,64 @@
//! Apple `.pkpass` generation.
pub mod manifest;
pub mod packager;
pub mod pass_builder;
pub mod signer;
use std::collections::BTreeMap;
use crate::error::WalletError;
use crate::strip_renderer;
use signer::PassSigner;
/// Everything needed to build one pass. Identity (`pass_type_id`, `team_id`) and
/// brand assets come from config; the rest from the card.
pub struct PassInput {
pub serial_number: String,
pub pass_type_id: String,
pub team_id: String,
pub organization_name: String,
pub holder_name: String,
pub title: Option<String>,
pub company: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub website: Option<String>,
/// The profile URL encoded in the QR barcode (PRD §6.3.1).
pub profile_url: String,
/// Hex background color used to render the strip image.
pub background_hex: String,
}
/// Brand assets bundled into every pass (CardClaws icon + logo PNGs).
pub struct BrandAssets {
pub icon_png: Vec<u8>,
pub logo_png: Vec<u8>,
}
/// Build a complete, signed `.pkpass` zip in memory.
///
/// Pipeline (PRD §10.1, corrected): build pass.json → render strip → assemble
/// the file set → hash every file into manifest.json → sign the manifest →
/// zip it all.
pub fn build_pkpass(
input: &PassInput,
brand: &BrandAssets,
signer: &dyn PassSigner,
) -> Result<Vec<u8>, WalletError> {
let pass_json = serde_json::to_vec(&pass_builder::build_pass_json(input))
.map_err(|e| WalletError::Build(e.to_string()))?;
let strip_png = strip_renderer::render_strip(&input.background_hex)?;
// BTreeMap keeps file ordering deterministic (stable manifest + zip).
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
files.insert("pass.json".into(), pass_json);
files.insert("icon.png".into(), brand.icon_png.clone());
files.insert("logo.png".into(), brand.logo_png.clone());
files.insert("strip.png".into(), strip_png);
let manifest_json = manifest::build_manifest(&files);
let signature = signer.sign_manifest(manifest_json.as_bytes())?;
packager::package(&files, &manifest_json, &signature)
}
@@ -0,0 +1,71 @@
//! Zips the bundle into a `.pkpass`. Contents: every payload/brand file, plus
//! `manifest.json` and the detached `signature`.
use std::collections::BTreeMap;
use std::io::{Cursor, Write};
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
use crate::error::WalletError;
pub fn package(
files: &BTreeMap<String, Vec<u8>>,
manifest_json: &str,
signature: &[u8],
) -> Result<Vec<u8>, WalletError> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut write = |name: &str, bytes: &[u8]| -> Result<(), WalletError> {
zip.start_file(name, opts)
.map_err(|e| WalletError::Packaging(e.to_string()))?;
zip.write_all(bytes)
.map_err(|e| WalletError::Packaging(e.to_string()))?;
Ok(())
};
for (name, bytes) in files {
write(name, bytes)?;
}
write("manifest.json", manifest_json.as_bytes())?;
write("signature", signature)?;
let cursor = zip
.finish()
.map_err(|e| WalletError::Packaging(e.to_string()))?;
Ok(cursor.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[test]
fn produces_openable_zip_with_required_entries() {
let mut files = BTreeMap::new();
files.insert("pass.json".to_string(), b"{}".to_vec());
files.insert("strip.png".to_string(), b"\x89PNG".to_vec());
let zip_bytes = package(&files, "{\"pass.json\":\"abc\"}", b"sig").unwrap();
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
let mut names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
names.sort();
assert_eq!(
names,
vec!["manifest.json", "pass.json", "signature", "strip.png"]
);
let mut manifest = String::new();
archive
.by_name("manifest.json")
.unwrap()
.read_to_string(&mut manifest)
.unwrap();
assert!(manifest.contains("pass.json"));
}
}
@@ -0,0 +1,109 @@
//! Constructs `pass.json` per the PassKit spec (PRD §6.3.1). Type `generic`.
use serde_json::{json, Value};
use super::PassInput;
pub fn build_pass_json(input: &PassInput) -> Value {
let mut secondary = Vec::new();
if let Some(company) = &input.company {
secondary.push(json!({ "key": "company", "label": "COMPANY", "value": company }));
}
if let Some(email) = &input.email {
secondary.push(json!({ "key": "email", "label": "EMAIL", "value": email }));
}
let mut auxiliary = Vec::new();
if let Some(phone) = &input.phone {
auxiliary.push(json!({ "key": "phone", "label": "PHONE", "value": phone }));
}
if let Some(website) = &input.website {
auxiliary.push(json!({ "key": "website", "label": "WEBSITE", "value": website }));
}
let mut back = vec![json!({
"key": "profile",
"label": "PROFILE",
"value": input.profile_url,
})];
if let Some(email) = &input.email {
back.push(json!({ "key": "back_email", "label": "EMAIL", "value": email }));
}
json!({
"formatVersion": 1,
"passTypeIdentifier": input.pass_type_id,
"teamIdentifier": input.team_id,
"organizationName": input.organization_name,
"serialNumber": input.serial_number,
"description": format!("{}'s CardClaws card", input.holder_name),
"barcode": {
"format": "PKBarcodeFormatQR",
"message": input.profile_url,
"messageEncoding": "iso-8859-1",
},
"barcodes": [{
"format": "PKBarcodeFormatQR",
"message": input.profile_url,
"messageEncoding": "iso-8859-1",
}],
"generic": {
"headerFields": [
{ "key": "name", "value": input.holder_name }
],
"primaryFields": [
{ "key": "title", "value": input.title.clone().unwrap_or_default() }
],
"secondaryFields": secondary,
"auxiliaryFields": auxiliary,
"backFields": back,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn input() -> PassInput {
PassInput {
serial_number: "serial-123".into(),
pass_type_id: "pass.com.cardclaws.card".into(),
team_id: "TEAM123".into(),
organization_name: "CardClaws".into(),
holder_name: "Omar Sobh".into(),
title: Some("Founder".into()),
company: Some("RedClaw".into()),
email: Some("[email protected]".into()),
phone: Some("+15551234567".into()),
website: Some("https://redclaw.dev".into()),
profile_url: "https://cardclaws.com/omar".into(),
background_hex: "#101014".into(),
}
}
#[test]
fn pass_json_has_required_passkit_fields() {
let pass = build_pass_json(&input());
for key in [
"formatVersion",
"passTypeIdentifier",
"teamIdentifier",
"organizationName",
"serialNumber",
"description",
"barcode",
"generic",
] {
assert!(pass.get(key).is_some(), "missing required field {key}");
}
assert_eq!(pass["formatVersion"], 1);
}
#[test]
fn barcode_message_is_profile_url() {
let pass = build_pass_json(&input());
assert_eq!(pass["barcode"]["message"], "https://cardclaws.com/omar");
assert_eq!(pass["barcode"]["format"], "PKBarcodeFormatQR");
}
}
@@ -0,0 +1,101 @@
//! Manifest signing. PassKit requires `signature` to be a PKCS#7 **detached**
//! DER signature over `manifest.json`, made with the Pass Type ID certificate,
//! its private key, and the Apple WWDR intermediate in the chain (PRD §20.3).
//!
//! The real signer uses OpenSSL and is gated behind the `apple-signing` feature
//! so default builds/tests stay fast. Tests use [`FakePassSigner`].
use crate::error::WalletError;
pub trait PassSigner: Send + Sync {
/// Produce the detached PKCS#7 DER signature over `manifest`.
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError>;
}
/// Deterministic non-cryptographic signer for tests. Produces a stable,
/// non-empty byte string so bundle assembly can be verified without certs.
pub struct FakePassSigner;
impl PassSigner for FakePassSigner {
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError> {
let mut sig = b"FAKE-PKCS7-SIGNATURE:".to_vec();
sig.extend_from_slice(&(manifest.len() as u32).to_be_bytes());
Ok(sig)
}
}
#[cfg(feature = "apple-signing")]
mod openssl_signer {
use openssl::pkcs7::{Pkcs7, Pkcs7Flags};
use openssl::pkey::{PKey, Private};
use openssl::stack::Stack;
use openssl::x509::X509;
use super::PassSigner;
use crate::error::WalletError;
/// Production signer holding the Pass Type ID cert + key and the WWDR
/// intermediate, loaded once at startup and kept in memory (never on disk).
pub struct OpenSslSigner {
cert: X509,
pkey: PKey<Private>,
wwdr: X509,
}
impl OpenSslSigner {
/// Load from a PKCS#12 (P12) blob (cert + key) and the WWDR intermediate
/// in PEM. `p12_password` unlocks the P12.
pub fn from_p12(
p12_der: &[u8],
p12_password: &str,
wwdr_pem: &[u8],
) -> Result<Self, WalletError> {
let p12 = openssl::pkcs12::Pkcs12::from_der(p12_der)
.map_err(|e| WalletError::Signing(e.to_string()))?;
let parsed = p12
.parse2(p12_password)
.map_err(|e| WalletError::Signing(e.to_string()))?;
let cert = parsed
.cert
.ok_or_else(|| WalletError::Signing("P12 missing certificate".into()))?;
let pkey = parsed
.pkey
.ok_or_else(|| WalletError::Signing("P12 missing private key".into()))?;
let wwdr = X509::from_pem(wwdr_pem).map_err(|e| WalletError::Signing(e.to_string()))?;
Ok(Self { cert, pkey, wwdr })
}
}
impl PassSigner for OpenSslSigner {
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError> {
let mut certs = Stack::new().map_err(|e| WalletError::Signing(e.to_string()))?;
certs
.push(self.wwdr.clone())
.map_err(|e| WalletError::Signing(e.to_string()))?;
// Detached + binary: the signature does not embed the manifest.
let flags = Pkcs7Flags::DETACHED | Pkcs7Flags::BINARY;
let pkcs7 = Pkcs7::sign(&self.cert, &self.pkey, &certs, manifest, flags)
.map_err(|e| WalletError::Signing(e.to_string()))?;
pkcs7
.to_der()
.map_err(|e| WalletError::Signing(e.to_string()))
}
}
}
#[cfg(feature = "apple-signing")]
pub use openssl_signer::OpenSslSigner;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fake_signer_is_deterministic_and_nonempty() {
let s = FakePassSigner;
let a = s.sign_manifest(b"manifest").unwrap();
let b = s.sign_manifest(b"manifest").unwrap();
assert_eq!(a, b);
assert!(!a.is_empty());
}
}
@@ -0,0 +1,15 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum WalletError {
#[error("pass build error: {0}")]
Build(String),
#[error("strip render error: {0}")]
Render(String),
#[error("manifest error: {0}")]
Manifest(String),
#[error("signing error: {0}")]
Signing(String),
#[error("packaging error: {0}")]
Packaging(String),
}
@@ -0,0 +1,14 @@
//! Wallet pass generation (PRD §10). Phase 1 implements the Apple `.pkpass`
//! pipeline; Google Wallet (JWT) lands in Phase 2.
//!
//! KEY CORRECTION vs. the PRD draft (plan review A1): the strip image is a
//! **bundled file** inside the `.pkpass` zip and is included in the manifest
//! SHA-1 hashes — it is NOT an `stripImage` URL. The pipeline here renders the
//! strip, writes it into the bundle, hashes every file in the manifest, signs
//! the manifest (PKCS#7 detached), and zips everything.
pub mod apple;
pub mod error;
pub mod strip_renderer;
pub use error::WalletError;
@@ -0,0 +1,69 @@
//! Renders the pass strip image and other bundled PNGs.
//!
//! Phase 1 renders a solid fill derived from the card's background color, at the
//! PassKit strip dimensions (1125x432 @3x, PRD §6.3.4). A later milestone will
//! composite the actual card-face top-third; the bundle contract (a real PNG
//! hashed into the manifest) is identical either way.
use std::io::Cursor;
use image::{ImageFormat, Rgba, RgbaImage};
use crate::error::WalletError;
/// PassKit strip image size at @3x (PRD §6.3.4).
pub const STRIP_WIDTH: u32 = 1125;
pub const STRIP_HEIGHT: u32 = 432;
/// Render the strip image as a solid fill of `background_hex`.
pub fn render_strip(background_hex: &str) -> Result<Vec<u8>, WalletError> {
render_solid(STRIP_WIDTH, STRIP_HEIGHT, background_hex)
}
/// Render a solid-color PNG. Used for the strip and for placeholder brand glyphs
/// until real assets are supplied.
pub fn render_solid(width: u32, height: u32, hex: &str) -> Result<Vec<u8>, WalletError> {
let [r, g, b] = parse_hex(hex)?;
let img = RgbaImage::from_pixel(width, height, Rgba([r, g, b, 255]));
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, ImageFormat::Png)
.map_err(|e| WalletError::Render(e.to_string()))?;
Ok(buf.into_inner())
}
/// Parse `#RRGGBB` (or `RRGGBB`) into RGB bytes.
fn parse_hex(hex: &str) -> Result<[u8; 3], WalletError> {
let h = hex.trim_start_matches('#');
if h.len() != 6 {
return Err(WalletError::Render(format!("invalid hex color: {hex}")));
}
let parse = |s: &str| u8::from_str_radix(s, 16);
match (parse(&h[0..2]), parse(&h[2..4]), parse(&h[4..6])) {
(Ok(r), Ok(g), Ok(b)) => Ok([r, g, b]),
_ => Err(WalletError::Render(format!("invalid hex color: {hex}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_is_valid_png_of_expected_size() {
let bytes = render_strip("#101014").unwrap();
// PNG magic number.
assert_eq!(
&bytes[0..8],
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
);
let decoded = image::load_from_memory(&bytes).unwrap();
assert_eq!(decoded.width(), STRIP_WIDTH);
assert_eq!(decoded.height(), STRIP_HEIGHT);
}
#[test]
fn rejects_bad_hex() {
assert!(render_solid(10, 10, "nope").is_err());
assert!(render_solid(10, 10, "#12345").is_err());
}
}
@@ -0,0 +1,145 @@
//! End-to-end Apple pass tests (PRD §15.2). Uses the fake signer so no cert is
//! needed; the bundle structure, manifest hashes, strip image, zip, and QR URL
//! are all exercised against the real pipeline.
use std::io::{Cursor, Read};
use cardclaws_wallet::apple::signer::FakePassSigner;
use cardclaws_wallet::apple::{build_pkpass, BrandAssets, PassInput};
use cardclaws_wallet::strip_renderer;
fn input() -> PassInput {
PassInput {
serial_number: "card-abc-123".into(),
pass_type_id: "pass.com.cardclaws.card".into(),
team_id: "TEAM123".into(),
organization_name: "CardClaws".into(),
holder_name: "Omar Sobh".into(),
title: Some("Founder & CEO".into()),
company: Some("RedClaw Systems".into()),
email: Some("[email protected]".into()),
phone: Some("+15551234567".into()),
website: Some("https://redclaw.dev".into()),
profile_url: "https://cardclaws.com/omar".into(),
background_hex: "#101014".into(),
}
}
fn brand() -> BrandAssets {
BrandAssets {
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
}
}
fn build() -> zip::ZipArchive<Cursor<Vec<u8>>> {
let bytes = build_pkpass(&input(), &brand(), &FakePassSigner).unwrap();
zip::ZipArchive::new(Cursor::new(bytes)).unwrap()
}
fn read_entry(archive: &mut zip::ZipArchive<Cursor<Vec<u8>>>, name: &str) -> Vec<u8> {
let mut buf = Vec::new();
archive
.by_name(name)
.unwrap()
.read_to_end(&mut buf)
.unwrap();
buf
}
#[test]
fn test_packager_produces_valid_zip() {
let mut archive = build();
let mut names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
names.sort();
assert_eq!(
names,
vec![
"icon.png",
"logo.png",
"manifest.json",
"pass.json",
"signature",
"strip.png",
]
);
}
#[test]
fn test_pass_json_structure_valid() {
let mut archive = build();
let pass: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
for key in [
"formatVersion",
"passTypeIdentifier",
"teamIdentifier",
"serialNumber",
"generic",
"barcode",
] {
assert!(pass.get(key).is_some(), "pass.json missing {key}");
}
assert_eq!(pass["generic"]["headerFields"][0]["value"], "Omar Sobh");
assert_eq!(
pass["generic"]["primaryFields"][0]["value"],
"Founder & CEO"
);
}
#[test]
fn test_manifest_sha1_correct() {
let mut archive = build();
let manifest: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "manifest.json")).unwrap();
// Every bundled file (including strip.png) must have a manifest entry whose
// hash matches the actual bytes in the zip.
for name in ["pass.json", "icon.png", "logo.png", "strip.png"] {
let bytes = read_entry(&mut archive, name);
let expected = sha1_hex(&bytes);
assert_eq!(
manifest[name].as_str().unwrap(),
expected,
"manifest hash mismatch for {name}"
);
}
}
#[test]
fn test_strip_image_is_bundled_not_a_url() {
// The plan's A1 correction: strip image is a real bundled PNG referenced in
// the manifest, and pass.json carries NO stripImage URL field.
let mut archive = build();
let strip = read_entry(&mut archive, "strip.png");
assert_eq!(&strip[0..4], &[0x89, b'P', b'N', b'G']);
let pass: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
assert!(pass.get("stripImage").is_none());
}
#[test]
fn test_signature_present_and_nonempty() {
let mut archive = build();
let sig = read_entry(&mut archive, "signature");
assert!(!sig.is_empty());
}
#[test]
fn test_pass_qr_url_matches_profile() {
let mut archive = build();
let pass: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
assert_eq!(pass["barcode"]["message"], "https://cardclaws.com/omar");
}
fn sha1_hex(bytes: &[u8]) -> String {
use sha1::{Digest, Sha1};
Sha1::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}