Implement megaphone conditional standard_donate with local device createdAt

This commit is contained in:
ayumi-signal
2026-01-15 09:40:22 -08:00
committed by GitHub
parent 5528cd37c0
commit 1cfda1f210
16 changed files with 452 additions and 19 deletions

View File

@@ -158,6 +158,61 @@ export function decryptDeviceName(
return Bytes.toString(plaintext);
}
// For testing
export function encryptDeviceCreatedAt(
createdAt: number,
deviceId: number,
registrationId: number,
identityPublic: PublicKey
): Uint8Array {
const createdAtBuffer = new ArrayBuffer(8);
const dataView = new DataView(createdAtBuffer);
dataView.setBigUint64(0, BigInt(createdAt), false);
const createdAtBytes = new Uint8Array(createdAtBuffer);
const associatedData = getAssociatedDataForDeviceCreatedAt(
deviceId,
registrationId
);
return identityPublic.seal(createdAtBytes, 'deviceCreatedAt', associatedData);
}
// createdAtCiphertext is an Int64, encrypted using the identity key
// PrivateKey with 5 bytes of associated data (deviceId || registrationId).
export function decryptDeviceCreatedAt(
createdAtCiphertext: Uint8Array,
deviceId: number,
registrationId: number,
identityPrivate: PrivateKey
): number {
const associatedData = getAssociatedDataForDeviceCreatedAt(
deviceId,
registrationId
);
const createdAtData = identityPrivate.open(
createdAtCiphertext,
'deviceCreatedAt',
associatedData
);
return Number(Bytes.readBigUint64BE(createdAtData));
}
function getAssociatedDataForDeviceCreatedAt(
deviceId: number,
registrationId: number
): Uint8Array {
if (deviceId > 255) {
throw new Error('deviceId above 255, must be 1 byte');
}
const associatedDataBuffer = new ArrayBuffer(5);
const dataView = new DataView(associatedDataBuffer);
dataView.setUint8(0, deviceId);
dataView.setUint32(1, registrationId, false);
return new Uint8Array(associatedDataBuffer);
}
export function deriveMasterKey(accountEntropyPool: string): Uint8Array {
return AccountEntropyPool.deriveSvrKey(accountEntropyPool);
}

View File

@@ -264,7 +264,7 @@ import { ReleaseNoteAndMegaphoneFetcher } from './services/releaseNoteAndMegapho
import { initMegaphoneCheckService } from './services/megaphone.preload.js';
import { BuildExpirationService } from './services/buildExpiration.preload.js';
import {
maybeQueueDeviceNameFetch,
maybeQueueDeviceInfoFetch,
onDeviceNameChangeSync,
} from './util/onDeviceNameChangeSync.preload.js';
import { postSaveUpdates } from './util/cleanup.preload.js';
@@ -1807,7 +1807,7 @@ export async function startApp(): Promise<void> {
// after connect on every startup
drop(registerCapabilities());
drop(ensureAEP());
drop(maybeQueueDeviceNameFetch());
drop(maybeQueueDeviceInfoFetch());
Stickers.downloadQueuedPacks();
}

View File

@@ -51,7 +51,11 @@ export function RemoteMegaphone({
if (isFullSize) {
return (
<div className={wrapperClassName} aria-live="polite">
<div
className={wrapperClassName}
aria-live="polite"
data-testid="RemoteMegaphone"
>
<div className={tw('flex items-start gap-3')}>
{image}
<div className={tw('w-full')}>

View File

@@ -10,7 +10,7 @@ import {
type RemoteMegaphoneType,
type VisibleRemoteMegaphoneType,
} from '../types/Megaphone.std.js';
import { HOUR } from '../util/durations/index.std.js';
import { DAY, HOUR } from '../util/durations/index.std.js';
import { DataReader, DataWriter } from '../sql/Client.preload.js';
import { drop } from '../util/drop.std.js';
import {
@@ -21,10 +21,13 @@ import {
import { isEnabled } from '../RemoteConfig.dom.js';
import { safeSetTimeout } from '../util/timeout.std.js';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js';
import { itemStorage } from '../textsecure/Storage.preload.js';
import { isMoreRecentThan } from '../util/timestamp.std.js';
const log = createLogger('megaphoneService');
const CHECK_INTERVAL = 12 * HOUR;
const CONDITIONAL_STANDARD_DONATE_DEVICE_AGE = 7 * DAY;
let nextCheckTimeout: NodeJS.Timeout | null;
@@ -91,6 +94,44 @@ export function isRemoteMegaphoneEnabled(): boolean {
return false;
}
export function isConditionalActive(conditionalId: string | null): boolean {
if (conditionalId == null) {
return true;
}
if (conditionalId === 'standard_donate') {
const deviceCreatedAt = itemStorage.user.getDeviceCreatedAt();
if (
!deviceCreatedAt ||
isMoreRecentThan(deviceCreatedAt, CONDITIONAL_STANDARD_DONATE_DEVICE_AGE)
) {
return false;
}
const me = window.ConversationController.getOurConversation();
if (!me) {
log.error(
"isConditionalActive: Can't check badges because our conversation not available"
);
return false;
}
const hasBadges = me.attributes.badges && me.attributes.badges.length > 0;
return !hasBadges;
}
if (conditionalId === 'internal_user') {
return isEnabled('desktop.internalUser');
}
if (conditionalId === 'test') {
return isMockEnvironment();
}
log.error(`isConditionalActive: Invalid value ${conditionalId}`);
return false;
}
// Private
async function processMegaphone(megaphone: RemoteMegaphoneType): Promise<void> {
@@ -150,6 +191,10 @@ export function isMegaphoneShowable(
return false;
}
if (!isConditionalActive(megaphone.conditionalId)) {
return false;
}
if (snoozedAt) {
let snoozeDuration;
try {

View File

@@ -38,6 +38,8 @@ import {
decryptAttachmentV1,
padAndEncryptAttachment,
CipherType,
encryptDeviceCreatedAt,
decryptDeviceCreatedAt,
} from '../Crypto.node.js';
import {
_generateAttachmentIv,
@@ -389,6 +391,30 @@ describe('Crypto', () => {
});
});
describe('encrypted device createdAt', () => {
it('roundtrips', () => {
const deviceId = 2;
const registrationId = 123;
const identityKey = Curve.generateKeyPair();
const createdAt = new Date().getTime();
const encrypted = encryptDeviceCreatedAt(
createdAt,
deviceId,
registrationId,
identityKey.publicKey
);
const decrypted = decryptDeviceCreatedAt(
encrypted,
deviceId,
registrationId,
identityKey.privateKey
);
assert.strictEqual(decrypted, createdAt);
});
});
describe('verifyHmacSha256', () => {
it('rejects if their MAC is too short', () => {
const key = getRandomBytes(32);

View File

@@ -5,7 +5,7 @@ import { assert } from 'chai';
import lodash from 'lodash';
import * as sinon from 'sinon';
import { getRandomBytes } from '../../Crypto.node.js';
import { generateRegistrationId, getRandomBytes } from '../../Crypto.node.js';
import { generateKeyPair } from '../../Curve.node.js';
import AccountManager from '../../textsecure/AccountManager.preload.js';
import type {
@@ -32,6 +32,7 @@ describe('AccountManager', () => {
const ourAci = generateAci();
const ourPni = generatePni();
const ourRegistrationId = generateRegistrationId();
const identityKey = generateKeyPair();
const pubKey = getRandomBytes(33);
const privKey = getRandomBytes(32);
@@ -42,6 +43,9 @@ describe('AccountManager', () => {
sandbox
.stub(signalProtocolStore, 'getIdentityKeyPair')
.returns(identityKey);
sandbox
.stub(signalProtocolStore, 'getLocalRegistrationId')
.resolves(ourRegistrationId);
const { user } = itemStorage;
sandbox.stub(user, 'getAci').returns(ourAci);
sandbox.stub(user, 'getPni').returns(ourPni);
@@ -77,6 +81,29 @@ describe('AccountManager', () => {
});
});
describe('encrypted device createdAt', () => {
it('roundtrips', async () => {
const deviceId = 2;
const createdAt = new Date().getTime();
const encrypted = await accountManager._encryptDeviceCreatedAt(
createdAt,
deviceId
);
if (!encrypted) {
throw new Error('failed to encrypt!');
}
assert.strictEqual(typeof encrypted, 'string');
const decrypted = await accountManager.decryptDeviceCreatedAt(
encrypted,
deviceId
);
assert.strictEqual(decrypted, createdAt);
});
});
describe('#_cleanSignedPreKeys', () => {
let originalLoadSignedPreKeys: any;
let originalRemoveSignedPreKey: any;

View File

@@ -0,0 +1,82 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import { expect } from 'playwright/test';
import { StorageState } from '@signalapp/mock-server';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup.js';
import Long from 'long';
import type { App } from '../playwright.node.js';
import { Bootstrap } from '../bootstrap.node.js';
import { MINUTE } from '../../util/durations/index.std.js';
export const debug = createDebug('mock:test:megaphone');
describe('megaphone', function (this: Mocha.Suite) {
let bootstrap: Bootstrap;
let app: App;
let nextApp: App;
this.timeout(MINUTE);
beforeEach(async () => {
bootstrap = new Bootstrap();
await bootstrap.init();
let state = StorageState.getEmpty();
const { phone } = bootstrap;
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
givenName: phone.profileName,
readReceipts: true,
hasCompletedUsernameOnboarding: true,
backupTier: Long.fromNumber(BackupLevel.Free),
});
await phone.setStorageState(state);
app = await bootstrap.link();
});
afterEach(async function (this: Mocha.Context) {
if (!bootstrap) {
return;
}
if (nextApp) {
await bootstrap.maybeSaveLogs(this.currentTest, nextApp);
}
await nextApp?.close();
await bootstrap.teardown();
});
it('shows megaphone', async () => {
const firstWindow = await app.getWindow();
await app.waitForReleaseNoteAndMegaphoneFetcher();
await firstWindow.evaluate(
'window.SignalCI.resetReleaseNoteAndMegaphoneFetcher()'
);
await app.close();
nextApp = await bootstrap.startApp();
const secondWindow = await nextApp.getWindow();
debug('waiting for megaphone');
const megaphoneEl = secondWindow.getByTestId('RemoteMegaphone');
await megaphoneEl.waitFor();
await expect(megaphoneEl.locator('text=/Donate Today/')).toBeVisible();
await expect(megaphoneEl.locator('img')).toBeVisible();
await expect(
megaphoneEl.getByText('Donate', { exact: true })
).toBeVisible();
await expect(megaphoneEl.locator('text=/Not now/')).toBeVisible();
});
});

View File

@@ -70,6 +70,19 @@
"conditionalId": "standard_donate",
"dontShowAfterEpochSeconds": 1734616800,
"secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] }
},
{
"primaryCtaId": "donate",
"secondaryCtaId": "snooze",
"countries": "1:1000000",
"desktopMinVersion": "6.7.0",
"priority": 100,
"dontShowBeforeEpochSeconds": 1732024800,
"uuid": "1A6FCAB5-2F4C-44D3-88B4-27140FACBF75",
"showForNumberOfDays": 30,
"conditionalId": "test",
"dontShowAfterEpochSeconds": 2147485547,
"secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] }
}
]
}

View File

@@ -0,0 +1,8 @@
{
"image": "/static/release-notes/donate-heart.png",
"uuid": "1A6FCAB5-2F4C-44D3-88B4-27140FACBF75",
"title": "Donate Today",
"body": "As a nonprofit, Signal needs your support.",
"primaryCtaText": "Donate",
"secondaryCtaText": "Not now"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -3,6 +3,7 @@
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import sinon from 'sinon';
import {
isMegaphoneCtaIdValid,
@@ -14,6 +15,10 @@ import type {
RemoteMegaphoneId,
RemoteMegaphoneType,
} from '../../types/Megaphone.std.js';
import { generateAci } from '../../types/ServiceId.std.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import type { ConversationController } from '../../ConversationController.preload.js';
import type { ConversationModel } from '../../models/conversations.preload.js';
const FAKE_MEGAPHONE: RemoteMegaphoneType = {
id: uuid() as RemoteMegaphoneId,
@@ -22,7 +27,7 @@ const FAKE_MEGAPHONE: RemoteMegaphoneType = {
dontShowBeforeEpochMs: Date.now() - 1 * DAY,
dontShowAfterEpochMs: Date.now() + 14 * DAY,
showForNumberOfDays: 30,
conditionalId: 'standard_donate',
conditionalId: 'test',
primaryCtaId: 'donate',
primaryCtaData: null,
secondaryCtaId: 'snooze',
@@ -125,4 +130,76 @@ describe('megaphone service', () => {
assert.strictEqual(isMegaphoneShowable(megaphone), false);
});
});
describe('conditionals', () => {
let sandbox: sinon.SinonSandbox;
let deviceCreatedAt: number;
let oldConversationController: ConversationController;
let ourConversation: ConversationModel;
const ourAci = generateAci();
const createMeWithBadges = (
badges: Array<{
id: string;
}>
): ConversationModel => {
const attrs = {
id: 'our-conversation-id',
serviceId: ourAci,
badges,
type: 'private',
sharedGroupNames: [],
version: 0,
expireTimerVersion: 1,
};
return {
...attrs,
attributes: attrs,
} as unknown as ConversationModel;
};
beforeEach(() => {
sandbox = sinon.createSandbox();
sandbox.stub(itemStorage, 'get').callsFake(key => {
if (key === 'deviceCreatedAt') {
return deviceCreatedAt;
}
return undefined;
});
deviceCreatedAt = Date.now();
ourConversation = createMeWithBadges([]);
oldConversationController = window.ConversationController;
window.ConversationController = {
getOurConversation: () => ourConversation,
conversationUpdated: () => undefined,
} as unknown as ConversationController;
});
afterEach(() => {
window.ConversationController = oldConversationController;
sandbox.restore();
});
describe('standard_donate', async () => {
const megaphone = getMegaphone({
conditionalId: 'standard_donate',
});
it('true when desktop has been registered for a week and has no badges', () => {
deviceCreatedAt = Date.now() - 7 * DAY;
assert.strictEqual(isMegaphoneShowable(megaphone), true);
});
it('false with fresh linked desktop', () => {
assert.strictEqual(isMegaphoneShowable(megaphone), false);
});
it('false with badges', () => {
ourConversation = createMeWithBadges([{ id: 'cool' }]);
assert.strictEqual(isMegaphoneShowable(megaphone), false);
});
});
});
});

View File

@@ -43,6 +43,8 @@ import {
encryptDeviceName,
generateRegistrationId,
getRandomBytes,
decryptDeviceCreatedAt,
encryptDeviceCreatedAt,
} from '../Crypto.node.js';
import {
generateKeyPair,
@@ -311,6 +313,58 @@ export default class AccountManager extends EventTarget {
return name;
}
// For testing
async _encryptDeviceCreatedAt(
createdAt: number,
deviceId: number
): Promise<string> {
const ourAci = itemStorage.user.getCheckedAci();
const identityKey = signalProtocolStore.getIdentityKeyPair(ourAci);
const registrationId =
await signalProtocolStore.getLocalRegistrationId(ourAci);
strictAssert(identityKey, 'Missing identity key pair');
strictAssert(registrationId, 'Missing registrationId for our Aci');
const createdAtCiphertextBytes = encryptDeviceCreatedAt(
createdAt,
deviceId,
registrationId,
identityKey.publicKey
);
return Bytes.toBase64(createdAtCiphertextBytes);
}
async decryptDeviceCreatedAt(
createdAtCiphertextBase64: string,
deviceId: number
): Promise<number> {
const ourAci = itemStorage.user.getCheckedAci();
const identityKey = signalProtocolStore.getIdentityKeyPair(ourAci);
if (!identityKey) {
throw new Error('decryptDeviceCreatedAt: No identity key pair!');
}
const registrationId =
await signalProtocolStore.getLocalRegistrationId(ourAci);
if (!registrationId) {
throw new Error('decryptDeviceCreatedAt: No registrationId for our Aci!');
}
const createdAtCiphertextBytes = Bytes.fromBase64(
createdAtCiphertextBase64
);
const createdAt = decryptDeviceCreatedAt(
createdAtCiphertextBytes,
deviceId,
registrationId,
identityKey.privateKey
);
return createdAt;
}
async maybeUpdateDeviceName(): Promise<void> {
const isNameEncrypted = itemStorage.user.getDeviceNameEncrypted();
if (isNameEncrypted) {

View File

@@ -967,7 +967,7 @@ const getDevicesResultZod = z.object({
id: z.number(),
name: z.string().nullish(), // primary devices may not have a name
lastSeen: z.number().nullish(),
created: z.number().nullish(),
createdAtCiphertext: z.string(),
})
),
});

View File

@@ -152,6 +152,14 @@ export class User {
return parseInt(value, 10);
}
public getDeviceCreatedAt(): number | undefined {
return this.storage.get('deviceCreatedAt');
}
public async setDeviceCreatedAt(createdAt: number): Promise<void> {
return this.storage.put('deviceCreatedAt', createdAt);
}
public getDeviceName(): string | undefined {
return this.storage.get('device_name');
}

View File

@@ -83,6 +83,7 @@ export type StorageAccessType = {
customColors: CustomColorsItemType;
device_name: string;
deviceCreatedAt: number;
existingOnboardingStoryMessageIds: ReadonlyArray<string> | undefined;
hasSetMyStoriesPrivacy: boolean;
hasCompletedUsernameOnboarding: boolean;

View File

@@ -27,41 +27,45 @@ export async function onDeviceNameChangeSync(
const { confirm } = event;
const maybeQueueAndThenConfirm = async () => {
await maybeQueueDeviceNameFetch();
await maybeQueueDeviceInfoFetch();
confirm();
};
drop(maybeQueueAndThenConfirm());
}
export async function maybeQueueDeviceNameFetch(): Promise<void> {
export async function maybeQueueDeviceInfoFetch(): Promise<void> {
if (deviceNameFetchQueue.size >= 1) {
log.info('maybeQueueDeviceNameFetch: skipping; fetch already queued');
log.info('maybeQueueDeviceInfoFetch: skipping; fetch already queued');
}
try {
await deviceNameFetchQueue.add(fetchAndUpdateDeviceName);
await deviceNameFetchQueue.add(fetchAndUpdateDeviceInfo);
} catch (e) {
log.error(
'maybeQueueDeviceNameFetch: error when fetching device name',
'maybeQueueDeviceInfoFetch: error when fetching device name',
toLogFormat(e)
);
}
}
async function fetchAndUpdateDeviceName() {
async function fetchAndUpdateDeviceInfo() {
const { devices } = await getDevices();
const localDeviceId = parseIntOrThrow(
itemStorage.user.getDeviceId(),
'fetchAndUpdateDeviceName: localDeviceId'
'fetchAndUpdateDeviceInfo: localDeviceId'
);
const ourDevice = devices.find(device => device.id === localDeviceId);
strictAssert(ourDevice, 'ourDevice must be returned from devices endpoint');
const newNameEncrypted = ourDevice.name;
await maybeUpdateDeviceCreatedAt(
ourDevice.createdAtCiphertext,
localDeviceId
);
const newNameEncrypted = ourDevice.name;
if (!newNameEncrypted) {
log.error('fetchAndUpdateDeviceName: device had empty name');
log.error('fetchAndUpdateDeviceInfo: device had empty name');
return;
}
@@ -71,20 +75,49 @@ async function fetchAndUpdateDeviceName() {
} catch (e) {
const deviceNameWasEncrypted = itemStorage.user.getDeviceNameEncrypted();
log.error(
`fetchAndUpdateDeviceName: failed to decrypt device name. Was encrypted local state: ${deviceNameWasEncrypted}`
`fetchAndUpdateDeviceInfo: failed to decrypt device name. Was encrypted local state: ${deviceNameWasEncrypted}`
);
return;
}
const existingName = itemStorage.user.getDeviceName();
if (newName === existingName) {
log.info('fetchAndUpdateDeviceName: new name matches existing name');
log.info('fetchAndUpdateDeviceInfo: new name matches existing name');
return;
}
await itemStorage.user.setDeviceName(newName);
window.Whisper.events.emit('deviceNameChanged');
log.info(
'fetchAndUpdateDeviceName: successfully updated new device name locally'
'fetchAndUpdateDeviceInfo: successfully updated new device name locally'
);
}
async function maybeUpdateDeviceCreatedAt(
createdAtCiphertext: string,
deviceId: number
): Promise<void> {
const existingCreatedAt = itemStorage.user.getDeviceCreatedAt();
if (existingCreatedAt) {
return;
}
const createdAtEncrypted = createdAtCiphertext;
let createdAt: number | undefined;
try {
createdAt = await accountManager.decryptDeviceCreatedAt(
createdAtEncrypted,
deviceId
);
} catch (e) {
log.error(
'maybeUpdateDeviceCreatedAt: failed to decrypt device createdAt',
toLogFormat(e)
);
}
if (createdAt) {
await itemStorage.user.setDeviceCreatedAt(createdAt);
log.info('maybeUpdateDeviceCreatedAt: saved createdAt');
}
}