Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,16 @@ 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.
```

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.
```

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
42 changes: 30 additions & 12 deletions docs/protocol-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------|
| | |
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions landpage/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<span class="text-zinc-500">$</span> amesh listen\n\n Pairing code: <span class="text-emerald-400">482916</span>\n\n<span class="text-emerald-400">✔</span> Controller connected.\n Verification code: <span class="text-emerald-400">847291</span>\n Codes match? (Y/n): y\n<span class="text-emerald-400">✔</span> "Dev Laptop" added as controller.`
code: `<span class="text-zinc-500">$</span> amesh listen\n\n Pairing code: <span class="text-emerald-400">482916</span>\n\n<span class="text-emerald-400">✔</span> Controller connected.\n Enter the 6-digit code shown on the Controller.\n Verification code: <span class="text-emerald-400">847291</span>\n<span class="text-emerald-400">✔</span> "Dev Laptop" added as controller.`
},
{
n: '3', title: 'Sign requests — 2 lines',
Expand Down Expand Up @@ -84,7 +84,7 @@
},
{
label: 'Pairing',
code: `<span class="text-zinc-500">$</span> <span class="text-zinc-50">amesh invite 482916</span>\n\n Connecting to relay with code 482916...\n\n<span class="text-emerald-400">✔</span> Peer found.\n Verification code: <span class="text-emerald-400">847291</span>\n Codes match? (Y/n): y\n<span class="text-emerald-400">✔</span> "prod-api" added as target.`
code: `<span class="text-zinc-500">$</span> <span class="text-zinc-50">amesh invite 482916</span>\n\n Connecting to relay with code 482916...\n\n<span class="text-emerald-400">✔</span> Peer found.\n Verification code: <span class="text-emerald-400">847291</span>\n Enter this code on the Target device.\n<span class="text-emerald-400">✔</span> "prod-api" added as target.`
},
{
label: 'Init',
Expand Down
2 changes: 1 addition & 1 deletion landpage/src/routes/docs/self-hosting/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ gcloud run deploy amesh-relay \\
</div>
<div class="border-l-2 border-emerald-400/60 pl-4 py-1">
<div class="text-sm font-semibold text-zinc-50">SAS prevents MITM</div>
<div class="mt-1 text-sm text-zinc-400">Even if someone controls the relay, both devices display a 6-digit code. A MITM attack produces different codes.</div>
<div class="mt-1 text-sm text-zinc-400">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.</div>
</div>
<div class="border-l-2 border-emerald-400/60 pl-4 py-1">
<div class="text-sm font-semibold text-zinc-50">Rate limiting</div>
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,18 @@ $ 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:
```bash
$ 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
Expand Down
22 changes: 2 additions & 20 deletions packages/cli/src/commands/invite.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'),
Expand All @@ -89,13 +80,4 @@ export default class Invite extends Command {
this.log('');
}

private confirm(prompt: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() !== 'n');
});
});
}
}
27 changes: 18 additions & 9 deletions packages/cli/src/commands/listen.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -105,10 +104,20 @@ export default class Listen extends Command {
this.log('');
}

private confirm(prompt: string): Promise<boolean> {
private prompt(message: string): Promise<string> {
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<boolean> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(message, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() !== 'n');
});
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions packages/relay/src/__tests__/handshake.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
runTargetHandshake,
runControllerHandshake,
computeSAS,
verifySAS,
} from '../../../cli/src/handshake.js';

let relay: ReturnType<typeof createRelayServer>;
Expand Down Expand Up @@ -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);
});
});
Loading