Merge pull request #13 from tlsnotary/twitter-rs

Example plugin in Rust: Twitter profile
This commit is contained in:
Tanner
2024-11-01 13:46:39 -07:00
committed by GitHub
9 changed files with 300 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
[build]
target = "wasm32-unknown-unknown"

View File

@@ -0,0 +1,2 @@
/target
Cargo.lock

View File

@@ -0,0 +1,17 @@
[package]
name = "twitter_profile_rs"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
anyhow = "1.0.89"
base64 = "0.22.1"
## The version needs to be locked to 1.2.0 until Extism in the browser extension is updated
extism-pdk = "=1.2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
spansy = { git = "https://github.com/tlsnotary/tlsn-utils", rev = "45370cc" }
url = "2.5.2"

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,11 @@
use extism_pdk::*;
#[host_fn]
extern "ExtismHost" {
pub fn redirect(url: &str);
}
#[host_fn]
extern "ExtismHost" {
pub fn notarize(params: &str) -> String;
}

View File

@@ -0,0 +1,179 @@
use std::{collections::HashMap, vec};
use anyhow::Context;
use base64::{engine::general_purpose, Engine as _};
use config::get;
use extism_pdk::*;
mod types;
use spansy::{json::parse_str, Spanned};
use types::{PluginConfig, RequestConfig, RequestObject, StepConfig};
mod host_functions;
use host_functions::{notarize, redirect};
mod utils;
use url::Url;
use utils::{get_cookies_by_host, get_headers_by_host};
const SETTINGS_REQUEST: RequestObject = RequestObject {
url: "https://api.x.com/1.1/account/settings.json",
method: "GET",
};
#[plugin_fn]
pub fn config() -> FnResult<Json<PluginConfig<'static>>> {
let icon: String = format!(
"data:image/png;base64,{}",
general_purpose::STANDARD.encode(include_bytes!("../assets/icon.png"))
);
let config = PluginConfig {
title: "Twitter Profile (Rust)",
description: "Notarize ownership of a Twitter profile",
steps: vec![
StepConfig {
title: "Visit Twitter website",
description: None,
cta: "Go to x.com",
action: "start",
prover: false,
},
StepConfig {
title: "Collect credentials",
cta: "Check cookies",
action: "two",
prover: false,
description: Some("Login to your account if you haven't already"),
},
StepConfig {
title: "Notarize twitter profile",
cta: "Notarize",
action: "three",
prover: true,
description: None,
},
],
host_functions: vec!["redirect", "notarize"],
cookies: vec!["api.x.com"],
headers: vec!["api.x.com"],
requests: vec![SETTINGS_REQUEST],
notary_urls: None,
proxy_urls: None,
icon,
};
Ok(Json(config))
}
/// Implementation of the first (start) plugin step
#[plugin_fn]
pub fn start() -> FnResult<Json<bool>> {
let x_url = Url::parse("https://x.com")?;
let tab_url = get("tabUrl")?.context("Error getting tab url")?;
let tab_url = Url::parse(&tab_url)?;
if tab_url.host_str() != x_url.host_str() {
unsafe {
let _ = redirect(x_url.as_str());
};
return Ok(Json(false));
}
Ok(Json(true))
}
/// Implementation of step "two".
/// This step collects and validates authentication cookies and headers for 'api.x.com'.
/// If all required information, it creates the request object.
/// Note that the url needs to be specified in the `config` too, otherwise the request will be refused.
#[plugin_fn]
pub fn two() -> FnResult<Json<RequestConfig>> {
let cookies = get_cookies_by_host("api.x.com")?;
let headers = get_headers_by_host("api.x.com")?;
log!(LogLevel::Info, "cookies: {cookies:?}");
log!(LogLevel::Info, "headers: {headers:?}");
let auth_token = cookies
.get("auth_token")
.ok_or_else(|| Error::msg("auth_token cookie not found"))?;
let ct0 = cookies
.get("ct0")
.ok_or_else(|| Error::msg("ct0 cookie not found"))?;
let authorization = headers
.get("authorization")
.ok_or_else(|| Error::msg("authorization header not found"))?;
let x_csrf_token = headers
.get("x-csrf-token")
.ok_or_else(|| Error::msg("x-csrf-token header not found"))?;
let cookie = format!("lang=en; auth_token={auth_token}; ct0={ct0}");
let headers: HashMap<String, String> = [
(
String::from("x-twitter-client-language"),
String::from("en"),
),
(String::from("x-csrf-token"), x_csrf_token.clone()),
(String::from("host"), String::from("api.x.com")),
(String::from("authorization"), authorization.clone()),
(String::from("Cookie"), cookie.clone()),
(String::from("Accept-Encoding"), String::from("identity")),
(String::from("Connection"), String::from("close")),
]
.into_iter()
.collect();
let secret_headers = vec![x_csrf_token.clone(), cookie, authorization.clone()];
let request = RequestConfig {
url: SETTINGS_REQUEST.url.to_string(),
method: SETTINGS_REQUEST.method.to_string(),
headers,
secret_headers,
get_secret_response: Some(String::from("redact")),
};
let request_json = serde_json::to_string(&request)?;
log!(LogLevel::Info, "request: {:?}", &request_json);
return Ok(Json(request));
}
/**
* Step 3: calls the `notarize` host function
*/
#[plugin_fn]
pub fn three() -> FnResult<Json<String>> {
let request_json: String = input()?;
log!(LogLevel::Info, "Input: {request_json:?}");
let id = unsafe {
let id = notarize(&request_json);
log!(LogLevel::Info, "Notarization result: {:?}", id);
id?
};
return Ok(Json(id));
}
/// This method is used to parse the Twitter response and specify what information is revealed (i.e. **not** redacted)
/// This method is optional in the notarization request. When it is not specified nothing is redacted.
///
/// In this example it locates the `screen_name` and excludes that range from the revealed response.
#[plugin_fn]
pub fn redact() -> FnResult<Json<Vec<String>>> {
let body_string: String = input()?;
let spansy = parse_str(&body_string)?;
log!(LogLevel::Info, "spansy: {spansy:?}");
let screen_name = spansy
.get("screen_name")
.context("Missing \"screen_name\" in response")?;
log!(LogLevel::Info, "screen_name: {:?}", screen_name);
let screen_name_start = screen_name.span().indices().iter().next().context("foo")?;
let screen_name_end = screen_name.span().indices().end().context("foo")?;
let secret_resps = vec![
body_string[0..screen_name_start - ("\"screen_name\":\"".len())].to_string(),
body_string[screen_name_end + 1..body_string.len()].to_string(),
];
Ok(Json(secret_resps))
}

View File

@@ -0,0 +1,53 @@
use std::collections::HashMap;
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(FromBytes, Deserialize, PartialEq, Debug, Serialize, ToBytes)]
#[serde(rename_all = "camelCase")]
#[encoding(Json)]
pub struct PluginConfig<'a> {
pub title: &'a str,
pub description: &'a str,
pub icon: String,
pub steps: Vec<StepConfig<'a>>,
pub host_functions: Vec<&'a str>,
pub cookies: Vec<&'a str>,
pub headers: Vec<&'a str>,
pub requests: Vec<RequestObject<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notary_urls: Option<Vec<&'a str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_urls: Option<Vec<&'a str>>,
}
#[derive(FromBytes, Deserialize, PartialEq, Debug, Serialize, ToBytes)]
#[serde(rename_all = "camelCase")]
#[encoding(Json)]
pub struct StepConfig<'a> {
pub title: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
pub cta: &'a str,
pub action: &'a str,
pub prover: bool,
}
#[derive(FromBytes, Deserialize, PartialEq, Debug, Serialize, ToBytes)]
#[serde(rename_all = "camelCase")]
#[encoding(Json)]
pub struct RequestObject<'a> {
pub url: &'a str,
pub method: &'a str,
}
#[derive(FromBytes, Deserialize, PartialEq, Debug, Serialize, ToBytes, Clone)]
#[serde(rename_all = "camelCase")]
#[encoding(Json)]
pub struct RequestConfig {
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
pub secret_headers: Vec<String>,
pub get_secret_response: Option<String>,
}

View File

@@ -0,0 +1,27 @@
use std::collections::HashMap;
use anyhow::Context;
use config::get;
use extism_pdk::*;
pub fn get_cookies_by_host(hostname: &str) -> Result<HashMap<String, String>, Error> {
get_by_host("cookies", hostname)
}
pub fn get_headers_by_host(hostname: &str) -> Result<HashMap<String, String>, Error> {
get_by_host("headers", hostname)
}
fn get_by_host(key: &str, hostname: &str) -> Result<HashMap<String, String>, Error> {
// Get key via Extism
let cookies_json: String =
get(key)?.context(format!("No {}s found in the configuration.", key))?;
// Parse the JSON string directly into a HashMap
let map: HashMap<String, HashMap<String, String>> = serde_json::from_str(&cookies_json)?;
// Attempt to find the hostname in the map
map.get(hostname)
.cloned()
.context(format!("Cannot find {}s for {}", key, hostname))
}

View File

@@ -0,0 +1,9 @@
# Testing
1. start notary server (**TLS off** for local testing, check version!)
2. Start proxy server:
```
wstcp --bind-addr 127.0.0.1:55688 api.x.com:443
```
3. `cargo build --release`
4. Load plugin in browser extension