Skip to content

Commit 73d12ca

Browse files
feat(users): add GET /users/:id/key-fingerprint safety-number endpoint (#162)
1 parent 94ce1af commit 73d12ca

2 files changed

Lines changed: 269 additions & 1 deletion

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Tests for GET /users/:id/key-fingerprint (issue #162)
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach } from 'vitest';
6+
import request from 'supertest';
7+
import express from 'express';
8+
import { createHash } from 'node:crypto';
9+
10+
// ── Mocks ─────────────────────────────────────────────────────────────────────
11+
12+
const mockUserFindFirst = vi.fn();
13+
const mockDeviceFindFirst = vi.fn();
14+
const mockDeviceFindMany = vi.fn();
15+
16+
vi.mock('../db/index.js', () => ({
17+
db: {
18+
query: {
19+
users: { findFirst: mockUserFindFirst, findMany: vi.fn() },
20+
devices: { findFirst: mockDeviceFindFirst, findMany: mockDeviceFindMany },
21+
wallets: { findFirst: vi.fn() },
22+
},
23+
update: vi.fn(),
24+
select: vi.fn(),
25+
},
26+
}));
27+
28+
vi.mock('../db/schema.js', () => ({
29+
users: { id: 'id', username: 'username' },
30+
wallets: {},
31+
devices: { userId: 'userId', isRevoked: 'isRevoked' },
32+
}));
33+
34+
vi.mock('drizzle-orm', () => ({
35+
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
36+
and: vi.fn((...args: unknown[]) => args),
37+
or: vi.fn((...args: unknown[]) => args),
38+
ilike: vi.fn(),
39+
exists: vi.fn(),
40+
sql: vi.fn(),
41+
}));
42+
43+
vi.mock('../lib/redis.js', () => ({
44+
get redis() {
45+
return null;
46+
},
47+
}));
48+
49+
vi.mock('../services/presence.js', () => ({
50+
isOnline: vi.fn().mockResolvedValue(false),
51+
}));
52+
53+
// Stub requireAuth — inject device-id so the real middleware path doesn't run.
54+
vi.mock('../middleware/auth.js', () => ({
55+
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
56+
(req as express.Request & { auth: { userId: string } }).auth = { userId: 'caller-id' };
57+
next();
58+
},
59+
}));
60+
61+
const { usersRouter } = await import('../routes/users.js');
62+
63+
function makeApp() {
64+
const app = express();
65+
app.use(express.json());
66+
app.use('/users', usersRouter);
67+
return app;
68+
}
69+
70+
// ── Fingerprint derivation helper (mirrors the route implementation) ──────────
71+
72+
function deriveFingerprint(identityKeys: string[]): string {
73+
const sorted = [...identityKeys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
74+
const concatenated = sorted.join('\n');
75+
const digest = createHash('sha256').update(concatenated, 'utf8').digest();
76+
77+
function bytesToSegment(buf: Buffer, offset: number, length: number): string {
78+
let value = BigInt(0);
79+
for (let i = 0; i < length; i++) {
80+
value = (value << BigInt(8)) | BigInt(buf[offset + i]!);
81+
}
82+
return (value % BigInt('1' + '0'.repeat(30))).toString().padStart(30, '0');
83+
}
84+
85+
return bytesToSegment(digest, 0, 15) + bytesToSegment(digest, 15, 15);
86+
}
87+
88+
beforeEach(() => {
89+
vi.clearAllMocks();
90+
// Default: authenticated device is active.
91+
mockDeviceFindFirst.mockResolvedValue({ id: 'caller-device', isRevoked: false });
92+
});
93+
94+
describe('GET /users/:id/key-fingerprint', () => {
95+
it('returns 404 when user does not exist', async () => {
96+
mockUserFindFirst.mockResolvedValue(undefined);
97+
98+
const res = await request(makeApp()).get('/users/unknown-id/key-fingerprint');
99+
100+
expect(res.status).toBe(404);
101+
expect(res.body.error).toMatch(/not found/i);
102+
});
103+
104+
it('returns 404 when user has no active devices', async () => {
105+
mockUserFindFirst.mockResolvedValue({ id: 'user-1' });
106+
mockDeviceFindMany.mockResolvedValue([]);
107+
108+
const res = await request(makeApp()).get('/users/user-1/key-fingerprint');
109+
110+
expect(res.status).toBe(404);
111+
expect(res.body.error).toMatch(/no active devices/i);
112+
});
113+
114+
it('returns a 60-digit fingerprint and 12 × 5-digit formatted safety number', async () => {
115+
mockUserFindFirst.mockResolvedValue({ id: 'user-1' });
116+
mockDeviceFindMany.mockResolvedValue([
117+
{ identityPublicKey: 'a2V5QQ==' },
118+
{ identityPublicKey: 'a2V5Qg==' },
119+
]);
120+
121+
const res = await request(makeApp()).get('/users/user-1/key-fingerprint');
122+
123+
expect(res.status).toBe(200);
124+
expect(res.body).toHaveProperty('userId', 'user-1');
125+
expect(res.body).toHaveProperty('fingerprint');
126+
expect(res.body).toHaveProperty('formatted');
127+
128+
const { fingerprint, formatted } = res.body as { fingerprint: string; formatted: string };
129+
130+
// Fingerprint must be exactly 60 numeric digits.
131+
expect(fingerprint).toHaveLength(60);
132+
expect(fingerprint).toMatch(/^\d{60}$/);
133+
134+
// Formatted must be 12 groups of 5 digits separated by spaces.
135+
expect(formatted).toMatch(/^(\d{5} ){11}\d{5}$/);
136+
137+
// Raw and formatted must contain the same digits.
138+
expect(formatted.replace(/ /g, '')).toBe(fingerprint);
139+
});
140+
141+
it('is deterministic: same keys → same fingerprint regardless of input order', async () => {
142+
const keys = ['a2V5Qg==', 'a2V5QQ==']; // reverse order vs. previous test
143+
144+
mockUserFindFirst.mockResolvedValue({ id: 'user-1' });
145+
mockDeviceFindMany.mockResolvedValue(keys.map((k) => ({ identityPublicKey: k })));
146+
147+
const res = await request(makeApp()).get('/users/user-1/key-fingerprint');
148+
149+
expect(res.status).toBe(200);
150+
const expected = deriveFingerprint(keys);
151+
expect(res.body.fingerprint).toBe(expected);
152+
});
153+
154+
it('produces a different fingerprint for different key sets', async () => {
155+
const fp1 = deriveFingerprint(['a2V5QQ==']);
156+
const fp2 = deriveFingerprint(['a2V5Qg==']);
157+
expect(fp1).not.toBe(fp2);
158+
});
159+
160+
it('single-device user gets a valid 60-digit fingerprint', async () => {
161+
mockUserFindFirst.mockResolvedValue({ id: 'user-1' });
162+
mockDeviceFindMany.mockResolvedValue([{ identityPublicKey: 'c2luZ2xlRGV2aWNlS2V5' }]);
163+
164+
const res = await request(makeApp()).get('/users/user-1/key-fingerprint');
165+
166+
expect(res.status).toBe(200);
167+
expect(res.body.fingerprint).toHaveLength(60);
168+
});
169+
});

apps/backend/src/routes/users.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { createHash } from 'node:crypto';
12
import { Router, type Router as RouterType } from 'express';
23
import { eq, and, or, ilike, exists, sql } from 'drizzle-orm';
34
import { db } from '../db/index.js';
4-
import { users, wallets } from '../db/schema.js';
5+
import { users, wallets, devices } from '../db/schema.js';
56
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
67
import { redis } from '../lib/redis.js';
78
import { isOnline } from '../services/presence.js';
@@ -151,6 +152,104 @@ usersRouter.get('/:id/presence', async (req: AuthRequest, res) => {
151152
res.json({ online });
152153
});
153154

155+
/**
156+
* GET /users/:id/key-fingerprint
157+
*
158+
* Returns a 60-digit numeric safety number derived from the user's set of
159+
* active device identity public keys. The derivation is deterministic and
160+
* identical on all clients:
161+
*
162+
* 1. Collect all non-revoked device identityPublicKey values for the user.
163+
* 2. Sort them lexicographically (UTF-8 byte order on the base64 strings).
164+
* 3. Concatenate them separated by a single newline (`\n`).
165+
* 4. Compute SHA-256 of the UTF-8-encoded concatenated string.
166+
* 5. Take the first 30 bytes of the digest and interpret them as a
167+
* big-endian unsigned integer modulo 10^30, zero-padded to 30 digits.
168+
* 6. Repeat with bytes 16–31 and reduce modulo 10^30 to produce a second
169+
* 30-digit segment, then concatenate → 60 digits total.
170+
* (This matches Signal's safety-number derivation: two independent
171+
* 30-digit numbers from non-overlapping digest halves, formatted in
172+
* groups of 5 separated by spaces.)
173+
*
174+
* The final value is returned both as a raw 60-character digit string and as
175+
* the canonical "groups of 5" display format (12 groups of 5, space-separated).
176+
*/
177+
usersRouter.get('/:id/key-fingerprint', async (req: AuthRequest, res) => {
178+
const id = req.params['id'] as string;
179+
180+
try {
181+
// Verify the target user exists.
182+
const user = await db.query.users.findFirst({
183+
where: eq(users.id, id),
184+
columns: { id: true },
185+
});
186+
187+
if (!user) {
188+
res.status(404).json({ error: 'User not found' });
189+
return;
190+
}
191+
192+
// Fetch all active (non-revoked) device identity public keys.
193+
const activeDevices = await db.query.devices.findMany({
194+
where: and(eq(devices.userId, id), eq(devices.isRevoked, false)),
195+
columns: { identityPublicKey: true },
196+
});
197+
198+
if (activeDevices.length === 0) {
199+
res.status(404).json({ error: 'No active devices found for this user' });
200+
return;
201+
}
202+
203+
// Step 2: sort lexicographically.
204+
const sortedKeys = activeDevices
205+
.map((d) => d.identityPublicKey)
206+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
207+
208+
// Step 3: concatenate with newline separator.
209+
const concatenated = sortedKeys.join('\n');
210+
211+
// Step 4: SHA-256.
212+
const digest = createHash('sha256').update(concatenated, 'utf8').digest();
213+
214+
// Steps 5 & 6: produce two 30-digit segments from the 32-byte digest.
215+
// Segment A: bytes 0–14 (15 bytes → 120 bits), reduce mod 10^30.
216+
// Segment B: bytes 15–29 (15 bytes), reduce mod 10^30.
217+
// (15 bytes gives well above the 30 decimal digits we need while keeping
218+
// overlap-free regions within 32 digest bytes.)
219+
function bytesToSafetySegment(buf: Buffer, offset: number, length: number): string {
220+
let value = BigInt(0);
221+
for (let i = 0; i < length; i++) {
222+
value = (value << BigInt(8)) | BigInt(buf[offset + i]!);
223+
}
224+
const mod = value % BigInt('1' + '0'.repeat(30));
225+
return mod.toString().padStart(30, '0');
226+
}
227+
228+
const segmentA = bytesToSafetySegment(digest, 0, 15);
229+
const segmentB = bytesToSafetySegment(digest, 15, 15);
230+
const raw = segmentA + segmentB;
231+
232+
// Format: 12 groups of 5 digits, space-separated (Signal convention).
233+
const formatted = raw.match(/.{5}/g)!.join(' ');
234+
235+
res.json({
236+
userId: id,
237+
/**
238+
* Raw 60-digit numeric fingerprint. Clients compare this string
239+
* after stripping spaces; the formatted version is for display.
240+
*/
241+
fingerprint: raw,
242+
/**
243+
* Human-readable version in groups of 5, matching Signal's safety
244+
* number display format.
245+
*/
246+
formatted,
247+
});
248+
} catch {
249+
res.status(500).json({ error: 'Failed to compute key fingerprint' });
250+
}
251+
});
252+
154253
usersRouter.patch('/me', async (req: AuthRequest, res) => {
155254
const userId = req.auth!.userId;
156255
const { username, avatarUrl } = req.body;

0 commit comments

Comments
 (0)