mirror of
https://github.com/tlsnotary/tlsn-plugin-boilerplate.git
synced 2026-01-10 12:07:59 -05:00
Merge pull request #13 from tlsnotary/twitter-rs
Example plugin in Rust: Twitter profile
This commit is contained in:
2
examples/twitter_profile_rs/.cargo/config.toml
Normal file
2
examples/twitter_profile_rs/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
target = "wasm32-unknown-unknown"
|
||||
2
examples/twitter_profile_rs/.gitignore
vendored
Normal file
2
examples/twitter_profile_rs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
17
examples/twitter_profile_rs/Cargo.toml
Normal file
17
examples/twitter_profile_rs/Cargo.toml
Normal 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"
|
||||
BIN
examples/twitter_profile_rs/assets/icon.png
Normal file
BIN
examples/twitter_profile_rs/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
11
examples/twitter_profile_rs/src/host_functions.rs
Normal file
11
examples/twitter_profile_rs/src/host_functions.rs
Normal 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;
|
||||
}
|
||||
179
examples/twitter_profile_rs/src/lib.rs
Normal file
179
examples/twitter_profile_rs/src/lib.rs
Normal 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))
|
||||
}
|
||||
53
examples/twitter_profile_rs/src/types.rs
Normal file
53
examples/twitter_profile_rs/src/types.rs
Normal 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>,
|
||||
}
|
||||
27
examples/twitter_profile_rs/src/utils.rs
Normal file
27
examples/twitter_profile_rs/src/utils.rs
Normal 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))
|
||||
}
|
||||
9
examples/twitter_profile_rs/testing.md
Normal file
9
examples/twitter_profile_rs/testing.md
Normal 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
|
||||
Reference in New Issue
Block a user