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:
-44
@@ -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';
|
||||
}
|
||||
+61
-1
@@ -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']);
|
||||
|
||||
|
||||
+101
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user