Skip to content

Commit 523cdd0

Browse files
authored
Merge pull request #46 from auths-dev/feature/fn-43-sas-verification
feat: verification via SAS and transport encryption for device pairing
2 parents b203e13 + 937d003 commit 523cdd0

16 files changed

Lines changed: 911 additions & 76 deletions

File tree

.github/workflows/docs.yml

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ permissions:
2323
contents: write
2424

2525
jobs:
26-
check-cli-docs:
27-
name: CLI docs up to date
26+
deploy:
27+
name: Deploy docs
2828
runs-on: ubuntu-latest
2929
steps:
3030
- uses: actions/checkout@v4
31+
with:
32+
fetch-depth: 0 # full history for git-revision-date
3133

3234
- name: Install Rust toolchain
3335
uses: dtolnay/rust-toolchain@stable
@@ -42,17 +44,8 @@ jobs:
4244
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
4345
restore-keys: ${{ runner.os }}-cargo-
4446

45-
- name: Check CLI docs are up to date
46-
run: cargo run -p xtask -- gen-docs --check
47-
48-
deploy:
49-
name: Deploy docs
50-
needs: check-cli-docs
51-
runs-on: ubuntu-latest
52-
steps:
53-
- uses: actions/checkout@v4
54-
with:
55-
fetch-depth: 0 # full history for git-revision-date
47+
- name: Regenerate CLI docs
48+
run: cargo run -p xtask -- gen-docs
5649

5750
- uses: actions/setup-python@v5
5851
with:

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/auths-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ auths-policy.workspace = true
3838
auths-index.workspace = true
3939
auths-crypto.workspace = true
4040
auths-sdk.workspace = true
41+
auths-pairing-protocol.workspace = true
4142
auths-telemetry.workspace = true
4243
auths-verifier = { workspace = true, features = ["native"] }
4344
auths-infra-git.workspace = true

crates/auths-cli/src/commands/device/pair/common.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,58 @@ pub(crate) fn print_completion(device_name: Option<&str>, device_did: &str) {
7272
println!();
7373
}
7474

75+
/// Display SAS and prompt for explicit Y/N confirmation (no default).
76+
///
77+
/// Returns `true` if the user confirms the SAS matches, `false` on rejection.
78+
pub(crate) fn prompt_sas_confirmation(sas_bytes: &[u8; 8]) -> Result<bool> {
79+
use auths_pairing_protocol::sas;
80+
81+
println!();
82+
println!("{}", style(format!("━━━ {LOCK}Verify Pairing ━━━")).bold());
83+
println!();
84+
println!(" Confirm this code matches your other device:");
85+
println!();
86+
println!(" {}", style(sas::format_sas_emoji(sas_bytes)).bold());
87+
println!(
88+
" {}",
89+
style(format!("({})", sas::format_sas_numeric(sas_bytes))).dim()
90+
);
91+
println!();
92+
println!(
93+
" {}",
94+
style("If the codes don't match, someone may be intercepting this connection.").dim()
95+
);
96+
println!();
97+
98+
let confirmed = dialoguer::Confirm::new()
99+
.with_prompt("Do the codes match? [y/N]")
100+
.default(false)
101+
.interact()
102+
.context("Failed to read confirmation")?;
103+
104+
Ok(confirmed)
105+
}
106+
107+
/// Display a warning when SAS verification fails.
108+
pub(crate) fn display_sas_mismatch_warning() {
109+
println!();
110+
println!(
111+
" {}{}",
112+
WARN,
113+
style("PAIRING ABORTED — possible interception detected")
114+
.red()
115+
.bold()
116+
);
117+
println!();
118+
println!(" The verification codes did not match. This could mean:");
119+
println!(" • An attacker is intercepting the connection (MITM)");
120+
println!(" • A network issue corrupted the key exchange");
121+
println!();
122+
println!(" Retry on a trusted network. If this persists, do not pair these devices.");
123+
println!(" No keys or attestations were created.");
124+
println!();
125+
}
126+
75127
/// Handle a successful pairing response — verify signature, complete ECDH, create attestation.
76128
pub(crate) fn handle_pairing_response(
77129
session: &mut PairingSession,
@@ -137,11 +189,37 @@ pub(crate) fn handle_pairing_response(
137189

138190
// Complete ECDH key exchange
139191
let exchange_spinner = create_wait_spinner(&format!("{GEAR}Completing key exchange..."));
140-
let _shared_secret = session
192+
let initiator_x25519_pub = session
193+
.ephemeral_pubkey_bytes()
194+
.context("Failed to get initiator pubkey")?;
195+
let shared_secret = session
141196
.complete_exchange(&device_x25519_bytes)
142197
.context("ECDH key exchange failed")?;
143198
exchange_spinner.finish_with_message(format!("{CHECK}Key exchange complete"));
144199

200+
// Derive SAS with transcript binding
201+
let short_code = &session.token.short_code;
202+
let sas_bytes = auths_pairing_protocol::sas::derive_sas(
203+
&shared_secret,
204+
&initiator_x25519_pub,
205+
&device_x25519_bytes,
206+
short_code,
207+
);
208+
let transport_key = auths_pairing_protocol::sas::derive_transport_key(
209+
&shared_secret,
210+
&initiator_x25519_pub,
211+
&device_x25519_bytes,
212+
short_code,
213+
);
214+
215+
// SAS verification ceremony
216+
let confirmed = prompt_sas_confirmation(&sas_bytes)?;
217+
if !confirmed {
218+
display_sas_mismatch_warning();
219+
drop(transport_key);
220+
anyhow::bail!("SAS verification failed — pairing aborted");
221+
}
222+
145223
if !auths_dir.exists() {
146224
println!();
147225
println!(

crates/auths-cli/src/commands/device/pair/join.rs

Lines changed: 135 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
33
use anyhow::{Context, Result};
44
use auths_core::config::EnvironmentConfig;
5+
use auths_core::pairing::types::Base64UrlEncoded;
6+
use auths_core::pairing::{PairingResponse, PairingToken};
7+
use auths_core::ports::pairing::PairingRelayClient;
58
use auths_infra_http::HttpPairingRelayClient;
6-
use auths_sdk::pairing::{
7-
PairingCompletionResult, join_pairing_session, load_device_signing_material,
8-
};
9+
use auths_pairing_protocol::sas;
10+
use auths_sdk::pairing::{load_device_signing_material, validate_short_code};
911
use chrono::Utc;
1012
use console::style;
1113

@@ -20,8 +22,7 @@ pub(crate) async fn handle_join(
2022
registry: &str,
2123
env_config: &EnvironmentConfig,
2224
) -> Result<()> {
23-
let normalized =
24-
auths_sdk::pairing::validate_short_code(code).map_err(|e| anyhow::anyhow!("{}", e))?;
25+
let normalized = validate_short_code(code).map_err(|e| anyhow::anyhow!("{}", e))?;
2526

2627
let formatted = format!("{}-{}", &normalized[..3], &normalized[3..]);
2728

@@ -67,42 +68,148 @@ pub(crate) async fn handle_join(
6768
);
6869
println!();
6970

70-
let create_spinner = create_wait_spinner(&format!("{GEAR}Creating and submitting response..."));
71+
// Look up the session by short code
72+
let session_data = relay
73+
.lookup_by_code(registry, &normalized)
74+
.await
75+
.map_err(|e| anyhow::anyhow!("Failed to look up session: {}", e))?;
76+
77+
let token_data = session_data
78+
.token
79+
.ok_or_else(|| anyhow::anyhow!("session has no token data"))?;
80+
81+
let token = PairingToken {
82+
controller_did: token_data.controller_did.clone(),
83+
endpoint: registry.to_string(),
84+
short_code: normalized.clone(),
85+
ephemeral_pubkey: token_data.ephemeral_pubkey.to_string(),
86+
expires_at: chrono::DateTime::from_timestamp(token_data.expires_at, 0)
87+
.unwrap_or_else(Utc::now),
88+
capabilities: token_data.capabilities.clone(),
89+
};
90+
91+
if token.is_expired(Utc::now()) {
92+
anyhow::bail!("Session expired");
93+
}
94+
95+
let create_spinner = create_wait_spinner(&format!("{GEAR}Creating pairing response..."));
7196

72-
match join_pairing_session(
73-
code,
74-
registry,
75-
&relay,
97+
// Create the response + ECDH
98+
let (pairing_response, shared_secret) = PairingResponse::create(
7699
Utc::now(),
77-
&material,
100+
&token,
101+
&material.seed,
102+
&material.public_key,
103+
material.device_did.to_string(),
78104
Some(hostname()),
79105
)
80-
.await
81-
.map_err(|e| anyhow::anyhow!("{}", e))?
82-
{
83-
PairingCompletionResult::Success { .. } => {
84-
create_spinner.finish_with_message(format!("{CHECK}Response submitted"));
85-
}
86-
PairingCompletionResult::Fallback { error, .. } => {
87-
create_spinner.finish_and_clear();
88-
anyhow::bail!("Failed to submit pairing response: {}", error);
89-
}
106+
.map_err(|e| anyhow::anyhow!("Failed to create pairing response: {}", e))?;
107+
108+
// Derive SAS from shared secret with transcript binding
109+
let initiator_x25519_pub = token
110+
.ephemeral_pubkey_bytes()
111+
.map_err(|e| anyhow::anyhow!("Invalid initiator pubkey: {}", e))?;
112+
let responder_x25519_pub = pairing_response
113+
.device_x25519_pubkey_bytes()
114+
.map_err(|e| anyhow::anyhow!("Invalid responder pubkey: {}", e))?;
115+
116+
let sas_bytes = sas::derive_sas(
117+
&shared_secret,
118+
&initiator_x25519_pub,
119+
&responder_x25519_pub,
120+
&normalized,
121+
);
122+
let transport_key = sas::derive_transport_key(
123+
&shared_secret,
124+
&initiator_x25519_pub,
125+
&responder_x25519_pub,
126+
&normalized,
127+
);
128+
129+
// Submit the response to the relay
130+
let submit_req = auths_core::pairing::types::SubmitResponseRequest {
131+
device_x25519_pubkey: Base64UrlEncoded::from_raw(
132+
pairing_response.device_x25519_pubkey.clone(),
133+
),
134+
device_signing_pubkey: Base64UrlEncoded::from_raw(
135+
pairing_response.device_signing_pubkey.clone(),
136+
),
137+
device_did: pairing_response.device_did.clone(),
138+
signature: Base64UrlEncoded::from_raw(pairing_response.signature.clone()),
139+
device_name: pairing_response.device_name.clone(),
140+
};
141+
142+
relay
143+
.submit_response(registry, &session_data.session_id, &submit_req)
144+
.await
145+
.map_err(|e| anyhow::anyhow!("Failed to submit response: {}", e))?;
146+
147+
create_spinner.finish_with_message(format!("{CHECK}Response submitted"));
148+
149+
// SAS verification ceremony
150+
let confirmed = prompt_sas_confirmation(&sas_bytes)?;
151+
if !confirmed {
152+
display_sas_mismatch_warning();
153+
drop(transport_key);
154+
anyhow::bail!("SAS verification failed — pairing aborted");
155+
}
156+
157+
// Wait for encrypted attestation from initiator
158+
let wait_spinner = create_wait_spinner(&format!(
159+
"{GEAR}Waiting for initiator to confirm and send attestation..."
160+
));
161+
162+
let confirmation = relay
163+
.get_confirmation(registry, &session_data.session_id)
164+
.await
165+
.map_err(|e| anyhow::anyhow!("Failed to get confirmation: {}", e))?;
166+
167+
if confirmation.aborted {
168+
wait_spinner.finish_and_clear();
169+
println!();
170+
println!(
171+
" {}{}",
172+
WARN,
173+
style("The other device rejected the pairing.").red().bold()
174+
);
175+
println!(" {}", style("No attestation was created.").dim());
176+
println!();
177+
drop(transport_key);
178+
anyhow::bail!("Initiator rejected SAS — pairing aborted");
179+
}
180+
181+
if let Some(encrypted) = confirmation.encrypted_attestation {
182+
let ciphertext = base64::Engine::decode(
183+
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
184+
&encrypted,
185+
)
186+
.context("Invalid base64 in encrypted attestation")?;
187+
188+
let _attestation_json = sas::decrypt_from_transport(&ciphertext, transport_key.as_bytes())
189+
.map_err(|e| anyhow::anyhow!("Failed to decrypt attestation: {}", e))?;
190+
191+
wait_spinner.finish_with_message(format!("{CHECK}Attestation received and decrypted"));
192+
193+
// TODO(fn-43.6): verify and store attestation locally
194+
} else {
195+
wait_spinner.finish_and_clear();
196+
println!();
197+
println!(
198+
" {}{}",
199+
WARN,
200+
style("No attestation received from initiator.").yellow()
201+
);
202+
println!();
90203
}
91204

92205
println!();
93206
println!(
94207
"{}",
95-
style(format!("━━━ {CHECK}Response Submitted ━━━"))
208+
style(format!("━━━ {CHECK}Pairing Complete ━━━"))
96209
.green()
97210
.bold()
98211
);
99212
println!();
100-
println!(
101-
" {}",
102-
style("The initiating device will verify the response and create").dim()
103-
);
104-
println!(" {}", style("a device attestation for this device.").dim());
105-
println!();
106213

107214
Ok(())
108215
}

0 commit comments

Comments
 (0)