@signalapp/mock-server
Advanced tools
Comparing version 7.1.3 to 8.0.0
{ | ||
"name": "@signalapp/mock-server", | ||
"version": "7.1.3", | ||
"version": "8.0.0", | ||
"description": "Mock Signal Server for writing tests", | ||
@@ -5,0 +5,0 @@ "main": "src/index.js", |
/// <reference types="node" /> | ||
/// <reference types="node" /> | ||
/// <reference types="node" /> | ||
import { type Readable } from 'stream'; | ||
import Long from 'long'; | ||
import { ServerOptions } from 'https'; | ||
import { BackupMediaBatch } from '../data/schemas'; | ||
import { ProvisionIdString, ServiceIdKind, ServiceIdString } from '../types'; | ||
import { Group as GroupData } from '../data/group'; | ||
import { signalservice as Proto } from '../../protos/compiled'; | ||
import { Server as BaseServer, ChallengeResponse, EnvelopeType, IsSendRateLimitedOptions, ModifyGroupOptions, ModifyGroupResult, ProvisionDeviceOptions, ProvisioningResponse } from '../server/base'; | ||
import { BackupMediaBatchResponse, Server as BaseServer, ChallengeResponse, EnvelopeType, IsSendRateLimitedOptions, ModifyGroupOptions, ModifyGroupResult, ProvisionDeviceOptions, ProvisioningResponse } from '../server/base'; | ||
import { Device, DeviceKeys } from '../data/device'; | ||
@@ -76,3 +79,5 @@ import { PrimaryDevice } from './primary-device'; | ||
stopRateLimiting({ source, target, }: RateLimitOptions): number | undefined; | ||
storeAttachmentOnCdn(cdnNumber: number, cdnKey: string, data: Uint8Array): void; | ||
removeAllCDNAttachments(): Promise<void>; | ||
storeAttachmentOnCdn(cdnNumber: number, cdnKey: string, data: Uint8Array): Promise<void>; | ||
storeBackupOnCdn(backupId: Uint8Array, stream: Readable): Promise<void>; | ||
getProvisioningResponse(id: ProvisionIdString): Promise<ProvisioningResponse>; | ||
@@ -87,2 +92,3 @@ handleMessage(source: Device | undefined, serviceIdKind: ServiceIdKind, envelopeType: EnvelopeType, target: Device, encrypted: Buffer): Promise<void>; | ||
protected onStorageManifestUpdate(device: Device, version: Long): Promise<void>; | ||
protected backupTransitAttachments(backupId: string, batch: BackupMediaBatch): Promise<Array<BackupMediaBatchResponse>>; | ||
private createQueue; | ||
@@ -89,0 +95,0 @@ private generateNumber; |
@@ -11,2 +11,4 @@ "use strict"; | ||
const fs_1 = __importDefault(require("fs")); | ||
const promises_1 = __importDefault(require("fs/promises")); | ||
const promises_2 = require("stream/promises"); | ||
const path_1 = __importDefault(require("path")); | ||
@@ -259,9 +261,29 @@ const https_1 = __importDefault(require("https")); | ||
} | ||
storeAttachmentOnCdn(cdnNumber, cdnKey, data) { | ||
async removeAllCDNAttachments() { | ||
const { cdn3Path } = this.config; | ||
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments'); | ||
const dir = path_1.default.join(cdn3Path, 'attachments'); | ||
await promises_1.default.rm(dir, { | ||
recursive: true, | ||
}); | ||
} | ||
async storeAttachmentOnCdn(cdnNumber, cdnKey, data) { | ||
assert_1.default.strictEqual(cdnNumber, 3, 'Only cdn 3 currently supported'); | ||
const { cdn3Path } = this.config; | ||
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments'); | ||
fs_1.default.mkdirSync(path_1.default.join(cdn3Path, 'attachments'), { recursive: true }); | ||
fs_1.default.writeFileSync(path_1.default.join(cdn3Path, 'attachments', cdnKey), data); | ||
const dir = path_1.default.join(cdn3Path, 'attachments'); | ||
await promises_1.default.mkdir(dir, { | ||
recursive: true, | ||
}); | ||
await promises_1.default.writeFile(path_1.default.join(dir, cdnKey), data); | ||
} | ||
async storeBackupOnCdn(backupId, stream) { | ||
const { cdn3Path } = this.config; | ||
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments'); | ||
const dir = path_1.default.join(cdn3Path, 'backups', Buffer.from(backupId).toString('base64url')); | ||
await promises_1.default.mkdir(dir, { | ||
recursive: true, | ||
}); | ||
await (0, promises_2.pipeline)(stream, fs_1.default.createWriteStream(path_1.default.join(dir, 'backup'))); | ||
} | ||
// | ||
@@ -422,2 +444,49 @@ // Implement Server's abstract methods | ||
} | ||
async backupTransitAttachments(backupId, batch) { | ||
const { cdn3Path } = this.config; | ||
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments'); | ||
const dir = path_1.default.join(cdn3Path, 'backups'); | ||
const mediaDir = path_1.default.join(dir, backupId, 'media'); | ||
await promises_1.default.mkdir(mediaDir, { | ||
recursive: true, | ||
}); | ||
return Promise.all(batch.items.map(async (item) => { | ||
assert_1.default.strictEqual(item.sourceAttachment.cdn, 3, 'Invalid object CDN'); | ||
const transitPath = path_1.default.join(dir, item.sourceAttachment.key); | ||
const finalPath = path_1.default.join(mediaDir, item.mediaId); | ||
// TODO(indutny): streams | ||
let data; | ||
try { | ||
data = await promises_1.default.readFile(transitPath); | ||
} | ||
catch (error) { | ||
(0, assert_1.default)(error instanceof Error); | ||
if ('code' in error && error.code === 'ENOENT') { | ||
return { | ||
cdn: 3, | ||
status: 404, | ||
mediaId: item.mediaId, | ||
}; | ||
} | ||
throw error; | ||
} | ||
assert_1.default.strictEqual(data.byteLength, item.objectLength, 'Invalid objectLength'); | ||
const reencrypted = (0, crypto_1.encryptAttachment)(data, { | ||
aesKey: item.encryptionKey, | ||
macKey: item.hmacKey, | ||
iv: item.iv, | ||
}); | ||
await promises_1.default.writeFile(finalPath, reencrypted.blob); | ||
this.onNewBackupMediaObject(backupId, { | ||
cdn: 3, | ||
mediaId: item.mediaId, | ||
objectLength: reencrypted.blob.length, | ||
}); | ||
return { | ||
cdn: 3, | ||
status: 200, | ||
mediaId: item.mediaId, | ||
}; | ||
})); | ||
} | ||
// | ||
@@ -424,0 +493,0 @@ // Private |
@@ -24,3 +24,8 @@ /// <reference types="node" /> | ||
export declare function encryptProvisionMessage(data: Buffer, remotePubKey: PublicKey): EncryptedProvisionMessage; | ||
export declare function encryptAttachment(cleartext: Buffer): Attachment; | ||
export type EncryptAttachmentOptions = Readonly<{ | ||
aesKey: Buffer; | ||
macKey: Buffer; | ||
iv: Buffer; | ||
}>; | ||
export declare function encryptAttachment(cleartext: Buffer, { aesKey, macKey, iv }?: EncryptAttachmentOptions): Attachment; | ||
export declare function generateServerCertificate(rootKey: PrivateKey): ServerCertificate; | ||
@@ -27,0 +32,0 @@ export declare function generateSenderCertificate(serverCert: ServerCertificate, sender: Sender): SenderCertificate; |
@@ -40,6 +40,7 @@ "use strict"; | ||
exports.encryptProvisionMessage = encryptProvisionMessage; | ||
function encryptAttachment(cleartext) { | ||
const aesKey = crypto_1.default.randomBytes(32); | ||
const macKey = crypto_1.default.randomBytes(32); | ||
const iv = crypto_1.default.randomBytes(16); | ||
function encryptAttachment(cleartext, { aesKey, macKey, iv } = { | ||
aesKey: crypto_1.default.randomBytes(32), | ||
macKey: crypto_1.default.randomBytes(32), | ||
iv: crypto_1.default.randomBytes(16), | ||
}) { | ||
const cipher = crypto_1.default.createCipheriv('aes-256-cbc', aesKey, iv); | ||
@@ -46,0 +47,0 @@ const ciphertext = buffer_1.Buffer.concat([cipher.update(cleartext), cipher.final()]); |
/// <reference types="node" /> | ||
import { ProtocolAddress, PublicKey } from '@signalapp/libsignal-client'; | ||
import { ProfileKeyCommitment } from '@signalapp/libsignal-client/zkgroup'; | ||
import { BackupLevel, ProfileKeyCommitment } from '@signalapp/libsignal-client/zkgroup'; | ||
import { AciString, DeviceId, KyberPreKey, PniString, PreKey, RegistrationId, ServiceIdKind, ServiceIdString, SignedPreKey } from '../types'; | ||
@@ -41,2 +41,3 @@ export type DeviceOptions = Readonly<{ | ||
}; | ||
backupLevel: BackupLevel; | ||
accessKey?: Buffer; | ||
@@ -43,0 +44,0 @@ profileKeyCommitment?: ProfileKeyCommitment; |
@@ -11,2 +11,3 @@ "use strict"; | ||
const libsignal_client_1 = require("@signalapp/libsignal-client"); | ||
const zkgroup_1 = require("@signalapp/libsignal-client/zkgroup"); | ||
const types_1 = require("../types"); | ||
@@ -21,2 +22,3 @@ const debug = (0, debug_1.default)('mock:device'); | ||
capabilities; | ||
backupLevel = zkgroup_1.BackupLevel.Media; | ||
accessKey; | ||
@@ -23,0 +25,0 @@ profileKeyCommitment; |
@@ -463,2 +463,66 @@ import z from 'zod'; | ||
export type SetBackupKey = z.infer<typeof SetBackupKeySchema>; | ||
export declare const BackupMediaBatchSchema: z.ZodObject<{ | ||
items: z.ZodArray<z.ZodObject<{ | ||
sourceAttachment: z.ZodObject<{ | ||
cdn: z.ZodNumber; | ||
key: z.ZodString; | ||
}, "strip", z.ZodTypeAny, { | ||
cdn: number; | ||
key: string; | ||
}, { | ||
cdn: number; | ||
key: string; | ||
}>; | ||
objectLength: z.ZodNumber; | ||
mediaId: z.ZodString; | ||
hmacKey: z.ZodEffects<z.ZodString, Buffer, string>; | ||
encryptionKey: z.ZodEffects<z.ZodString, Buffer, string>; | ||
iv: z.ZodEffects<z.ZodString, Buffer, string>; | ||
}, "strip", z.ZodTypeAny, { | ||
sourceAttachment: { | ||
cdn: number; | ||
key: string; | ||
}; | ||
objectLength: number; | ||
mediaId: string; | ||
hmacKey: Buffer; | ||
encryptionKey: Buffer; | ||
iv: Buffer; | ||
}, { | ||
sourceAttachment: { | ||
cdn: number; | ||
key: string; | ||
}; | ||
objectLength: number; | ||
mediaId: string; | ||
hmacKey: string; | ||
encryptionKey: string; | ||
iv: string; | ||
}>, "many">; | ||
}, "strip", z.ZodTypeAny, { | ||
items: { | ||
sourceAttachment: { | ||
cdn: number; | ||
key: string; | ||
}; | ||
objectLength: number; | ||
mediaId: string; | ||
hmacKey: Buffer; | ||
encryptionKey: Buffer; | ||
iv: Buffer; | ||
}[]; | ||
}, { | ||
items: { | ||
sourceAttachment: { | ||
cdn: number; | ||
key: string; | ||
}; | ||
objectLength: number; | ||
mediaId: string; | ||
hmacKey: string; | ||
encryptionKey: string; | ||
iv: string; | ||
}[]; | ||
}>; | ||
export type BackupMediaBatch = z.infer<typeof BackupMediaBatchSchema>; | ||
export {}; |
@@ -8,3 +8,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.SetBackupKeySchema = exports.BackupHeadersSchema = exports.SetBackupIdSchema = exports.DeleteCallLinkSchema = exports.UpdateCallLinkSchema = exports.CreateCallLinkSchema = exports.CreateCallLinkAuthSchema = exports.PutUsernameLinkSchema = exports.UsernameConfirmationSchema = exports.UsernameReservationSchema = exports.GroupStateSchema = exports.AtomicLinkingDataSchema = exports.MessageListSchema = exports.MessageSchema = exports.DeviceKeysSchema = exports.DeviceIdSchema = exports.RegistrationIdSchema = exports.ServiceIdSchema = exports.PniSchema = exports.AciSchema = exports.PositiveInt = void 0; | ||
exports.BackupMediaBatchSchema = exports.SetBackupKeySchema = exports.BackupHeadersSchema = exports.SetBackupIdSchema = exports.DeleteCallLinkSchema = exports.UpdateCallLinkSchema = exports.CreateCallLinkSchema = exports.CreateCallLinkAuthSchema = exports.PutUsernameLinkSchema = exports.UsernameConfirmationSchema = exports.UsernameReservationSchema = exports.GroupStateSchema = exports.AtomicLinkingDataSchema = exports.MessageListSchema = exports.MessageSchema = exports.DeviceKeysSchema = exports.DeviceIdSchema = exports.RegistrationIdSchema = exports.ServiceIdSchema = exports.PniSchema = exports.AciSchema = exports.PositiveInt = void 0; | ||
const zod_1 = __importDefault(require("zod")); | ||
@@ -116,1 +116,16 @@ const util_1 = require("../util"); | ||
}); | ||
exports.BackupMediaBatchSchema = zod_1.default.object({ | ||
items: zod_1.default | ||
.object({ | ||
sourceAttachment: zod_1.default.object({ | ||
cdn: zod_1.default.number(), | ||
key: zod_1.default.string(), | ||
}), | ||
objectLength: zod_1.default.number(), | ||
mediaId: zod_1.default.string(), | ||
hmacKey: zod_1.default.string().transform(util_1.fromBase64), | ||
encryptionKey: zod_1.default.string().transform(util_1.fromBase64), | ||
iv: zod_1.default.string().transform(util_1.fromBase64), | ||
}) | ||
.array(), | ||
}); |
@@ -12,3 +12,3 @@ /// <reference types="node" /> | ||
import { ChangeNumberOptions, Device, DeviceKeys } from '../data/device'; | ||
import { BackupHeaders, CreateCallLink, DeleteCallLink, Message, SetBackupId, SetBackupKey, UpdateCallLink, UsernameConfirmation, UsernameReservation } from '../data/schemas'; | ||
import { BackupHeaders, BackupMediaBatch, CreateCallLink, DeleteCallLink, Message, SetBackupId, SetBackupKey, UpdateCallLink, UsernameConfirmation, UsernameReservation } from '../data/schemas'; | ||
import { AciString, AttachmentId, DeviceId, PniString, ProvisionIdString, ProvisioningCode, RegistrationId, ServiceIdKind, ServiceIdString } from '../types'; | ||
@@ -131,2 +131,30 @@ import { ModifyGroupResult, ServerGroup } from './group'; | ||
}>; | ||
export type BackupMediaObject = Readonly<{ | ||
cdn: 3; | ||
mediaId: string; | ||
objectLength: number; | ||
}>; | ||
export type BackupMediaList = Readonly<{ | ||
storedMediaObjects: ReadonlyArray<BackupMediaObject>; | ||
backupDir: string; | ||
mediaDir: string; | ||
cursor: string | undefined; | ||
}>; | ||
export type BackupMediaCursor = { | ||
readonly backupId: string; | ||
remainingMedia: ReadonlyArray<BackupMediaObject>; | ||
}; | ||
export type ListBackupMediaOptions = Readonly<{ | ||
cursor: string | undefined; | ||
limit: number; | ||
}>; | ||
export type BackupMediaBatchResponse = Readonly<{ | ||
status: number; | ||
failureReason?: string; | ||
cdn: 3; | ||
mediaId: string; | ||
}>; | ||
export type BackupMediaBatchResult = Readonly<{ | ||
responses: ReadonlyArray<BackupMediaBatchResponse>; | ||
}>; | ||
export type AttachmentUploadForm = Readonly<{ | ||
@@ -164,2 +192,4 @@ cdn: 3; | ||
private readonly backupCDNPasswordById; | ||
private readonly backupMediaById; | ||
private readonly backupMediaCursorById; | ||
protected privCertificate: ServerCertificate | undefined; | ||
@@ -236,6 +266,11 @@ protected privZKSecret: ServerSecretParams | undefined; | ||
getBackupInfo(headers: BackupHeaders): Promise<BackupInfo>; | ||
listBackupMedia(headers: BackupHeaders, { cursor, limit }: ListBackupMediaOptions): Promise<BackupMediaList>; | ||
getBackupMediaUploadForm(headers: BackupHeaders): Promise<AttachmentUploadForm>; | ||
getBackupUploadForm(headers: BackupHeaders): Promise<AttachmentUploadForm>; | ||
backupMediaBatch(headers: BackupHeaders, batch: BackupMediaBatch): Promise<BackupMediaBatchResult>; | ||
getBackupCDNAuth(headers: BackupHeaders): Promise<Record<string, string>>; | ||
authorizeBackupCDN(backupId: string, password: string): Promise<boolean>; | ||
getBackupCredentials({ aci }: Device, range: CredentialsRange): Promise<Credentials | undefined>; | ||
getBackupCredentials({ aci, backupLevel }: Device, range: CredentialsRange): Promise<Credentials | undefined>; | ||
protected onNewBackupMediaObject(backupId: string, media: BackupMediaObject): Promise<void>; | ||
protected abstract backupTransitAttachments(backupId: string, batch: BackupMediaBatch): Promise<Array<BackupMediaBatchResponse>>; | ||
abstract isUnregistered(serviceId: ServiceIdString): boolean; | ||
@@ -242,0 +277,0 @@ abstract isSendRateLimited(options: IsSendRateLimitedOptions): boolean; |
@@ -58,2 +58,4 @@ "use strict"; | ||
backupCDNPasswordById = new Map(); | ||
backupMediaById = new Map(); | ||
backupMediaCursorById = new Map(); | ||
privCertificate; | ||
@@ -808,6 +810,46 @@ privZKSecret; | ||
backupDir: backupId, | ||
mediaDir: `media_${backupId}`, | ||
mediaDir: 'media', | ||
backupName: 'backup', | ||
}; | ||
} | ||
async listBackupMedia(headers, { cursor, limit }) { | ||
const backupId = this.authenticateBackup(headers); | ||
let cursorData; | ||
let newCursor; | ||
if (cursor !== undefined) { | ||
cursorData = this.backupMediaCursorById.get(cursor); | ||
} | ||
if (cursorData === undefined) { | ||
newCursor = crypto_1.default.randomBytes(8).toString('hex'); | ||
cursorData = { | ||
backupId, | ||
remainingMedia: this.backupMediaById.get(backupId)?.slice() ?? [], | ||
}; | ||
this.backupMediaCursorById.set(newCursor, cursorData); | ||
} | ||
else { | ||
assert_1.default.strictEqual(cursorData.backupId, backupId); | ||
} | ||
const storedMediaObjects = cursorData.remainingMedia.slice(0, limit); | ||
// End of list | ||
if (storedMediaObjects.length < limit) { | ||
(0, assert_1.default)(newCursor !== undefined); | ||
this.backupMediaCursorById.delete(newCursor); | ||
newCursor = undefined; | ||
} | ||
else { | ||
cursorData.remainingMedia = cursorData.remainingMedia.slice(limit); | ||
} | ||
return { | ||
storedMediaObjects, | ||
backupDir: backupId, | ||
mediaDir: 'media', | ||
cursor: newCursor, | ||
}; | ||
} | ||
async getBackupMediaUploadForm(headers) { | ||
const backupId = this.authenticateBackup(headers); | ||
const form = await this.getAttachmentUploadForm('backups', `transit_${backupId}/${(0, uuid_1.v4)()}`); | ||
return form; | ||
} | ||
async getBackupUploadForm(headers) { | ||
@@ -818,2 +860,7 @@ const backupId = this.authenticateBackup(headers); | ||
} | ||
async backupMediaBatch(headers, batch) { | ||
const backupId = this.authenticateBackup(headers); | ||
const responses = await this.backupTransitAttachments(backupId, batch); | ||
return { responses }; | ||
} | ||
async getBackupCDNAuth(headers) { | ||
@@ -839,3 +886,3 @@ const backupId = this.authenticateBackup(headers); | ||
} | ||
async getBackupCredentials({ aci }, range) { | ||
async getBackupCredentials({ aci, backupLevel }, range) { | ||
const req = this.backupAuthReqByAci.get(aci); | ||
@@ -846,7 +893,13 @@ if (req === undefined) { | ||
return this.issueCredentials(range, (redemptionTime) => { | ||
return req.issueCredential(redemptionTime, | ||
// TODO(indutny): offer different levels | ||
zkgroup_1.BackupLevel.Messages, this.backupServerSecret); | ||
return req.issueCredential(redemptionTime, backupLevel, this.backupServerSecret); | ||
}); | ||
} | ||
async onNewBackupMediaObject(backupId, media) { | ||
let list = this.backupMediaById.get(backupId); | ||
if (list === undefined) { | ||
list = []; | ||
this.backupMediaById.set(backupId, list); | ||
} | ||
list.push(media); | ||
} | ||
// | ||
@@ -853,0 +906,0 @@ // Private |
@@ -369,7 +369,7 @@ "use strict"; | ||
}); | ||
this.router.get('/v1/archives/auth/read', async (_params, _body, headers, params = {}) => { | ||
this.router.get('/v1/archives/auth/read', async (_params, _body, headers, query = {}) => { | ||
if (this.device) { | ||
return [400, { error: 'Extraneous authentication' }]; | ||
} | ||
if (params.cdn !== '3') { | ||
if (query.cdn !== '3') { | ||
return [400, { error: 'Invalid cdn query param' }]; | ||
@@ -393,2 +393,41 @@ } | ||
}); | ||
this.router.get('/v1/archives/media', async (_params, _body, headers, query = {}) => { | ||
if (this.device) { | ||
return [400, { error: 'Extraneous authentication' }]; | ||
} | ||
if (typeof query.limit !== 'string') { | ||
return [400, { error: 'Missing limit param' }]; | ||
} | ||
const limit = parseInt(query.limit, 10); | ||
if (limit <= 0) { | ||
return [400, { error: 'Invalid limit' }]; | ||
} | ||
const cursor = query.cursor; | ||
return [ | ||
200, | ||
await this.server.listBackupMedia(schemas_1.BackupHeadersSchema.parse(headers), { cursor: cursor ? String(cursor) : undefined, limit }), | ||
]; | ||
}); | ||
this.router.get('/v1/archives/media/upload/form', async (_params, _body, headers) => { | ||
if (this.device) { | ||
return [400, { error: 'Extraneous authentication' }]; | ||
} | ||
return [ | ||
200, | ||
await this.server.getBackupMediaUploadForm(schemas_1.BackupHeadersSchema.parse(headers)), | ||
]; | ||
}); | ||
this.router.put('/v1/archives/media/batch', async (_params, body, headers) => { | ||
if (this.device) { | ||
return [400, { error: 'Extraneous authentication' }]; | ||
} | ||
if (!body) { | ||
return [400, { error: 'Missing body' }]; | ||
} | ||
const batch = schemas_1.BackupMediaBatchSchema.parse(JSON.parse(body.toString())); | ||
return [ | ||
200, | ||
await this.server.backupMediaBatch(schemas_1.BackupHeadersSchema.parse(headers), batch), | ||
]; | ||
}); | ||
// | ||
@@ -395,0 +434,0 @@ // Keepalive |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
3884176
71635
6