@parlor/shell-desktop 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/assets/icons/128x128.png +0 -0
  4. package/assets/icons/128x128@2x.png +0 -0
  5. package/assets/icons/32x32.png +0 -0
  6. package/assets/icons/icon.icns +0 -0
  7. package/assets/icons/icon.ico +0 -0
  8. package/dist/build.d.ts +3 -0
  9. package/dist/build.d.ts.map +1 -0
  10. package/dist/build.js +18 -0
  11. package/dist/build.js.map +1 -0
  12. package/dist/dev.d.ts +9 -0
  13. package/dist/dev.d.ts.map +1 -0
  14. package/dist/dev.js +28 -0
  15. package/dist/dev.js.map +1 -0
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +6 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/prerequisites.d.ts +4 -0
  21. package/dist/prerequisites.d.ts.map +1 -0
  22. package/dist/prerequisites.js +12 -0
  23. package/dist/prerequisites.js.map +1 -0
  24. package/dist/project.d.ts +49 -0
  25. package/dist/project.d.ts.map +1 -0
  26. package/dist/project.js +245 -0
  27. package/dist/project.js.map +1 -0
  28. package/dist/target-triple.d.ts +3 -0
  29. package/dist/target-triple.d.ts.map +1 -0
  30. package/dist/target-triple.js +10 -0
  31. package/dist/target-triple.js.map +1 -0
  32. package/dist/tauri-cli.d.ts +9 -0
  33. package/dist/tauri-cli.d.ts.map +1 -0
  34. package/dist/tauri-cli.js +42 -0
  35. package/dist/tauri-cli.js.map +1 -0
  36. package/package.json +45 -3
  37. package/rust/Cargo.toml +37 -0
  38. package/rust/src/auth_browser.rs +195 -0
  39. package/rust/src/control.rs +104 -0
  40. package/rust/src/device_key.rs +282 -0
  41. package/rust/src/dialog.rs +22 -0
  42. package/rust/src/host.rs +338 -0
  43. package/rust/src/keystore.rs +442 -0
  44. package/rust/src/lib.rs +22 -0
  45. package/rust/src/secret_store.rs +42 -0
  46. package/rust/src/session.rs +100 -0
  47. package/rust/src/vault.rs +133 -0
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@parlor/shell-desktop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "homepage": "https://cychien.github.io/parlor",
5
+ "bugs": "https://github.com/cychien/parlor/issues",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cychien/parlor.git",
10
+ "directory": "packages/shell-desktop"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "assets",
15
+ "rust/Cargo.toml",
16
+ "rust/src"
17
+ ],
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@tauri-apps/api": "^2.12.1",
27
+ "@tauri-apps/cli": "^2.12.1",
28
+ "@tauri-apps/plugin-deep-link": "^2.6.1",
29
+ "@tauri-apps/plugin-dialog": "^2.8.3",
30
+ "@tauri-apps/plugin-fs": "^2.7.1",
31
+ "@tauri-apps/plugin-notification": "^2.5.1",
32
+ "@parlor/shell-web": "0.2.0",
33
+ "@parlor/config": "0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^24.14.0",
37
+ "typescript": "^7.0.2",
38
+ "vitest": "^5.0.3"
39
+ },
40
+ "optionalDependencies": {
41
+ "@parlor/agent-host-darwin-arm64": "0.2.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "test": "vitest run"
47
+ }
6
48
  }
@@ -0,0 +1,37 @@
1
+ [package]
2
+ name = "parlor-shell"
3
+ version = "0.0.0"
4
+ edition = "2024"
5
+ publish = false
6
+
7
+ [lib]
8
+ name = "parlor_shell"
9
+
10
+ [dependencies]
11
+ tauri = { version = "2", features = [] }
12
+ tauri-plugin-dialog = "2"
13
+ serde = { version = "1", features = ["derive"] }
14
+ serde_json = "1"
15
+ base64 = "0.22"
16
+ sha2 = "0.10"
17
+
18
+ [target.'cfg(target_os = "macos")'.dependencies]
19
+ security-framework = "3"
20
+ security-framework-sys = "2"
21
+ core-foundation = "0.10"
22
+ core-foundation-sys = "0.8"
23
+ objc2 = "0.6"
24
+ objc2-foundation = "0.3"
25
+ objc2-local-authentication = "0.3"
26
+ objc2-app-kit = { version = "0.3", default-features = false, features = [
27
+ "std",
28
+ "NSResponder",
29
+ "NSWindow",
30
+ ] }
31
+ objc2-authentication-services = { version = "0.3", default-features = false, features = [
32
+ "std",
33
+ "block2",
34
+ "ASFoundation",
35
+ "ASWebAuthenticationSession",
36
+ ] }
37
+ block2 = "0.6"
@@ -0,0 +1,195 @@
1
+ use tauri::{Url, WebviewWindow};
2
+
3
+ fn sign_in_page(url: &str) -> Result<Url, String> {
4
+ match Url::parse(url) {
5
+ Ok(page) if matches!(page.scheme(), "https" | "http") => Ok(page),
6
+ _ => Err(format!("The sign-in page must be an http(s) URL: {url:?}")),
7
+ }
8
+ }
9
+
10
+ fn url_scheme(scheme: &str) -> Result<&str, String> {
11
+ let mut chars = scheme.chars();
12
+ let valid = chars.next().is_some_and(|first| first.is_ascii_lowercase())
13
+ && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "+.-".contains(c));
14
+ if valid {
15
+ Ok(scheme)
16
+ } else {
17
+ Err(format!("The callback must be the app's URL scheme: {scheme:?}"))
18
+ }
19
+ }
20
+
21
+ #[cfg(target_os = "macos")]
22
+ mod apple {
23
+ use std::cell::RefCell;
24
+ use std::rc::Rc;
25
+ use std::sync::mpsc;
26
+
27
+ use block2::RcBlock;
28
+ use objc2::rc::Retained;
29
+ use objc2::runtime::ProtocolObject;
30
+ use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
31
+ use objc2_app_kit::NSWindow;
32
+ use objc2_authentication_services::{
33
+ ASWebAuthenticationPresentationContextProviding, ASWebAuthenticationSession,
34
+ ASWebAuthenticationSessionErrorCode, ASWebAuthenticationSessionErrorDomain,
35
+ };
36
+ use objc2_foundation::{NSError, NSObject, NSObjectProtocol, NSString, NSURL};
37
+ use tauri::{Url, WebviewWindow};
38
+
39
+ type Outcome = Result<Option<String>, String>;
40
+ /// The session presents over its anchor, which it holds weakly.
41
+ type Running = (Retained<ASWebAuthenticationSession>, Retained<Anchor>);
42
+
43
+ define_class!(
44
+ #[unsafe(super(NSObject))]
45
+ #[thread_kind = MainThreadOnly]
46
+ #[ivars = Retained<NSWindow>]
47
+ struct Anchor;
48
+
49
+ unsafe impl NSObjectProtocol for Anchor {}
50
+
51
+ unsafe impl ASWebAuthenticationPresentationContextProviding for Anchor {
52
+ #[unsafe(method_id(presentationAnchorForWebAuthenticationSession:))]
53
+ fn anchor(&self, _session: &ASWebAuthenticationSession) -> Retained<NSWindow> {
54
+ self.ivars().clone()
55
+ }
56
+ }
57
+ );
58
+
59
+ impl Anchor {
60
+ fn new(mtm: MainThreadMarker, window: Retained<NSWindow>) -> Retained<Self> {
61
+ let this = Self::alloc(mtm).set_ivars(window);
62
+ unsafe { msg_send![super(this), init] }
63
+ }
64
+ }
65
+
66
+ fn outcome(url: *mut NSURL, error: *mut NSError) -> Outcome {
67
+ if let Some(url) = unsafe { url.as_ref() } {
68
+ return Ok(url.absoluteString().map(|text| text.to_string()));
69
+ }
70
+ let Some(error) = (unsafe { error.as_ref() }) else {
71
+ return Err("Sign-in did not finish.".to_string());
72
+ };
73
+ let cancelled = error.domain().to_string()
74
+ == unsafe { ASWebAuthenticationSessionErrorDomain }.to_string()
75
+ && error.code() == ASWebAuthenticationSessionErrorCode::CanceledLogin.0;
76
+ if cancelled {
77
+ Ok(None)
78
+ } else {
79
+ Err(error.localizedDescription().to_string())
80
+ }
81
+ }
82
+
83
+ /// Runs on the main thread; the session and its anchor stay alive until it completes.
84
+ fn start(window: &WebviewWindow, page: &Url, scheme: &str, done: mpsc::Sender<Outcome>) {
85
+ let fail = |done: &mpsc::Sender<Outcome>, message: &str| {
86
+ let _ = done.send(Err(message.to_string()));
87
+ };
88
+ let Some(mtm) = MainThreadMarker::new() else {
89
+ return fail(&done, "The sign-in browser must open on the main thread.");
90
+ };
91
+ let Some(ns_window) = window
92
+ .ns_window()
93
+ .ok()
94
+ .and_then(|pointer| unsafe { Retained::retain(pointer.cast::<NSWindow>()) })
95
+ else {
96
+ return fail(&done, "The app has no window to show the sign-in browser over.");
97
+ };
98
+ let Some(url) = NSURL::URLWithString(&NSString::from_str(page.as_str())) else {
99
+ return fail(&done, "The sign-in page URL is not valid.");
100
+ };
101
+ let anchor = Anchor::new(mtm, ns_window);
102
+ let running: Rc<RefCell<Option<Running>>> = Rc::new(RefCell::new(None));
103
+ let keep = running.clone();
104
+ let reply = RefCell::new(Some(done.clone()));
105
+ let completion = RcBlock::new(move |url: *mut NSURL, error: *mut NSError| {
106
+ keep.borrow_mut().take();
107
+ if let Some(done) = reply.borrow_mut().take() {
108
+ let _ = done.send(outcome(url, error));
109
+ }
110
+ });
111
+ #[allow(deprecated)]
112
+ let session = unsafe {
113
+ ASWebAuthenticationSession::initWithURL_callbackURLScheme_completionHandler(
114
+ ASWebAuthenticationSession::alloc(),
115
+ &url,
116
+ Some(&NSString::from_str(scheme)),
117
+ RcBlock::as_ptr(&completion),
118
+ )
119
+ };
120
+ unsafe {
121
+ session.setPresentationContextProvider(Some(ProtocolObject::from_ref(&*anchor)));
122
+ session.setPrefersEphemeralWebBrowserSession(false);
123
+ }
124
+ *running.borrow_mut() = Some((session.clone(), anchor));
125
+ if !unsafe { session.start() } {
126
+ running.borrow_mut().take();
127
+ fail(&done, "The sign-in browser could not open.");
128
+ }
129
+ }
130
+
131
+ pub async fn open(window: WebviewWindow, page: Url, scheme: String) -> Outcome {
132
+ let (done, outcome) = mpsc::channel();
133
+ let target = window.clone();
134
+ window
135
+ .run_on_main_thread(move || start(&target, &page, &scheme, done))
136
+ .map_err(|e| e.to_string())?;
137
+ tauri::async_runtime::spawn_blocking(move || outcome.recv())
138
+ .await
139
+ .map_err(|e| e.to_string())?
140
+ .map_err(|_| "The sign-in browser closed without an answer.".to_string())?
141
+ }
142
+ }
143
+
144
+ #[cfg(not(target_os = "macos"))]
145
+ mod apple {
146
+ use tauri::{Url, WebviewWindow};
147
+
148
+ pub async fn open(
149
+ _window: WebviewWindow,
150
+ _page: Url,
151
+ _scheme: String,
152
+ ) -> Result<Option<String>, String> {
153
+ Err("unsupported".to_string())
154
+ }
155
+ }
156
+
157
+ /// Opens `url` in ASWebAuthenticationSession and resolves with the URL it returned to under
158
+ /// `callback_scheme`, or `None` when the user closed it.
159
+ #[tauri::command]
160
+ pub async fn parlor_auth_browser_open(
161
+ window: WebviewWindow,
162
+ url: String,
163
+ callback_scheme: String,
164
+ ) -> Result<Option<String>, String> {
165
+ let page = sign_in_page(&url)?;
166
+ let scheme = url_scheme(&callback_scheme)?.to_string();
167
+ apple::open(window, page, scheme).await
168
+ }
169
+
170
+ #[cfg(test)]
171
+ mod tests {
172
+ use super::*;
173
+
174
+ #[test]
175
+ fn only_an_http_page_and_a_url_scheme_are_accepted() {
176
+ assert!(sign_in_page("https://app.example.com/_parlor/auth/native").is_ok());
177
+ assert!(sign_in_page("http://localhost:3000/x").is_ok());
178
+ for bad in ["", "javascript:alert(1)", "file:///etc/passwd", "hello://x"] {
179
+ assert!(sign_in_page(bad).is_err(), "{bad}");
180
+ }
181
+ assert_eq!(url_scheme("hello"), Ok("hello"));
182
+ assert_eq!(url_scheme("dev.parlor-app+x"), Ok("dev.parlor-app+x"));
183
+ for bad in ["", "Hello", "1app", "a b", "https://x"] {
184
+ assert!(url_scheme(bad).is_err(), "{bad}");
185
+ }
186
+ }
187
+
188
+ #[test]
189
+ fn a_refused_probe_never_reads_as_unavailable() {
190
+ for error in [sign_in_page("").unwrap_err(), url_scheme("").unwrap_err()] {
191
+ let lower = error.to_lowercase();
192
+ assert!(!lower.contains("not found") && !lower.contains("unsupported"));
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,104 @@
1
+ use std::io::{BufRead, BufReader, Write};
2
+ use std::os::unix::net::UnixStream;
3
+ use std::path::PathBuf;
4
+ use std::time::Duration;
5
+
6
+ use serde_json::Value;
7
+
8
+ pub const REPLY_TIMEOUT: Duration = Duration::from_secs(30);
9
+
10
+ pub fn home() -> Result<PathBuf, String> {
11
+ std::env::var_os("HOME")
12
+ .map(PathBuf::from)
13
+ .ok_or_else(|| "HOME is not set".to_string())
14
+ }
15
+
16
+ pub fn socket_path(home: &std::path::Path) -> PathBuf {
17
+ home.join(".parlor")
18
+ .join("host")
19
+ .join("run")
20
+ .join("control.sock")
21
+ }
22
+
23
+ pub fn exchange(socket: &std::path::Path, request: &Value) -> Result<Value, String> {
24
+ let mut stream = UnixStream::connect(socket).map_err(|e| e.to_string())?;
25
+ stream
26
+ .set_read_timeout(Some(REPLY_TIMEOUT))
27
+ .map_err(|e| e.to_string())?;
28
+ let line = format!("{request}\n");
29
+ stream
30
+ .write_all(line.as_bytes())
31
+ .map_err(|e| e.to_string())?;
32
+ let mut reply = String::new();
33
+ BufReader::new(stream)
34
+ .read_line(&mut reply)
35
+ .map_err(|e| e.to_string())?;
36
+ parse_reply(&reply)
37
+ }
38
+
39
+ pub fn parse_reply(line: &str) -> Result<Value, String> {
40
+ if line.trim().is_empty() {
41
+ return Err("Parlor Host closed the connection.".to_string());
42
+ }
43
+ serde_json::from_str(line).map_err(|e| e.to_string())
44
+ }
45
+
46
+ pub fn request(socket: &std::path::Path, request: Value) -> Result<Value, String> {
47
+ let reply = exchange(socket, &request)?;
48
+ if reply.get("ok").and_then(Value::as_bool) == Some(true) {
49
+ return Ok(reply);
50
+ }
51
+ let error = reply
52
+ .get("error")
53
+ .and_then(Value::as_str)
54
+ .unwrap_or("Parlor Host refused the request.");
55
+ Err(error.to_string())
56
+ }
57
+
58
+ #[cfg(test)]
59
+ mod tests {
60
+ use super::*;
61
+ use serde_json::json;
62
+ use std::os::unix::net::UnixListener;
63
+
64
+ #[test]
65
+ fn socket_lives_under_the_home_host_run_directory() {
66
+ assert_eq!(
67
+ socket_path(std::path::Path::new("/Users/a")),
68
+ PathBuf::from("/Users/a/.parlor/host/run/control.sock")
69
+ );
70
+ }
71
+
72
+ #[test]
73
+ fn a_reply_is_one_json_line() {
74
+ assert_eq!(parse_reply("{\"ok\":true}\n").unwrap(), json!({"ok": true}));
75
+ assert!(parse_reply("").is_err());
76
+ assert!(parse_reply("not json").is_err());
77
+ }
78
+
79
+ #[test]
80
+ fn request_writes_one_line_and_surfaces_a_refusal() {
81
+ let dir = std::env::temp_dir().join(format!("ps-{}", std::process::id()));
82
+ std::fs::create_dir_all(&dir).unwrap();
83
+ let path = dir.join("c.sock");
84
+ let listener = UnixListener::bind(&path).unwrap();
85
+ let server = std::thread::spawn(move || {
86
+ let (mut stream, _) = listener.accept().unwrap();
87
+ let mut line = String::new();
88
+ BufReader::new(stream.try_clone().unwrap())
89
+ .read_line(&mut line)
90
+ .unwrap();
91
+ assert_eq!(
92
+ serde_json::from_str::<Value>(&line).unwrap(),
93
+ json!({"op": "status"})
94
+ );
95
+ stream
96
+ .write_all(b"{\"ok\":false,\"error\":\"declined_locally\"}\n")
97
+ .unwrap();
98
+ });
99
+ let outcome = request(&path, json!({"op": "status"}));
100
+ server.join().unwrap();
101
+ std::fs::remove_dir_all(&dir).ok();
102
+ assert_eq!(outcome, Err("declined_locally".to_string()));
103
+ }
104
+ }
@@ -0,0 +1,282 @@
1
+ use base64::Engine;
2
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3
+ use serde::Serialize;
4
+ use sha2::{Digest, Sha256};
5
+ use tauri::AppHandle;
6
+
7
+ use crate::keystore::{self, PublicPoint};
8
+
9
+ #[derive(Serialize, Debug, PartialEq)]
10
+ #[serde(rename_all = "camelCase")]
11
+ pub struct PublicJwk {
12
+ pub kty: &'static str,
13
+ pub crv: &'static str,
14
+ pub x: String,
15
+ pub y: String,
16
+ }
17
+
18
+ #[derive(Serialize, Debug, PartialEq)]
19
+ #[serde(rename_all = "camelCase")]
20
+ pub struct KeyReport {
21
+ pub key_id: String,
22
+ pub public_key: PublicJwk,
23
+ }
24
+
25
+ #[derive(Serialize, Debug, PartialEq)]
26
+ pub struct Proof {
27
+ pub signature: String,
28
+ }
29
+
30
+ #[derive(Serialize, Debug, PartialEq)]
31
+ pub struct PublicKeys {
32
+ pub signing: KeyReport,
33
+ #[serde(skip_serializing_if = "Option::is_none")]
34
+ pub presence: Option<KeyReport>,
35
+ pub proof: Proof,
36
+ }
37
+
38
+ #[derive(Serialize, Debug, PartialEq)]
39
+ pub struct Signature {
40
+ pub signature: String,
41
+ }
42
+
43
+ pub fn encode(bytes: &[u8]) -> String {
44
+ URL_SAFE_NO_PAD.encode(bytes)
45
+ }
46
+
47
+ pub fn thumbprint(x: &str, y: &str) -> String {
48
+ let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#);
49
+ encode(&Sha256::digest(canonical.as_bytes()))
50
+ }
51
+
52
+ pub fn report_of(point: &PublicPoint) -> KeyReport {
53
+ let (x, y) = (encode(&point.x), encode(&point.y));
54
+ KeyReport {
55
+ key_id: thumbprint(&x, &y),
56
+ public_key: PublicJwk {
57
+ kty: "EC",
58
+ crv: "P-256",
59
+ x,
60
+ y,
61
+ },
62
+ }
63
+ }
64
+
65
+ fn point_json(key: &PublicJwk) -> String {
66
+ format!(
67
+ r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
68
+ key.x, key.y
69
+ )
70
+ }
71
+
72
+ pub fn enrollment_bytes(
73
+ challenge: &str,
74
+ signing: &KeyReport,
75
+ presence: Option<&KeyReport>,
76
+ ) -> Vec<u8> {
77
+ let challenge = serde_json::to_string(challenge).unwrap_or_default();
78
+ let presence = match presence {
79
+ Some(report) => format!(
80
+ r#"{{"format":"es256-raw","id":"{}","publicKey":{}}}"#,
81
+ report.key_id,
82
+ point_json(&report.public_key)
83
+ ),
84
+ None => "null".to_string(),
85
+ };
86
+ format!(
87
+ r#"{{"challenge":{challenge},"presence":{presence},"signing":{}}}"#,
88
+ point_json(&signing.public_key)
89
+ )
90
+ .into_bytes()
91
+ }
92
+
93
+ pub fn der_to_raw(der: &[u8]) -> Result<[u8; 64], String> {
94
+ fn length(bytes: &[u8], at: &mut usize) -> Result<usize, String> {
95
+ let first = *bytes.get(*at).ok_or("short signature")?;
96
+ *at += 1;
97
+ if first < 0x80 {
98
+ return Ok(first as usize);
99
+ }
100
+ let count = (first & 0x7f) as usize;
101
+ let mut value = 0usize;
102
+ for _ in 0..count {
103
+ value = (value << 8) | *bytes.get(*at).ok_or("short signature")? as usize;
104
+ *at += 1;
105
+ }
106
+ Ok(value)
107
+ }
108
+ fn integer(bytes: &[u8], at: &mut usize) -> Result<[u8; 32], String> {
109
+ if bytes.get(*at) != Some(&0x02) {
110
+ return Err("not an integer".to_string());
111
+ }
112
+ *at += 1;
113
+ let size = length(bytes, at)?;
114
+ let body = bytes.get(*at..*at + size).ok_or("short signature")?;
115
+ *at += size;
116
+ let trimmed = match body.iter().position(|byte| *byte != 0) {
117
+ Some(first) => &body[first..],
118
+ None => &[0u8][..],
119
+ };
120
+ if trimmed.len() > 32 {
121
+ return Err("integer too long".to_string());
122
+ }
123
+ let mut out = [0u8; 32];
124
+ out[32 - trimmed.len()..].copy_from_slice(trimmed);
125
+ Ok(out)
126
+ }
127
+ let mut at = 0;
128
+ if der.first() != Some(&0x30) {
129
+ return Err("not a sequence".to_string());
130
+ }
131
+ at += 1;
132
+ length(der, &mut at)?;
133
+ let r = integer(der, &mut at)?;
134
+ let s = integer(der, &mut at)?;
135
+ let mut raw = [0u8; 64];
136
+ raw[..32].copy_from_slice(&r);
137
+ raw[32..].copy_from_slice(&s);
138
+ Ok(raw)
139
+ }
140
+
141
+ pub fn decode_payload(payload: &str) -> Result<Vec<u8>, String> {
142
+ URL_SAFE_NO_PAD.decode(payload).map_err(|e| e.to_string())
143
+ }
144
+
145
+ async fn blocking<T: Send + 'static>(
146
+ work: impl FnOnce() -> Result<T, String> + Send + 'static,
147
+ ) -> Result<T, String> {
148
+ tauri::async_runtime::spawn_blocking(work)
149
+ .await
150
+ .map_err(|e| e.to_string())?
151
+ }
152
+
153
+ #[tauri::command]
154
+ pub async fn parlor_device_key_public(
155
+ app: AppHandle,
156
+ presence: bool,
157
+ challenge: String,
158
+ ) -> Result<PublicKeys, String> {
159
+ let identifier = app.config().identifier.clone();
160
+ blocking(move || {
161
+ let signing = report_of(&keystore::signing_key(&identifier)?.point()?);
162
+ let presence = if presence {
163
+ keystore::presence_key(&identifier)?
164
+ .map(|key| key.point().map(|point| report_of(&point)))
165
+ .transpose()?
166
+ } else {
167
+ None
168
+ };
169
+ let bytes = enrollment_bytes(&challenge, &signing, presence.as_ref());
170
+ let raw = keystore::signing_key(&identifier)?.sign(&bytes)?;
171
+ Ok(PublicKeys {
172
+ signing,
173
+ presence,
174
+ proof: Proof {
175
+ signature: encode(&raw),
176
+ },
177
+ })
178
+ })
179
+ .await
180
+ }
181
+
182
+ #[tauri::command]
183
+ pub async fn parlor_device_key_sign(
184
+ app: AppHandle,
185
+ key_id: String,
186
+ payload: String,
187
+ ) -> Result<Signature, String> {
188
+ let identifier = app.config().identifier.clone();
189
+ blocking(move || {
190
+ let bytes = decode_payload(&payload)?;
191
+ let key = keystore::find(&identifier, &key_id)?;
192
+ Ok(Signature {
193
+ signature: encode(&key.sign(&bytes)?),
194
+ })
195
+ })
196
+ .await
197
+ }
198
+
199
+ #[cfg(test)]
200
+ mod tests {
201
+ use super::*;
202
+
203
+ fn report(x: &str, y: &str) -> KeyReport {
204
+ KeyReport {
205
+ key_id: thumbprint(x, y),
206
+ public_key: PublicJwk {
207
+ kty: "EC",
208
+ crv: "P-256",
209
+ x: x.to_string(),
210
+ y: y.to_string(),
211
+ },
212
+ }
213
+ }
214
+
215
+ #[test]
216
+ fn the_enrollment_bytes_match_the_canonical_json_of_the_web_adapter() {
217
+ let signing = report("AAA", "BBB");
218
+ let text = String::from_utf8(enrollment_bytes("chal", &signing, None)).unwrap();
219
+ assert_eq!(
220
+ text,
221
+ r#"{"challenge":"chal","presence":null,"signing":{"crv":"P-256","kty":"EC","x":"AAA","y":"BBB"}}"#
222
+ );
223
+ let presence = report("CCC", "DDD");
224
+ let text = String::from_utf8(enrollment_bytes("chal", &signing, Some(&presence))).unwrap();
225
+ assert_eq!(
226
+ text,
227
+ format!(
228
+ r#"{{"challenge":"chal","presence":{{"format":"es256-raw","id":"{}","publicKey":{{"crv":"P-256","kty":"EC","x":"CCC","y":"DDD"}}}},"signing":{{"crv":"P-256","kty":"EC","x":"AAA","y":"BBB"}}}}"#,
229
+ presence.key_id
230
+ )
231
+ );
232
+ }
233
+
234
+ #[test]
235
+ fn the_key_id_is_the_sha256_thumbprint_of_the_sorted_point() {
236
+ assert_eq!(
237
+ thumbprint("x", "y"),
238
+ encode(&Sha256::digest(
239
+ br#"{"crv":"P-256","kty":"EC","x":"x","y":"y"}"#
240
+ ))
241
+ );
242
+ assert_eq!(thumbprint("x", "y").len(), 43);
243
+ }
244
+
245
+ #[test]
246
+ fn der_signatures_become_raw_r_and_s() {
247
+ let mut der = vec![0x30, 0x44, 0x02, 0x20];
248
+ der.extend([1u8; 32]);
249
+ der.extend([0x02, 0x20]);
250
+ der.extend([2u8; 32]);
251
+ let raw = der_to_raw(&der).unwrap();
252
+ assert_eq!(&raw[..32], &[1u8; 32]);
253
+ assert_eq!(&raw[32..], &[2u8; 32]);
254
+ }
255
+
256
+ #[test]
257
+ fn der_integers_lose_their_sign_padding_and_gain_leading_zeros() {
258
+ let mut der = vec![0x30, 0x26, 0x02, 0x21, 0x00];
259
+ der.extend([0xffu8; 32]);
260
+ der.extend([0x02, 0x01, 0x05]);
261
+ let raw = der_to_raw(&der).unwrap();
262
+ assert_eq!(&raw[..32], &[0xffu8; 32]);
263
+ assert_eq!(raw[63], 5);
264
+ assert_eq!(&raw[32..63], &[0u8; 31]);
265
+ assert!(der_to_raw(&[0x31, 0x00]).is_err());
266
+ }
267
+
268
+ #[test]
269
+ fn a_missing_presence_key_is_left_out_of_the_reply() {
270
+ let keys = PublicKeys {
271
+ signing: report("a", "b"),
272
+ presence: None,
273
+ proof: Proof {
274
+ signature: "s".to_string(),
275
+ },
276
+ };
277
+ let json = serde_json::to_value(&keys).unwrap();
278
+ assert!(json.get("presence").is_none());
279
+ assert_eq!(json["signing"]["publicKey"]["kty"], "EC");
280
+ assert_eq!(json["signing"]["keyId"], keys.signing.key_id);
281
+ }
282
+ }
@@ -0,0 +1,22 @@
1
+ pub fn title(app_name: &str) -> String {
2
+ format!("Connect this Mac to {app_name}")
3
+ }
4
+
5
+ pub fn message(app_name: &str, account: &str, matching_code: &str) -> String {
6
+ format!(
7
+ "Let {app_name} use the agents on this Mac as {account}?\n\nCode: {matching_code}\n\nContinue only if you started this in {app_name}."
8
+ )
9
+ }
10
+
11
+ #[cfg(test)]
12
+ mod tests {
13
+ use super::*;
14
+
15
+ #[test]
16
+ fn the_dialog_names_the_app_the_account_and_the_code() {
17
+ assert_eq!(title("Hello"), "Connect this Mac to Hello");
18
+ let text = message("Hello", "alice@example.com", "ABC-234");
19
+ assert!(text.contains("alice@example.com"));
20
+ assert!(text.contains("ABC-234"));
21
+ }
22
+ }