diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e406ec..07d1faa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,13 @@ jobs: path: | Modules/CosignCore/Frameworks/CosignCore.xcframework Modules/CosignCore/Sources/Generated - key: xcframework-${{ runner.os }}-${{ hashFiles('core/src/**', 'core/Cargo.toml', 'core/Cargo.lock', 'core/build.rs', 'core/uniffi.toml', 'scripts/build-rust.sh', 'scripts/build-xcframework.sh') }} + # Key on the Rust LIBRARY only: the top-level core/src/*.rs sources + the .udl, + # NOT core/src/bin/** (the relay binary). The XCFramework is built from the + # cosign_core library and does not depend on the relay binary, so relay-only + # edits must not invalidate this cache (they used to, via a core/src/** glob, + # forcing an ~18 min rebuild). If a nested library module dir is ever added + # under core/src, add it here. + key: xcframework-${{ runner.os }}-${{ hashFiles('core/src/*.rs', 'core/src/cosign_core.udl', 'core/Cargo.toml', 'core/Cargo.lock', 'core/build.rs', 'core/uniffi.toml', 'scripts/build-rust.sh', 'scripts/build-xcframework.sh') }} - name: Build XCFramework if: steps.xcframework.outputs.cache-hit != 'true' diff --git a/core/src/bin/relay-server.rs b/core/src/bin/relay-server.rs index c92ee34..7169d19 100644 --- a/core/src/bin/relay-server.rs +++ b/core/src/bin/relay-server.rs @@ -6,6 +6,7 @@ use std::{ io::{self, Read, Write}, net::{IpAddr, SocketAddr, TcpListener, TcpStream}, path::PathBuf, + sync::{Mutex, OnceLock}, time::{Duration, Instant}, }; @@ -17,6 +18,7 @@ use cosign_core::{ types::{self, ConfigActionInfo, DecodedInstruction, ProposalCompanionRef, ProposalDetail}, }; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use solana_client::client_error::reqwest; use solana_sdk::{ address_lookup_table::instruction::ProgramInstruction as AddressLookupTableInstruction, @@ -83,6 +85,8 @@ const DEFAULT_RPC_ALLOWED_METHODS: &[&str] = &[ const LANDING_HTML: &str = include_str!("../../static/index.html"); #[cfg(feature = "landing")] const PRIVACY_HTML: &str = include_str!("../../static/privacy.html"); +#[cfg(feature = "landing")] +const FAVICON_SVG: &str = include_str!("../../static/favicon.svg"); // Product screenshots used by the landing page, served from /assets/*.png and // baked into the binary so the page stays self-contained. @@ -704,6 +708,16 @@ fn handle_request_with_client( return html_response(200, PRIVACY_HTML.to_string()); } + #[cfg(feature = "landing")] + if request.method == "GET" && request.path == "/favicon.svg" { + return HttpResponse { + status: 200, + reason: reason_phrase(200), + content_type: "image/svg+xml; charset=utf-8", + body: FAVICON_SVG.as_bytes().to_vec(), + }; + } + #[cfg(feature = "landing")] if request.method == "GET" && let Some((_, bytes)) = LANDING_ASSETS @@ -726,6 +740,10 @@ fn handle_request_with_client( return prices_response(request.query.as_deref()); } + if request.method == "GET" && request.path == "/cosign/v1/release" { + return release_response(); + } + if request.method != "GET" { return error_response(405, ResponseFormat::Html, "method not allowed"); } @@ -4695,6 +4713,112 @@ fn jupiter_usd_price(body: &Value, mint: &str) -> Option { .or_else(|| price.as_str().and_then(|value| value.parse().ok())) } +// TTL-cached latest-release data. Populated on first request and refreshed after +// the TTL expires. Avoids hitting the unauthenticated GitHub API on every page load +// (rate limit: 60 req/hr/IP). The cache is process-global and initialised lazily. +static RELEASE_CACHE: OnceLock>> = OnceLock::new(); +const RELEASE_CACHE_TTL: Duration = Duration::from_secs(7 * 60); + +fn release_response() -> HttpResponse { + let cache = RELEASE_CACHE.get_or_init(|| Mutex::new(None)); + { + let guard = cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((ref cached, fetched_at)) = *guard + && fetched_at.elapsed() < RELEASE_CACHE_TTL + { + return json_response(200, cached.clone()); + } + } + match fetch_release_data() { + Ok(release_json) => { + let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some((release_json.clone(), Instant::now())); + json_response(200, release_json) + } + Err(_) => json_response(502, json!({"kind": "release", "error": "unavailable"})), + } +} + +/// Fetches the latest tagged GitHub release for hackshare/cosign, downloads the +/// BuildClaim.json release asset, and returns a JSON blob with version/tag/commit/ +/// fingerprint fields. The fingerprint is sha256(raw BuildClaim.json bytes), which +/// the app computes locally from the same asset to verify the build. +fn fetch_release_data() -> Result { + let client = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + + let response = client + .get("https://api.github.com/repos/hackshare/cosign/releases/latest") + .header("User-Agent", "cosign-relay") + .header("Accept", "application/vnd.github+json") + .send() + .map_err(|e| e.to_string())?; + + if !response.status().is_success() { + return Err(format!( + "GitHub API returned {}", + response.status().as_u16() + )); + } + + let release: Value = response.json().map_err(|e| e.to_string())?; + let html_url = release["html_url"] + .as_str() + .ok_or("missing html_url")? + .to_owned(); + + let assets = release["assets"].as_array().ok_or("missing assets")?; + let asset = assets + .iter() + .find(|a| a["name"].as_str() == Some("BuildClaim.json")) + .ok_or("BuildClaim.json asset not found")?; + let download_url = asset["browser_download_url"] + .as_str() + .ok_or("missing browser_download_url")? + .to_owned(); + + let bytes_response = client + .get(&download_url) + .header("User-Agent", "cosign-relay") + .send() + .map_err(|e| e.to_string())?; + let bytes = bytes_response.bytes().map_err(|e| e.to_string())?; + + // Fingerprint over the exact published bytes — the app verifier hashes the + // same asset so both sides produce identical hex without re-serialising. + let fingerprint: String = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + + let claim: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + let version = claim["version"] + .as_str() + .ok_or("missing version")? + .to_owned(); + let tag = claim["tag"].as_str().ok_or("missing tag")?.to_owned(); + let commit = claim["commitSha"] + .as_str() + .ok_or("missing commitSha")? + .to_owned(); + let build = claim["build"].as_str().ok_or("missing build")?.to_owned(); + let commit_short = commit[..8.min(commit.len())].to_owned(); + + Ok(json!({ + "kind": "release", + "version": version, + "tag": tag, + "build": build, + "commit": commit, + "commitShort": commit_short, + "fingerprint": fingerprint, + "releaseUrl": html_url, + })) +} + fn member_squads_json_response(member: &Pubkey, squads: &[types::MultisigSummary]) -> HttpResponse { json_response( 200, diff --git a/core/static/favicon.svg b/core/static/favicon.svg new file mode 100644 index 0000000..569a577 --- /dev/null +++ b/core/static/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/core/static/index.html b/core/static/index.html index 328f914..b53ab1f 100644 --- a/core/static/index.html +++ b/core/static/index.html @@ -4,6 +4,7 @@ Cosign + @@ -64,7 +65,14 @@ -
+
+ +
@@ -199,7 +207,7 @@

-
+
Cosign decoded proposal detail @@ -211,30 +219,32 @@

-
+
01Decoded and inspectable

Read the action, not the bytes.

Every instruction is decoded into plain language with a severity and a confidence read on the data. Open the raw fields whenever you want them. Nothing is hidden behind a blind signature.

-
+
02Co-sign to the threshold

Add your signature, watch the threshold close.

See who has approved and how many signatures remain. The threshold ring fills as co-signers sign, and Face ID confirms before anything is broadcast. Hold to approve keeps an accidental tap from spending anything.

-
+
03Self-custody

Your keys stay on the device.

Keys are generated and held on your iPhone, in the iOS Keychain. Import an existing wallet from its recovery phrase or a raw Solana secret key, all behind Face ID. Cosign cannot move your funds.

Hardware signer support is on the roadmap.

-
+
04Verifiable receipt

A receipt you can check on-chain.

After a proposal executes, Cosign shows the signature and the decoded result, with a link to inspect the transaction on-chain. What you approved and what landed are the same thing, and you can prove it.

+ +
@@ -289,21 +299,11 @@

-
Verified build
v0.1.0
-
Releasev0.1.0+1751312345
-
Commitaca62f4c
-
Fingerprint · SHA-256
aca62f4c9d3b71e04f2a8c61bd09e710f5a23c4e8b90d172a6f3c5
-
- - - - - - - - - -
+
Verified build
View release
v0.1.0
+
Releasev0.1.0+1751312345
+
Commitaca62f4c
+
Fingerprint · SHA-256
aca62f4c9d3b71e04f2a8c61bd09e710f5a23c4e8b90d172a6f3c5
+
@@ -376,9 +376,12 @@

bestR){ bestR = ratios[k]; bestI = k; } }); + if(bestI !== null && bestR > 0 && bestI !== current){ current = bestI; setActive(bestI); } + }, { threshold:[0,0.15,0.3,0.45,0.6,0.75,0.9,1] }); beats.forEach(function(b){ bio.observe(b); }); - setActive(0); + current = '0'; setActive(0); } document.querySelectorAll('[data-toggle-group]').forEach(function(wrap){ @@ -432,5 +438,22 @@

Cosign Privacy +