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
@@ -0,0 +1,12 @@
import { DataSource } from 'typeorm';
import { AuthUserEntity } from '../../../entities';
export async function handleUpdateUserProvisionSecret(
dataSource: DataSource,
userId: string,
provisionSecret: string
): Promise<void> {
const repo = dataSource.getRepository(AuthUserEntity);
await repo.update({ id: userId }, { provisionSecret });
}
+4
View File
@@ -22,6 +22,7 @@ import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById'
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash';
import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey';
import { handleUpdateUserProvisionSecret } from './commands/handlers/updateUserProvisionSecret';
export const registerUser = (user: AuthUserPayload) =>
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
@@ -70,3 +71,6 @@ export const updateUserPasswordHash = (userId: string, passwordHash: string) =>
export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) =>
handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey);
export const updateUserProvisionSecret = (userId: string, provisionSecret: string) =>
handleUpdateUserProvisionSecret(getDataSource(), userId, provisionSecret);
+2 -1
View File
@@ -15,7 +15,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
passwordHash: row.passwordHash,
displayName: row.displayName,
createdAt: row.createdAt,
signingPublicKey: row.signingPublicKey ?? null
signingPublicKey: row.signingPublicKey ?? null,
provisionSecret: row.provisionSecret ?? null
};
}
+1
View File
@@ -29,6 +29,7 @@ export interface AuthUserPayload {
displayName: string;
createdAt: number;
signingPublicKey?: string | null;
provisionSecret?: string | null;
}
export type ServerChannelType = 'text' | 'voice';
+8
View File
@@ -23,4 +23,12 @@ export class AuthUserEntity {
@Column('text', { nullable: true })
signingPublicKey!: string | null;
/**
* Password this account uses when it provisions linked accounts on foreign
* signal servers. Held here so every device of the same human resolves to
* one foreign account instead of registering a duplicate.
*/
@Column('text', { nullable: true })
provisionSecret!: string | null;
}
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ProvisionSecret1000000000013 implements MigrationInterface {
name = 'ProvisionSecret1000000000013';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "users" ADD COLUMN "provisionSecret" text');
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "users" DROP COLUMN "provisionSecret"');
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
import { SessionTokens1000000000011 } from './1000000000011-SessionTokens';
import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey';
import { ProvisionSecret1000000000013 } from './1000000000013-ProvisionSecret';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -25,5 +26,6 @@ export const serverMigrations = [
ServerIcons1000000000009,
DeviceTokens1000000000010,
SessionTokens1000000000011,
SigningPublicKey1000000000012
SigningPublicKey1000000000012,
ProvisionSecret1000000000013
];
+35
View File
@@ -8,6 +8,7 @@ import {
updateUserSigningPublicKey
} from '../cqrs';
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
import { resolveProvisionSecret } from '../services/provision-secret.service';
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
import { isDuplicateUsernameError } from './user-registration.rules';
@@ -80,6 +81,40 @@ router.post('/login', async (req, res) => {
res.json(buildAuthResponse(user, session.token, session.expiresAt));
});
/**
* Returns the caller's provision secret, creating it on first use. Every
* device of this account gets the same value, which is what keeps a person to
* a single linked account on each foreign signal server.
*/
router.get('/me/provision-secret', requireAuth, async (req, res) => {
const userId = getAuthenticatedUserId(req);
const provisionSecret = await resolveProvisionSecret(userId);
if (!provisionSecret) {
return res.status(404).json({ error: 'User not found', errorCode: 'USER_NOT_FOUND' });
}
res.json({ provisionSecret });
});
/**
* Rotates the caller's password on this server. Used by clients to move a
* linked account created with a legacy per-device secret onto the account's
* canonical provision secret, so the user's other devices can sign in to it.
*/
router.post('/me/password', requireAuth, async (req, res) => {
const { newPassword } = req.body;
const userId = getAuthenticatedUserId(req);
if (typeof newPassword !== 'string' || newPassword.length < 8) {
return res.status(400).json({ error: 'Invalid password', errorCode: 'INVALID_PASSWORD' });
}
await updateUserPasswordHash(userId, await hashPasswordForStorage(newPassword));
res.json({ ok: true });
});
router.put('/me/signing-key', requireAuth, async (req, res) => {
const { publicKeyJwk } = req.body;
const userId = getAuthenticatedUserId(req);
@@ -0,0 +1,76 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
const findOne = vi.fn();
const update = vi.fn();
vi.mock('../db/database', () => ({
getDataSource: () => ({
getRepository: () => ({
findOne,
update
})
})
}));
const { generateProvisionSecret, isUsableProvisionSecret, resolveProvisionSecret } =
await import('./provision-secret.service');
describe('provision-secret.service', () => {
beforeEach(() => {
findOne.mockReset();
update.mockReset();
});
it('generates a 64 character hex secret', () => {
expect(generateProvisionSecret()).toMatch(/^[a-f0-9]{64}$/);
});
it('rejects blank secrets', () => {
expect(isUsableProvisionSecret(null)).toBe(false);
expect(isUsableProvisionSecret(' ')).toBe(false);
expect(isUsableProvisionSecret('secret')).toBe(true);
});
it('returns the same stored secret on every call so all devices match', async () => {
findOne.mockResolvedValue({ id: 'user-1', provisionSecret: 'stored-secret' });
await expect(resolveProvisionSecret('user-1')).resolves.toBe('stored-secret');
await expect(resolveProvisionSecret('user-1')).resolves.toBe('stored-secret');
expect(update).not.toHaveBeenCalled();
});
it('creates the secret on first use and returns the persisted value', async () => {
findOne
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: null })
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: 'created-secret' });
await expect(resolveProvisionSecret('user-1')).resolves.toBe('created-secret');
expect(update).toHaveBeenCalledOnce();
});
it('only writes while the column is empty so concurrent callers converge', async () => {
findOne
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: null })
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: 'winning-secret' });
await resolveProvisionSecret('user-1');
const [criteria] = update.mock.calls[0];
expect(criteria).toMatchObject({ id: 'user-1' });
expect(criteria.provisionSecret).toBeDefined();
});
it('returns null for an unknown user', async () => {
findOne.mockResolvedValue(null);
await expect(resolveProvisionSecret('missing')).resolves.toBeNull();
expect(update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
import { randomBytes } from 'crypto';
import { IsNull } from 'typeorm';
import { getDataSource } from '../db/database';
import { AuthUserEntity } from '../entities';
/**
* The provision secret is the password this account uses when it creates its
* linked accounts on foreign signal servers. It must be identical on every
* device of the same human: a per-device secret makes the second device fail
* to log in to the existing foreign account and register a duplicate one, so
* the same person shows up twice to everyone else.
*/
export function generateProvisionSecret(): string {
return randomBytes(32).toString('hex');
}
export function isUsableProvisionSecret(secret: string | null | undefined): secret is string {
return typeof secret === 'string' && secret.trim().length > 0;
}
/**
* Returns the account's provision secret, creating it on first use. Concurrent
* callers converge on one value: the insert only applies while the column is
* still empty, and the stored value is re-read before returning.
*/
export async function resolveProvisionSecret(userId: string): Promise<string | null> {
const repo = getDataSource().getRepository(AuthUserEntity);
const existing = await repo.findOne({ where: { id: userId } });
if (!existing) {
return null;
}
if (isUsableProvisionSecret(existing.provisionSecret)) {
return existing.provisionSecret;
}
await repo.update(
{ id: userId, provisionSecret: IsNull() },
{ provisionSecret: generateProvisionSecret() }
);
const stored = await repo.findOne({ where: { id: userId } });
return isUsableProvisionSecret(stored?.provisionSecret) ? stored.provisionSecret : null;
}