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
75 changes: 51 additions & 24 deletions apps/ade-cli/src/services/sync/syncAccountHelloAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ import { evaluatePairedHelloDpop, syncDpopFailureMessage, type SyncDpopNonceCach
export const SYNC_REPAIR_REQUIRED_MESSAGE = "This device is not paired with this machine, or its saved"
+ " pairing is no longer valid. Pair it again.";

export const SYNC_ACCOUNT_SESSION_CHANGED_MESSAGE = "The ADE account session on this machine changed"
+ " while connecting. Try again.";
export const SYNC_ACCOUNT_SESSION_CHANGED_MESSAGE = "The ADE account session on the computer you're"
+ " connecting to changed while connecting. Try again.";

export const SYNC_ACCOUNT_VERIFY_UNAVAILABLE_MESSAGE = "This machine cannot verify ADE accounts."
+ " Update ADE on this computer, then try again.";
export const SYNC_ACCOUNT_VERIFY_UNAVAILABLE_MESSAGE = "The computer you're connecting to cannot verify"
+ " ADE accounts. Update ADE there, then try again.";

export const SYNC_ACCOUNT_NOT_SIGNED_IN_MESSAGE = "This machine is not signed in to an ADE account."
+ " Sign in on this computer, then try again.";
export const SYNC_ACCOUNT_NOT_SIGNED_IN_MESSAGE = "The computer you're connecting to is not signed in"
+ " to an ADE account. Sign in on that computer, then try again.";

export const SYNC_ACCOUNT_DEVICE_MISMATCH_MESSAGE = "The account identity in this connection did not"
+ " match the device that sent it.";
Expand All @@ -48,11 +48,15 @@ export const SYNC_ACCOUNT_KEYLESS_RECORD_MESSAGE = "This device's saved pairing
export const SYNC_ACCOUNT_OTHER_OWNER_MESSAGE = "This device is already paired to this machine under"
+ " a different ADE account.";

export const SYNC_ACCOUNT_PAIRING_WRITE_FAILED_MESSAGE = "This machine could not save the new pairing"
+ " for this device. Try again.";
export const SYNC_ACCOUNT_PAIRING_WRITE_FAILED_MESSAGE = "The computer you're connecting to could not"
+ " save the new pairing for this device. Try again.";

export const SYNC_ACCOUNT_VERIFY_FAILED_MESSAGE = "This machine could not verify your ADE account"
+ " session. Sign out and back in on this device, then try again.";
export const SYNC_ACCOUNT_COMMIT_FAILED_MESSAGE = "The computer you're connecting to could not"
+ " finish saving the account pairing. Try again.";

export const SYNC_ACCOUNT_VERIFY_FAILED_MESSAGE = "The computer you're connecting to could not verify"
+ " its ADE account session. Open ADE there and check that it is signed in to the same ADE account,"
+ " then try again.";

export type SyncAccountHelloAuth = Extract<SyncHelloPayload["auth"], { kind: "account" }>;

Expand Down Expand Up @@ -111,8 +115,9 @@ export type SyncAccountHelloAuthOptions = {
/** "PIN" on the project host, "code" on the brain — same instruction, local wording. */
pairingCodeNoun: string;
/**
* Code carried when this machine holds no account session at all. The brain
* answers `relay_account_required` because its only account route is Relay.
* Code carried when this machine holds no account session at all. The project
* host uses `account_not_signed_in`; the brain answers `relay_account_required`
* because its only account route is Relay.
*/
notSignedInCode: SyncHelloErrorPayload["code"];
};
Expand Down Expand Up @@ -164,11 +169,22 @@ export async function authenticateSyncAccountHello(
// `auth_failed` here reads as "pair it again" on every client.
return reject(SYNC_ACCOUNT_VERIFY_UNAVAILABLE_MESSAGE, "host_update_required");
}
const attestation = await options.verifyAccountAttestation({
token: auth.accountToken,
expectedUserId: authorization.userId,
config,
});
let attestation: VerifiedAccountAttestation;
try {
attestation = await options.verifyAccountAttestation({
token: auth.accountToken,
expectedUserId: authorization.userId,
config,
});
} catch (error) {
logger.warn(`${logPrefix}.account_attestation_rejected`, {
deviceId: auth.deviceId,
reason: typeof (error as { code?: unknown } | null)?.code === "string"
? (error as { code: string }).code
: "verification_failed",
});
return reject(SYNC_ACCOUNT_VERIFY_FAILED_MESSAGE, "account_verification_failed");
}
if (!isPeerCurrent()) return { kind: "stale" };
const commitAuthorization = await options.captureAccountAuthorization();
if (!isPeerCurrent()) return { kind: "stale" };
Expand Down Expand Up @@ -266,10 +282,21 @@ export async function authenticateSyncAccountHello(
// acknowledgement to arm, and staging deliberately withholds elevations,
// so a staged adoption would leave the record local for exactly as long
// as the bug it fixes.
const paired = pairingStore.pairPeerViaAccount(peer, attestation, {
dpopPublicKey: existingPairingRecord ? null : auth.dpop?.publicKey ?? null,
runtimeHostGrant: auth.runtimeHostGrant ?? null,
});
let paired: ReturnType<SyncPairingStore["pairPeerViaAccount"]>;
try {
paired = pairingStore.pairPeerViaAccount(peer, attestation, {
dpopPublicKey: existingPairingRecord ? null : auth.dpop?.publicKey ?? null,
runtimeHostGrant: auth.runtimeHostGrant ?? null,
});
} catch (error) {
logger.warn(`${logPrefix}.account_pairing_write_failed`, {
deviceId: auth.deviceId,
reason: typeof (error as { code?: unknown } | null)?.code === "string"
? (error as { code: string }).code
: "pairing_write_failed",
});
return reject(SYNC_ACCOUNT_PAIRING_WRITE_FAILED_MESSAGE);
}
// Read-only: this confirms the record we just wrote is readable, and must
// not be mistaken for the device proving it received the secret (which is
// what promotes a staged rotation).
Expand All @@ -292,12 +319,12 @@ export async function authenticateSyncAccountHello(
};
});
} catch (error) {
logger.warn(`${logPrefix}.account_auth_rejected`, {
logger.warn(`${logPrefix}.account_commit_failed`, {
deviceId: auth.deviceId,
reason: typeof (error as { code?: unknown } | null)?.code === "string"
? (error as { code: string }).code
: "verification_failed",
: "commit_failed",
});
return reject(SYNC_ACCOUNT_VERIFY_FAILED_MESSAGE);
return reject(SYNC_ACCOUNT_COMMIT_FAILED_MESSAGE);
}
}
165 changes: 163 additions & 2 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4156,8 +4156,8 @@ describe("sync host account authentication", () => {
"signed-out account hello_error",
);
expect(signedOutRejected.payload).toMatchObject({
code: "auth_failed",
message: expect.stringMatching(/not signed in.*Sign in on this computer/i),
code: "account_not_signed_in",
message: expect.stringMatching(/computer you're connecting to.*not signed in.*Sign in on that computer/i),
});

const pinClient = await openAccountClient(port);
Expand Down Expand Up @@ -4185,6 +4185,77 @@ describe("sync host account authentication", () => {
}
});

it("keeps project-host pairing-write failures out of account verification errors", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const secretsDir = path.join(projectRoot, ".ade", "secrets");
const pinStore = createSyncPinStore({ filePath: path.join(secretsDir, "sync-pin.json") });
const pairingSecretsPath = path.join(secretsDir, "sync-paired-devices.json");
const pairingStore = createSyncPairingStore({ filePath: pairingSecretsPath, pinStore });
const pairPeerViaAccount = vi.spyOn(pairingStore, "pairPeerViaAccount")
.mockImplementation(() => {
throw new Error("pairing store write failed");
});
const listener = createSharedSyncListener({ bindHost: "127.0.0.1" });
const baseArgs = createHostArgs(projectRoot, []);
const host = createSyncHostService({
...baseArgs,
...accountDependencies(),
pinStore,
pairingStore,
pairingSecretsPath,
sharedListener: listener,
discoveryEnabled: false,
deviceRegistryService: {
...baseArgs.deviceRegistryService,
upsertPeerMetadata: vi.fn(),
},
} as unknown as Parameters<typeof createSyncHostService>[0]);
const clients: Array<Awaited<ReturnType<typeof openAccountClient>>> = [];
try {
const port = await host.waitUntilListening();
const peer = {
deviceId: "project-host-pairing-write-failure",
deviceName: "Project host test peer",
platform: "iOS",
deviceType: "phone",
siteId: "project-host-pairing-write-failure-site",
dbVersion: 0,
} satisfies SyncPeerMetadata;
const accountToken = await mintAccountToken();
const dpopKey = makeDpopKeyPair();
const client = await openAccountClient(port, listener.getRelayBridgeProof());
clients.push(client);
sendAccountHello({
ws: client.ws,
peer,
accountToken,
dpop: signAccountDpop({
privateKey: dpopKey.privateKey,
publicKeyX963: dpopKey.publicKeyX963,
deviceId: peer.deviceId,
accountToken,
}),
});
const rejection = await waitForValue(
() => client.envelopes.find((envelope) => envelope.type === "hello_error"),
"project-host pairing-write hello_error",
);
expect(rejection.payload).toMatchObject({
code: "auth_failed",
message: expect.stringMatching(/could not save the new pairing/i),
});
expect((rejection.payload as { message: string }).message)
.not.toMatch(/could not verify/i);
expect(pairPeerViaAccount).toHaveBeenCalledTimes(1);
} finally {
pairPeerViaAccount.mockRestore();
for (const client of clients) client.ws.close();
await host.dispose();
await listener.close();
cleanup();
}
});

// A re-pair used to overwrite the device's working secret the instant the
// host answered, two round trips before the device could persist the reply.
// Dropping the socket in that gap — the ordinary outcome on a flaky network —
Expand Down Expand Up @@ -4442,6 +4513,96 @@ describe("sync host account authentication", () => {
}
});

it("projectless brain keeps pairing-write failures out of account verification errors", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const secretsDir = path.join(projectRoot, "secrets");
fs.mkdirSync(secretsDir, { recursive: true });
resetBrainMachineSyncStoresForTests();
const stores = resolveBrainMachineSyncStores(secretsDir);
const pairPeerViaAccount = vi.spyOn(stores.pairingStore, "pairPeerViaAccount")
.mockImplementation(() => {
throw new Error("pairing store write failed");
});
const deviceKey = makeDpopKeyPair();
const accountToken = await mintAccountToken();
const handler = createBrainProjectActionsSyncHandler({
logger: createDiscoveryLogger(),
projectCatalogProvider: {
listProjects: vi.fn(async () => ({ projects: [] })),
prepareProjectConnection: vi.fn(),
},
bootstrapCredentialStore: new EncryptedFileCredentialStore({
secretsDir,
keyMaterial: { read: () => null },
}),
secretsDir,
localDeviceIdPath: path.join(secretsDir, "sync-device-id"),
localSiteIdPath: path.join(secretsDir, "sync-site-id"),
accountAuthService: {
getStatus: () => ({
signedIn: true,
userId: ownerUserId,
email: null,
name: null,
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
}),
getAccessToken: async () => "host-account-lease",
},
getAccountAttestationConfig: () => ({ issuer, jwksUrl, oauthClientId }),
});
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
server.on("connection", (ws, request) => handler({
ws,
remoteAddress: request.socket.remoteAddress ?? null,
remotePort: request.socket.remotePort ?? null,
transportOrigin: "relay-bridge",
}));
let client: WebSocket | null = null;
try {
await new Promise<void>((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
const peer = {
deviceId: "projectless-pairing-write-failure",
deviceName: "Projectless test peer",
platform: "unknown",
deviceType: "browser",
siteId: "projectless-pairing-write-failure-site",
dbVersion: 0,
} satisfies SyncPeerMetadata;
const opened = await openAccountClient((server.address() as AddressInfo).port);
client = opened.ws;
sendAccountHello({
ws: opened.ws,
peer,
accountToken,
dpop: signAccountDpop({
privateKey: deviceKey.privateKey,
publicKeyX963: deviceKey.publicKeyX963,
deviceId: peer.deviceId,
accountToken,
}),
});
const rejection = await waitForValue(
() => opened.envelopes.find((envelope) => envelope.type === "hello_error"),
"projectless pairing-write hello_error",
);
expect(rejection.payload).toMatchObject({
code: "auth_failed",
message: expect.stringMatching(/could not save the new pairing/i),
});
expect((rejection.payload as { message: string }).message)
.not.toMatch(/could not verify/i);
expect(pairPeerViaAccount).toHaveBeenCalledTimes(1);
} finally {
pairPeerViaAccount.mockRestore();
client?.close();
await new Promise<void>((resolve) => server.close(() => resolve()));
cleanup();
}
});

/**
* Regression: the same fresh machine, reached by a phone over the LAN.
* `ade sync pin generate` and the desktop's pairing card write the machine
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/src/services/sync/syncHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7290,7 +7290,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
arbitrateConnectionAttempt(hello.peer.deviceId, peer, hello.peer),
allowLegacyUpgrade: true,
pairingCodeNoun: "PIN",
notSignedInCode: "auth_failed",
notSignedInCode: "account_not_signed_in",
});
if (accountResult.kind === "stale") return true;
if (accountResult.kind === "rejected") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ import {
orderPairedCandidates,
pairedRuntimeFailureMessage,
pairedRuntimeRouteHost,
type PairedRuntimeAccountHelloCode,
type PairedRuntimeEndpointCandidate,
} from "./pairedRuntimeRoutes";
import {
PairedRuntimeCompatibilityError,
PairedRuntimeHelloRejectedError,
PairedRuntimeRelayAuthRequiredError,
PairedRuntimeTransportUnavailableError,
} from "./pairedRuntimeErrors";
Expand Down Expand Up @@ -122,6 +124,7 @@ export async function bootstrapPairedRuntime(args: {
const attemptRecorder = createRouteAttemptRecorder();
const { attempts, record: recordAttempt } = attemptRecorder;
let relayAuthError: PairedRuntimeRelayAuthRequiredError | null = null;
let accountHelloCode: PairedRuntimeAccountHelloCode | null = null;
// Keep the phases explicit even if a future candidate-builder change
// accidentally reorders endpoints.
const orderedCandidates = orderPairedCandidates(candidates);
Expand Down Expand Up @@ -227,6 +230,16 @@ export async function bootstrapPairedRuntime(args: {
});
continue;
}
if (
error instanceof PairedRuntimeHelloRejectedError
&& (
error.helloCode === "account_not_signed_in"
|| error.helloCode === "account_verification_failed"
)
&& accountHelloCode == null
) {
accountHelloCode = error.helloCode;
}
const failure = classifyPairedRuntimeFailure(error);
markEndpointFailed(candidate, failure);
recordAttempt({
Expand Down Expand Up @@ -395,15 +408,15 @@ export async function bootstrapPairedRuntime(args: {
};
// A skipped relay leg only wins when nothing more actionable was found; a
// host that rejected the pairing outranks "you aren't signed in".
if (relayAuthError && failure === "authentication") {
if (relayAuthError && failure === "authentication" && accountHelloCode == null) {
throw new PairedRuntimeRelayAuthRequiredError(
relayAuthError.message,
relayAuthError.cause,
diagnostic,
);
}
throw new PairedRuntimeTransportUnavailableError(
pairedRuntimeFailureMessage(failure, credentials.hostIdentity.name),
pairedRuntimeFailureMessage(failure, credentials.hostIdentity.name, accountHelloCode),
undefined,
diagnostic,
);
Expand Down
Loading
Loading