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
2 changes: 2 additions & 0 deletions docs/architecture/remote-e2ee-openmls.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ Limits and lifecycle:
| KeyPackage | 16 KiB | 10 minutes, reserved atomically for 60 seconds, consumed once by the daemon |
| Welcome | 16 KiB | 10 minutes, byte-identical retry until device activation acknowledgement |

The daemon's and the device's clocks may disagree by up to two minutes. The device starts its KeyPackage lifetime two minutes in the past, as OpenMLS's own default lifetimes do, and still ends it ten minutes from now, so a daemon whose clock is behind accepts it. The daemon accepts that twelve-minute span and the exact ten-minute span of devices built before it. The device accepts an invitation issued up to two minutes after its own clock reads. Each side's checks against its own clock stay exact: the daemon's invitation expiry and rollback detection, and every durable clock-rollback check.

A KeyPackage reservation binds account, installation, device, crypto session, profile ID, profile revision, credential fingerprint, and KeyPackage hash. One concurrent reservation wins. Failed group creation releases the reservation only while it remains live and no Welcome exists. Successful group creation consumes it permanently. A consumed, expired, malformed, wrong-profile, or wrong-credential KeyPackage is deleted and cannot be retried.

The daemon creates a fresh group ID, adds the reserved KeyPackage, persists the group transition and exact Welcome bytes atomically, and only then publishes the Welcome. The device validates the complete pairing transcript, profile, group ID, daemon credential, member count of two, and its own leaf before persisting the joined state. It returns an MLS-protected activation acknowledgement. The control plane deletes the Welcome after that acknowledgement or expiry. Expiry or ambiguous state requires a new invitation and KeyPackage. KeyPackages and Welcomes never authorize daemon scopes.
Expand Down
18 changes: 10 additions & 8 deletions docs/architecture/remote-hosted-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

# Remote hosted-path checkpoint

Status: real E2EE integration adapters implemented with local test stores; production startup disabled
Status: checkpoint record from the Session 60 integration. The production path it led to is described in [Production remote access from a WSL daemon](remote-production-wsl.md), [from macOS and Linux desktop daemons](remote-production-desktop.md), and [Pair once, share per session](remote-session-sharing.md). The gates below record how each one was settled.

## Scope

Expand Down Expand Up @@ -88,12 +88,14 @@ Every sealed envelope costs a native endpoint transaction, a witness round, and
- **Superseded grants.** An application envelope sealed under an older hosted grant generation is never opened or executed. The bridge answers it once per operation with `daemon_rejected` (`stale_grant_generation`), sealed under the current generation and sent to the frame's source route without moving the reply route. The SDK removes the matching record from the outbox and reports the request as failed, instead of retrying ciphertext that can never be accepted. The bridge remembers the last 256 rejected operations; each rejection is still bounded by the sender's relay frame budget. An envelope from a generation ahead of the daemon's is refused without a reply.
- **Route following.** The daemon follows the relay's route view for the paired device. A device that reconnects receives deliveries on its new route before it sends anything. When the device route disappears, the bridge keeps the last one and sends fail at the relay until the device returns.

## Remaining gates
## Gates

Before production remote control:
This checkpoint listed five gates before production remote control. Where they stand:

- Person 1 must provide the reviewed OpenMLS prepared-envelope transaction and browser/WASM persistence strategy.
- MLS application, Update, commit, and epoch-ready delivery classes need an ordered priority contract.
- Production identity, datastore, workload authentication, quotas, deployment, and TLS termination must be selected.
- Permission lifecycle, action-digest binding, policy generations, and race resolution must be implemented and reviewed.
- Ordinary-session remote exposure must receive an explicit enablement review.
- **Browser persistence:** settled. The worker barrier and the IndexedDB strategy in [Endpoint E2EE platform bindings](e2ee-platform-bindings.md) are implemented, including one tab per pairing: a newer tab takes the endpoint's lifetime lock from an older one, and the older endpoint fails closed.
- **Delivery-class priority:** still open as a written contract. The daemon delivery shape above orders replies after the deliveries that preceded them.
- **Production identity, datastore, and deployment:** settled. Accounts sign in through Cognito, the daemon admits its relay connections with its installation key, tickets, the witness, and its journal live in DynamoDB, and the stack serves TLS on its own domain.
- **Permission lifecycle:** not needed for the first release. The phone answers only the agent's questions; it never approves tool actions.
- **Ordinary-session exposure:** settled by sharing per session. The phone reaches only the sessions where `/remote` ran, a share ends after 24 hours idle, and remote access stays behind the per-account opt-in.

The gates that remain are the evidence runs and the independent security review in the WSL and desktop documents.
4 changes: 4 additions & 0 deletions docs/architecture/remote-production-wsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ Remote access is opt-in per person: the control plane accepts a token only when

The control plane never accepts a static account token in production.

One daemon at a time serves remote access for an installation. A user can run several daemons, for example `axl` and `axl --unsafe`, each with its own state directory and so its own pairing, but they share the installation and its relay identity; two connected at once would replace each other's relay route without end and neither phone would get through. The daemon serving remote access holds a claim file in `~/.axl/remote/`, which names its process and state directory. Another daemon's `/remote` is refused with that name, and its restore waits until the claim is released or its owner has exited.

## Production control plane

A production runtime replaces the deployment-test runtime's single configured account:
Expand All @@ -107,6 +109,8 @@ It replaces the deployment-test runtime on the existing stack (`infra/aws/hosted

The witness keeps its current shape: three replicas inside the control plane, each with its own Ed25519 signing key and its own DynamoDB records and high-water journal, recovering lineage by lineage after a restart. The gateway requires all three receipts.

A restart can find a lineage split: the process stopped after some replicas stored a step and before the rest did. No certificate was issued for that step, and the endpoint still holds it pending. Recovery needs all three replicas to agree, so before it runs, the gateway brings the replicas left behind forward by that same endpoint-signed step. A replica takes it only for a lineage it has not recovered, only as a direct successor of its own head, and only when fresh heads that both other replicas signed for one endpoint-signed read each sit at its head or at that successor. A registration that some replicas missed is completed as an ordinary registration, since they never held the lineage. Anything else, such as two replicas holding different successors, is left unrecovered and the endpoint fails closed. No replica moves backward and every head is one the endpoint signed, so rollback detection is unchanged: an endpoint restored from before its pending step proposes a conflicting successor and is caught.

This does not meet the RFC's "independently administered" requirement. One account's credentials can reach all three replicas, so they protect against a lost or rolled-back table, not against a compromised operator account. Spreading the replicas over regions of the same account was considered and rejected for now: it would add two cross-region round trips to every message and make any one region an outage for all of remote access, while buying no independence. Three replicas in separately administered accounts, each its own service, are a prerequisite for widening the opt-in beyond the owner.

Replica trust (the three public verification keys) is pinned into the production daemon and browser builds, never taken from a link or a server response.
Expand Down
7 changes: 7 additions & 0 deletions packages/e2ee/bindings/browser/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,5 +164,12 @@ export declare function createDeviceEndpoint(
export declare function openDeviceEndpoint(session: {
readonly cryptoSessionId: Uint8Array;
}): Promise<DeviceEndpoint>;
/**
* Take a crypto session's endpoint from another tab of this profile that does not let go; that
* tab's endpoint fails closed as for any lost lock.
*/
export declare function takeOverDeviceEndpoint(session: {
readonly cryptoSessionId: Uint8Array;
}): Promise<void>;
/** Permanently closes this module instance. Repeated close calls are harmless. */
export declare function closeBrowserBinding(): void;
20 changes: 20 additions & 0 deletions packages/e2ee/bindings/browser/loader/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,26 @@ export const openDeviceEndpoint = ({ cryptoSessionId } = {}) =>
nullResult(value);
return deviceEndpointHandle();
});
/**
* Take a crypto session's endpoint from another tab or window of this browser profile that holds
* it and does not let go, such as a background tab the browser suspended. The worker there loses
* the session's lifetime lock, so its endpoint fails closed and later calls are refused, exactly as
* for any lost lock; committed state is not touched. Resolves once the lock is free, after which
* `openDeviceEndpoint` can take it.
*/
export const takeOverDeviceEndpoint = async ({ cryptoSessionId } = {}) => {
if (!(cryptoSessionId instanceof Uint8Array) || cryptoSessionId.byteLength !== 16) {
throw new AxlE2eeError("invalid_argument");
}
if (!globalThis.navigator?.locks) throw new AxlE2eeError("storage_unavailable");
// The name the worker's store holds for the endpoint's lifetime (worker/storage.js).
const sessionHex = [...cryptoSessionId].map((byte) => byte.toString(16).padStart(2, "0")).join("");
await globalThis.navigator.locks.request(
`axl-e2ee-v1:${sessionHex}`,
{ mode: "exclusive", steal: true },
() => undefined,
);
};
export const closeBrowserBinding = () => {
if (state === "closed") return;
state = "closed";
Expand Down
1 change: 1 addition & 0 deletions packages/e2ee/bindings/browser/scripts/check-abi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ const valueExports = [
"inspectPairingInvitation",
"openDaemonEndpoint",
"openDeviceEndpoint",
"takeOverDeviceEndpoint",
].sort();
const declared = [...declarations.matchAll(/^export declare (?:class|const|function) ([A-Za-z0-9_]+)/gmu)]
.map((match) => match[1])
Expand Down
4 changes: 3 additions & 1 deletion packages/e2ee/bindings/browser/test/barrier-scenario.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ function createOptions(peer, witness, operationId) {

export async function runDeviceBarrierScenario() {
const witness = new TestWitness();
const peer = new TestPeerDaemonFixture(0x91, Date.now());
// The peer daemon's clock runs behind this device's, as a laptop's can after sleep. A KeyPackage
// must still be accepted (this was the intermittent join failure on slow CI browsers).
const peer = new TestPeerDaemonFixture(0x91, Date.now() - 1_500);
const session = peer.crypto_session_id();
const databaseName = `axl-e2ee-production-v1:${hex(session)}`;
const lockName = `axl-e2ee-v1:${hex(session)}`;
Expand Down
45 changes: 43 additions & 2 deletions packages/e2ee/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub const SUITE_VALUE: u16 = 0x004e;
pub const HANDSHAKE_MAX_BYTES: usize = 16 * 1024;
/// KeyPackage lifetime fixed by the pairing profile.
pub const KEY_PACKAGE_LIFETIME_SECONDS: u64 = 10 * 60;
/// How far a daemon's and a device's clocks may disagree during pairing. Each side allows it
/// only for the other side's timestamps; its checks against its own clock stay exact.
pub const PAIRING_CLOCK_SKEW_SECONDS: u64 = 2 * 60;
/// Maximum authenticated-data length.
pub const AAD_MAX_BYTES: usize = 512;
/// Maximum application plaintext length.
Expand Down Expand Up @@ -1350,12 +1353,16 @@ impl Phone {
CoreProvider::new().map_err(|_| Error::Crypto("provider initialization failed"))?;
ensure_suite(&provider)?;
let (credential, signer) = make_credential(&provider, &identity)?;
// Starting the lifetime a little in the past lets a daemon whose clock is behind this
// device's accept the KeyPackage, as OpenMLS's own default lifetimes do. It still ends
// the full lifetime from now.
let now_seconds = now_ms / 1_000;
let not_before = now_seconds.saturating_sub(PAIRING_CLOCK_SKEW_SECONDS);
let not_after = now_seconds
.checked_add(KEY_PACKAGE_LIFETIME_SECONDS)
.ok_or(Error::ClockRollback)?;
let bundle = KeyPackage::builder()
.key_package_lifetime(Lifetime::init(now_seconds, not_after))
.key_package_lifetime(Lifetime::init(not_before, not_after))
.build(SUITE, &provider, &signer, credential)
.map_err(|_| Error::Crypto("KeyPackage creation failed"))?;
let bytes = bundle
Expand Down Expand Up @@ -1787,7 +1794,10 @@ fn validate_phone_key_package(
}
let lifetime = package.life_time();
let now_seconds = now_ms / 1_000;
if lifetime.not_after().checked_sub(lifetime.not_before()) != Some(KEY_PACKAGE_LIFETIME_SECONDS)
// The lifetime starts the clock skew early; devices built before that started it now.
let span = lifetime.not_after().checked_sub(lifetime.not_before());
if (span != Some(KEY_PACKAGE_LIFETIME_SECONDS + PAIRING_CLOCK_SKEW_SECONDS)
&& span != Some(KEY_PACKAGE_LIFETIME_SECONDS))
|| now_seconds < lifetime.not_before()
|| now_seconds >= lifetime.not_after()
{
Expand Down Expand Up @@ -1942,6 +1952,37 @@ impl<'a> Cursor<'a> {
mod tests {
use super::*;

/// A daemon consuming the KeyPackage of a phone whose clock is `ahead_ms` ahead of its own.
fn consume_from_phone_ahead_by(ahead_ms: u64) -> Result<PairWelcome, Error> {
let context = PairContext {
crypto_session_id: [21; 16],
group_id: [22; 32],
account_id: [23; 16],
installation_id: [24; 16],
device_id: [25; 16],
};
let phone_identity = Identity::device(
context.account_id,
context.installation_id,
context.device_id,
)?;
let now_ms = SystemClock.now_ms()?;
let (_, package) = Phone::create_at(phone_identity, now_ms + ahead_ms)?;
let mut daemon = Daemon::create(
Identity::daemon(context.account_id, context.installation_id),
context,
)?;
daemon.consume_key_package(package)
}

#[test]
fn a_daemon_accepts_a_phone_clock_ahead_by_up_to_the_pairing_skew() {
consume_from_phone_ahead_by(0).unwrap();
consume_from_phone_ahead_by(1_500).unwrap();
consume_from_phone_ahead_by((PAIRING_CLOCK_SKEW_SECONDS - 5) * 1_000).unwrap();
assert!(consume_from_phone_ahead_by((PAIRING_CLOCK_SKEW_SECONDS + 5) * 1_000).is_err());
}

#[test]
fn rejects_invalid_key_package_lifetime_and_capabilities() {
let context = PairContext {
Expand Down
21 changes: 19 additions & 2 deletions packages/e2ee/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,8 +313,16 @@ impl PairingInvitation {
self.verify_at(now_ms)
}

/// A device's check of the daemon's invitation against the device's own clock, which may be
/// up to `PAIRING_CLOCK_SKEW_SECONDS` behind the daemon's. The daemon keeps its exact checks.
pub(crate) fn verify_at(&self, now_ms: u64) -> Result<(), PairingError> {
self.validate_time(now_ms)?;
self.validate_time_shape()?;
if now_ms.saturating_add(crate::PAIRING_CLOCK_SKEW_SECONDS * 1_000) < self.issued_at_ms {
return Err(PairingError::ClockRollback);
}
if now_ms >= self.expires_at_ms {
return Err(PairingError::Expired);
}
self.verify_signature()
}

Expand Down Expand Up @@ -1540,7 +1548,16 @@ mod tests {
extended.validate_time_shape(),
Err(PairingError::InvalidTime)
);
fixture.clock.set(fixture.invitation.issued_at_ms - 1);
// A device clock may be behind the daemon's by up to the pairing skew, and no further.
let skew_ms = crate::PAIRING_CLOCK_SKEW_SECONDS * 1_000;
fixture.clock.set(fixture.invitation.issued_at_ms - skew_ms);
fixture
.invitation
.verify_with_clock(fixture.clock.as_ref())
.unwrap();
fixture
.clock
.set(fixture.invitation.issued_at_ms - skew_ms - 1);
assert_eq!(
fixture.invitation.verify_with_clock(fixture.clock.as_ref()),
Err(PairingError::ClockRollback)
Expand Down
Loading
Loading