feat(auth): recover cross-signal authorization with provision secrets

A client that could not authorize against a foreign signal server was
redirected into a dead end with no way to retry, so servers joined from
another signal route became unreachable.

The home server now stores a per-user provision secret, clients keep it in
their own store, and a recovery service records why authorization failed per
server URL. Invite, server browser, and chat room surface that reason and
offer a retry instead of silently redirecting.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent e49b3ec112
commit f9e8538c80
37 changed files with 1877 additions and 244 deletions
@@ -1,44 +0,0 @@
import {
describe,
expect,
it
} from 'vitest';
import { shouldNavigateToAuthorizeSignalServer } from './signal-server-authorize.rules';
describe('signal-server-authorize rules', () => {
it('does not navigate to authorize when the signal server is offline', () => {
expect(shouldNavigateToAuthorizeSignalServer('offline', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(false);
expect(shouldNavigateToAuthorizeSignalServer('offline', {
kind: 'collision',
error: new Error('collision') as never
})).toBe(false);
});
it('navigates to authorize on online servers that need manual sign-in', () => {
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(true);
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'collision',
error: new Error('collision') as never
})).toBe(true);
});
it('does not navigate for unknown endpoint status or non-authorize provision outcomes', () => {
expect(shouldNavigateToAuthorizeSignalServer('unknown', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(false);
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'skipped',
reason: 'no-home-user'
})).toBe(false);
});
});
@@ -1,18 +0,0 @@
import type { EnsureProvisionedResult } from '../../application/services/signal-server-auth.service';
import type { ServerEndpointStatus } from '../../../server-directory/domain/models/server-directory.model';
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
export function shouldNavigateToAuthorizeSignalServer(
endpointStatus: ServerEndpointStatus | undefined | null,
provisionResult: EnsureProvisionedResult
): boolean {
if (!isEndpointOnlineForConnection(endpointStatus)) {
return false;
}
if (provisionResult.kind === 'collision') {
return true;
}
return provisionResult.kind === 'skipped' && provisionResult.reason === 'no-provision-secret';
}
@@ -5,10 +5,22 @@ import {
} from 'vitest';
import {
ProvisionUsernameCollisionError,
buildProvisionPlan,
buildProvisionUsernameCandidates,
shortHomeUserId
shortHomeUserId,
shouldAdoptCanonicalSecret
} from './signal-server-provision.rules';
const HOME_USER_ID = 'a3f2b1c4-5678-90ab-cdef-1234567890ab';
function plan(canonical: string | null, deviceLocal: string | null) {
return buildProvisionPlan({
preferredUsername: 'alice',
homeUserId: HOME_USER_ID,
secrets: { canonical, deviceLocal }
}).map((attempt) => `${attempt.kind}:${attempt.username}:${attempt.secretSource}`);
}
describe('signal-server-provision.rules', () => {
it('derives a stable short id from a home user uuid', () => {
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
@@ -26,6 +38,54 @@ describe('signal-server-provision.rules', () => {
).toEqual(['alice-a3f2b1']);
});
it('signs in to the preferred username before ever trying the suffixed one', () => {
expect(plan('canonical-secret', null)).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('tries the legacy device secret before giving the username up as someone else\'s', () => {
expect(plan('canonical-secret', 'legacy-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'login:alice:device-local',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical',
'login:alice-a3f2b1:device-local'
]);
});
it('never registers a suffixed duplicate without a canonical secret', () => {
expect(plan(null, 'legacy-secret')).toEqual([
'register:alice:device-local',
'login:alice:device-local',
'login:alice-a3f2b1:device-local'
]);
});
it('produces no attempts when no secret is available at all', () => {
expect(plan(null, null)).toEqual([]);
});
it('does not repeat the canonical secret as a legacy attempt', () => {
expect(plan('same-secret', 'same-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('adopts the canonical secret only after a legacy login', () => {
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, 'canonical')).toBe(true);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'canonical' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'register', secretSource: 'device-local' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, null)).toBe(false);
});
it('exposes attempted usernames on collision errors', () => {
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);
@@ -32,3 +32,104 @@ export function buildProvisionUsernameCandidates(
return [...new Set(candidates)];
}
/**
* `canonical` is the account-wide secret issued by the home signal server, so
* it is identical on every device of the same human. `device-local` is the
* legacy secret that older builds generated per device; it only ever unlocks
* accounts that this one device created.
*/
export type ProvisionSecretSource = 'canonical' | 'device-local';
export interface ProvisionSecrets {
canonical: string | null;
deviceLocal: string | null;
}
export interface ProvisionAttempt {
kind: 'register' | 'login';
username: string;
secret: string;
secretSource: ProvisionSecretSource;
usedSuffix: boolean;
}
/**
* Orders the provisioning attempts for one foreign signal server.
*
* The ordering exists to guarantee that a human never ends up with two
* accounts on the same server. For each username we first try to claim it,
* then to sign in with the canonical secret (another device of ours already
* claimed it), then with the device-local secret (this device claimed it
* before canonical secrets existed). Only once a username is proven to belong
* to somebody else do we move on to the suffixed name.
*
* Registering the suffixed name requires a canonical secret. Without one we
* cannot tell "another human owns this name" apart from "our own account whose
* secret this device never had", and guessing wrong forks the user's identity.
*/
export function buildProvisionPlan(input: {
preferredUsername: string;
homeUserId: string;
secrets: ProvisionSecrets;
}): ProvisionAttempt[] {
const candidates = buildProvisionUsernameCandidates(input.preferredUsername, input.homeUserId);
const { canonical, deviceLocal } = input.secrets;
const primary = canonical ?? deviceLocal;
if (!primary) {
return [];
}
const attempts: ProvisionAttempt[] = [];
candidates.forEach((username, index) => {
const usedSuffix = index > 0;
if (!usedSuffix || canonical) {
attempts.push({
kind: 'register',
username,
secret: primary,
secretSource: canonical ? 'canonical' : 'device-local',
usedSuffix
});
}
if (canonical) {
attempts.push({
kind: 'login',
username,
secret: canonical,
secretSource: 'canonical',
usedSuffix
});
}
if (deviceLocal && deviceLocal !== canonical) {
attempts.push({
kind: 'login',
username,
secret: deviceLocal,
secretSource: 'device-local',
usedSuffix
});
}
});
return attempts;
}
/**
* A login that succeeded with the legacy device-local secret leaves the
* account unreachable from the user's other devices until its password is
* moved to the canonical secret.
*/
export function shouldAdoptCanonicalSecret(
attempt: Pick<ProvisionAttempt, 'kind' | 'secretSource'>,
canonicalSecret: string | null
): canonicalSecret is string {
return attempt.kind === 'login'
&& attempt.secretSource === 'device-local'
&& !!canonicalSecret;
}