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:
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface AuthUserPayload {
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
signingPublicKey?: string | null;
|
||||
provisionSecret?: string | null;
|
||||
}
|
||||
|
||||
export type ServerChannelType = 'text' | 'voice';
|
||||
|
||||
@@ -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"');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user