From 6b8a47dfd445e49ddda38266dd3ba37c03be18b9 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Fri, 3 Apr 2026 09:36:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20SAS=20code=20entry=20=E2=80=94=20target?= =?UTF-8?q?=20enters=20code=20instead=20of=20visual=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace "Codes match? (Y/n)" with code entry: controller displays the 6-digit SAS, target operator types it. Constant-time comparison catches mismatches automatically, eliminating rubber-stamp risk. One-sided verification on the target is sufficient — its allow list is the security-critical one. --- README.md | 4 +- docs/architecture-decisions.md | 2 +- docs/guide.md | 2 +- docs/protocol-spec.md | 42 +++++++++++++------ docs/self-hosting.md | 2 +- landpage/src/routes/+page.svelte | 4 +- .../src/routes/docs/self-hosting/+page.svelte | 2 +- packages/cli/README.md | 8 ++-- packages/cli/src/commands/invite.ts | 22 +--------- packages/cli/src/commands/listen.ts | 27 ++++++++---- packages/cli/src/handshake.ts | 13 ++++++ .../__tests__/handshake.integration.test.ts | 25 +++++++++++ 12 files changed, 101 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 2a9dc2c..e7cea87 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ On the target (server): ```bash amesh listen # Pairing code: 482916 +# Enter the 6-digit code shown on the Controller. +# Verification code: 847291 # ✔ "my-laptop" added as controller. ``` @@ -63,7 +65,7 @@ On the controller (your laptop): ```bash amesh invite 482916 # Verification code: 847291 -# Codes match? (Y/n): y +# Enter this code on the Target device. # ✔ "prod-api" added as target. ``` diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 1eaa05b..8ef30b5 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -60,7 +60,7 @@ Note: The encrypted-file fallback (Tier 3) is available as an explicit opt-in (` SAS = truncate(SHA-256(targetPubKey || controllerPubKey || sharedECDHSecret), 6 digits) ``` -Both CLIs display this number; the developer confirms they match. Same approach as Signal, Matrix, Bluetooth Secure Simple Pairing. Skippable with `--no-verify` for headless/automated pairing. +The controller CLI displays this code; the target CLI prompts the operator to enter it. The target verifies the entered code using constant-time comparison — a mismatch aborts pairing automatically, eliminating the risk of a distracted operator rubber-stamping a visual comparison. One-sided verification on the target is sufficient because the target's allow list is the security-critical one (it controls who may authenticate). Same cryptographic approach as Signal, Matrix, Bluetooth Secure Simple Pairing. --- diff --git a/docs/guide.md b/docs/guide.md index e06819c..def3696 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -246,7 +246,7 @@ amesh invite 482916 # ✔ "prod-api" added as target. ``` -Both sides display a verification code — confirm they match. After that: +The controller displays a 6-digit verification code — enter it on the target to confirm the pairing. After that: - The target's allow list has the controller's key with role `controller` (accepts auth from it) - The controller's allow list has the target's key with role `target` (cannot auth from it) diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index a409f89..db9fb97 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -230,7 +230,7 @@ TARGET RELAY CONTROLLER |-- (8) { pubKey, friendlyName, timestamp, selfSig } ---------->| | | | | | | - | <======= (9) SAS Verification (both display 6-digit code) ========> | + | <======= (9) SAS Verification (controller displays, target enters) ========> | | | | |-- (10) Disconnect ----------->|<- (10) Disconnect ------------| | | | @@ -271,13 +271,13 @@ Each side sends: `selfSig` is an ECDSA-P256-SHA256 signature over `(publicKey + friendlyName + timestamp)` made with the **permanent** private key. This proves the sender controls the private key corresponding to the public key they're presenting. **Step 9 — SAS Verification (Short Authentication String):** -After both sides have exchanged permanent keys, each CLI computes and displays a 6-digit verification code: +After both sides have exchanged permanent keys, each CLI computes a 6-digit verification code: ``` SAS = truncate(SHA-256(targetPubKey || controllerPubKey || sharedECDHSecret), 6 digits) ``` -Both CLIs display this number. The developer confirms they match on both terminals. In a MITM scenario (relay performing separate ECDH with each side), the shared secrets differ, so SAS values won't match. This is the same approach used by Signal, Matrix, and Bluetooth Secure Simple Pairing. +The **controller** displays the code. The **target** prompts the operator to enter the code shown on the controller's screen. The target verifies the entered code against its own computed SAS using a constant-time comparison. If they match, pairing proceeds. If they differ (indicating a MITM), pairing is aborted automatically. -SAS is displayed by default. Skippable with `--no-verify` flag for automated/headless pairing. +This "code entry" approach (vs. visual comparison) eliminates the risk of a distracted operator rubber-stamping a mismatch. One-sided verification on the target is sufficient because the target's allow list is the security-critical one — it controls who may authenticate. Same cryptographic principle as Signal, Matrix, and Bluetooth Secure Simple Pairing. > **Why SAS in addition to selfSig:** The `selfSig` alone does not prevent a relay MITM that performs separate ECDH with each side and substitutes its own permanent key with a valid selfSig. The SAS catches this because the ECDH shared secrets differ. @@ -309,20 +309,38 @@ $ amesh listen ✔ Keys exchanged and verified. ┌──────────────────────────────────┐ - │ Verification code: 847291 │ - │ Confirm this matches the │ - │ Controller's display. │ + │ Enter the 6-digit code shown │ + │ on the Controller's screen. │ └──────────────────────────────────┘ -? Codes match? (Y/n): y + Verification code: 847291 -✔ "MacBook Pro — dev" added to allow list. +✔ "MacBook Pro — dev" added as controller. - Device ID : am_1a2b3c4d5e6f7a8b - You can now use amesh signing. The relay connection is closed. ``` +### CLI output (Controller side) +``` +$ amesh invite 482916 + + Connecting to relay with code 482916... + +✔ Peer found. +✔ Ephemeral P-256 ECDH tunnel established. +✔ Keys exchanged and verified. + + ┌──────────────────────────────────┐ + │ Verification code: 847291 │ + │ Enter this code on the Target │ + │ device to complete pairing. │ + └──────────────────────────────────┘ + +✔ "prod-api-us-east-1" added as target. + + Pairing complete. The relay connection is closed. +``` + --- ## 7. Phase 3 — The Wire Protocol (Signing) @@ -728,7 +746,7 @@ The relay could theoretically swap ephemeral public keys during Step 5 to perfor 1. **`selfSig`** (Step 7/8): Proves each side controls the private key corresponding to the public key they present. A relay doing MITM cannot forge a `selfSig` for a key it doesn't control. -2. **SAS Verification** (Step 9): Even if the relay performs separate ECDH with each side and substitutes its own permanent key with a valid `selfSig`, the SAS codes will differ because the ECDH shared secrets differ. This is cryptographic proof of no MITM — not reliant on the developer recognizing an unfamiliar device name. Same approach as Signal, Matrix, and Bluetooth Secure Simple Pairing. +2. **SAS Verification** (Step 9): Even if the relay performs separate ECDH with each side and substitutes its own permanent key with a valid `selfSig`, the SAS codes will differ because the ECDH shared secrets differ. The target operator enters the code displayed on the controller — a mismatch is caught automatically via constant-time comparison, eliminating human error. Same cryptographic approach as Signal, Matrix, and Bluetooth Secure Simple Pairing. ### One-Way Trust Directionality Trust between devices is **one-directional** by default. A controller can authenticate to a target, but the target cannot authenticate back to the controller. This limits the blast radius of a compromised target — even if an attacker gains control of the server, they cannot use its amesh identity to authenticate to the controller. The `role` field in each allow list entry is HMAC-sealed, so an attacker cannot flip a `"target"` role to `"controller"` without invalidating the HMAC. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index ba366a5..8b5cd83 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -238,7 +238,7 @@ The relay has no other configuration. It is stateless and requires no database, The relay is designed to be untrusted: - **All key exchange is encrypted** — the relay forwards opaque ChaCha20-Poly1305 blobs, it cannot read the content -- **SAS verification prevents MITM** — even if someone controls the relay, both devices display a 6-digit code that must match. A MITM attack would produce different codes. +- **SAS verification prevents MITM** — even if someone controls the relay, the target must enter the 6-digit code displayed on the controller. A MITM attack produces different codes, and the mismatch is caught automatically. - **Rate limiting** — 5 failed OTC attempts per IP per minute - **No persistence** — nothing is stored. Sessions exist only in memory during the ~30 second pairing window. diff --git a/landpage/src/routes/+page.svelte b/landpage/src/routes/+page.svelte index 3f4cf34..5d2ef9b 100644 --- a/landpage/src/routes/+page.svelte +++ b/landpage/src/routes/+page.svelte @@ -41,7 +41,7 @@ { n: '2', title: 'Pair two machines', desc: 'The server runs amesh listen, your laptop runs amesh invite. Trust is one-way: your laptop controls the server, not the other way around.', - code: `$ amesh listen\n\n Pairing code: 482916\n\n Controller connected.\n Verification code: 847291\n Codes match? (Y/n): y\n "Dev Laptop" added as controller.` + code: `$ amesh listen\n\n Pairing code: 482916\n\n Controller connected.\n Enter the 6-digit code shown on the Controller.\n Verification code: 847291\n "Dev Laptop" added as controller.` }, { n: '3', title: 'Sign requests — 2 lines', @@ -84,7 +84,7 @@ }, { label: 'Pairing', - code: `$ amesh invite 482916\n\n Connecting to relay with code 482916...\n\n Peer found.\n Verification code: 847291\n Codes match? (Y/n): y\n "prod-api" added as target.` + code: `$ amesh invite 482916\n\n Connecting to relay with code 482916...\n\n Peer found.\n Verification code: 847291\n Enter this code on the Target device.\n "prod-api" added as target.` }, { label: 'Init', diff --git a/landpage/src/routes/docs/self-hosting/+page.svelte b/landpage/src/routes/docs/self-hosting/+page.svelte index 947d008..ad26a74 100644 --- a/landpage/src/routes/docs/self-hosting/+page.svelte +++ b/landpage/src/routes/docs/self-hosting/+page.svelte @@ -158,7 +158,7 @@ gcloud run deploy amesh-relay \\
SAS prevents MITM
-
Even if someone controls the relay, both devices display a 6-digit code. A MITM attack produces different codes.
+
Even if someone controls the relay, the target must enter a 6-digit code from the controller. A MITM attack produces different codes — caught automatically.
Rate limiting
diff --git a/packages/cli/README.md b/packages/cli/README.md index 6ac8ebf..756b62c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -27,9 +27,9 @@ $ amesh listen Pairing code: 482916 Controller connected. + Enter the 6-digit code shown on the Controller. Verification code: 847291 - Codes match? (Y/n): y - "Dev Laptop" added to allow list. + "Dev Laptop" added as controller. ``` On the controller: @@ -37,8 +37,8 @@ On the controller: $ amesh invite 482916 Connected to relay. Verification code: 847291 - Codes match? (Y/n): y - "prod-api" added to allow list. + Enter this code on the Target device. + "prod-api" added as target. ``` ## Environment variables diff --git a/packages/cli/src/commands/invite.ts b/packages/cli/src/commands/invite.ts index 461ef7f..53b618d 100644 --- a/packages/cli/src/commands/invite.ts +++ b/packages/cli/src/commands/invite.ts @@ -1,8 +1,6 @@ import { Command, Args, Flags } from '@oclif/core'; import { loadContext } from '../context.js'; import { runControllerHandshake } from '../handshake.js'; -import { createInterface } from 'node:readline'; - const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; export default class Invite extends Command { @@ -61,18 +59,11 @@ export default class Invite extends Command { this.log(''); this.log(' ┌──────────────────────────────────┐'); this.log(` │ Verification code: ${result.sas} │`); - this.log(' │ Confirm this matches the │'); - this.log(" │ Target's display. │"); + this.log(' │ Enter this code on the Target │'); + this.log(' │ device to complete pairing. │'); this.log(' └──────────────────────────────────┘'); this.log(''); - const confirmed = await this.confirm(' Codes match? (Y/n): '); - if (!confirmed) { - this.log(''); - this.log(' Pairing cancelled. No changes made.'); - return; - } - await allowList.addDevice({ deviceId: `am_${Buffer.from(result.peerPublicKey).toString('base64url').slice(0, 16)}`, publicKey: Buffer.from(result.peerPublicKey).toString('base64'), @@ -89,13 +80,4 @@ export default class Invite extends Command { this.log(''); } - private confirm(prompt: string): Promise { - return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - rl.question(prompt, (answer) => { - rl.close(); - resolve(answer.trim().toLowerCase() !== 'n'); - }); - }); - } } diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index 3e04334..294801a 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -1,6 +1,6 @@ import { Command, Flags } from '@oclif/core'; import { loadContext } from '../context.js'; -import { generateOTC, runTargetHandshake } from '../handshake.js'; +import { generateOTC, runTargetHandshake, verifySAS } from '../handshake.js'; import { createInterface } from 'node:readline'; const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; @@ -59,16 +59,15 @@ export default class Listen extends Command { this.log(' Keys exchanged and verified.'); this.log(''); this.log(' ┌──────────────────────────────────┐'); - this.log(` │ Verification code: ${result.sas} │`); - this.log(' │ Confirm this matches the │'); - this.log(" │ Controller's display. │"); + this.log(' │ Enter the 6-digit code shown │'); + this.log(" │ on the Controller's screen. │"); this.log(' └──────────────────────────────────┘'); this.log(''); - const confirmed = await this.confirm(' Codes match? (Y/n): '); - if (!confirmed) { + const entered = await this.prompt(' Verification code: '); + if (!verifySAS(entered.trim(), result.sas)) { this.log(''); - this.log(' Pairing cancelled. No changes made.'); + this.log(' Code mismatch — possible MITM. Pairing aborted.'); return; } @@ -105,10 +104,20 @@ export default class Listen extends Command { this.log(''); } - private confirm(prompt: string): Promise { + private prompt(message: string): Promise { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout }); - rl.question(prompt, (answer) => { + rl.question(message, (answer) => { + rl.close(); + resolve(answer); + }); + }); + } + + private confirm(message: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, (answer) => { rl.close(); resolve(answer.trim().toLowerCase() !== 'n'); }); diff --git a/packages/cli/src/handshake.ts b/packages/cli/src/handshake.ts index 3f03445..13e1981 100644 --- a/packages/cli/src/handshake.ts +++ b/packages/cli/src/handshake.ts @@ -124,6 +124,19 @@ function verifySelfSig(peer: PeerIdentity): boolean { return verifyMessage(sig, message, publicKey); } +/** + * Constant-time comparison for SAS codes. + * Prevents timing side-channels during code entry verification. + */ +export function verifySAS(entered: string, computed: string): boolean { + if (entered.length !== computed.length) return false; + let diff = 0; + for (let i = 0; i < entered.length; i++) { + diff |= entered.charCodeAt(i) ^ computed.charCodeAt(i); + } + return diff === 0; +} + export interface HandshakeResult { peerPublicKey: Uint8Array; peerFriendlyName: string; diff --git a/packages/relay/src/__tests__/handshake.integration.test.ts b/packages/relay/src/__tests__/handshake.integration.test.ts index 2cf5dfd..3c7ee37 100644 --- a/packages/relay/src/__tests__/handshake.integration.test.ts +++ b/packages/relay/src/__tests__/handshake.integration.test.ts @@ -6,6 +6,7 @@ import { runTargetHandshake, runControllerHandshake, computeSAS, + verifySAS, } from '../../../cli/src/handshake.js'; let relay: ReturnType; @@ -119,3 +120,27 @@ describe('SAS computation', () => { expect(computeSAS(a, b, secret)).not.toBe(computeSAS(b, a, secret)); }); }); + +describe('verifySAS (constant-time code entry)', () => { + it('accepts matching codes', () => { + expect(verifySAS('847291', '847291')).toBe(true); + }); + + it('rejects mismatched codes', () => { + expect(verifySAS('847291', '123456')).toBe(false); + }); + + it('rejects wrong length', () => { + expect(verifySAS('12345', '123456')).toBe(false); + expect(verifySAS('1234567', '123456')).toBe(false); + }); + + it('rejects empty input', () => { + expect(verifySAS('', '123456')).toBe(false); + }); + + it('accepts codes with leading zeros', () => { + expect(verifySAS('000001', '000001')).toBe(true); + expect(verifySAS('000001', '000002')).toBe(false); + }); +});