Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ components:
type: "boolean"
level_tips:
type: "boolean"
redacted:
type: "boolean"
description: "when true, server sends generic push text and omits data payload"
lang:
type: "string"
app_version:
Expand Down
59 changes: 40 additions & 19 deletions src/class/GroundControlToMajorTom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const auth = new GoogleAuth({
* @see https://firebase.google.com/docs/cloud-messaging/http-server-ref
*/
export class GroundControlToMajorTom {
protected static readonly REDACTED_ALERT = { title: "BlueWallet", body: "You have a new notification" };
protected static _jwtToken: string = "";
protected static _jwtTokenMicroTimestamp: number = 0;

Expand Down Expand Up @@ -160,7 +161,7 @@ export class GroundControlToMajorTom {
};

if (pushNotification.os === "android") return GroundControlToMajorTom._pushToFcm(dataSource, serverKey, pushNotification.token, fcmPayload, pushNotification);
if (pushNotification.os === "ios") return GroundControlToMajorTom._pushToApns(dataSource, apnsP8, pushNotification.token, apnsPayload, pushNotification, pushNotification.txid);
if (pushNotification.os === "ios") return GroundControlToMajorTom._pushToApns(dataSource, apnsP8, pushNotification.token, apnsPayload, pushNotification);
}

static async pushOnchainAddressWasPaid(dataSource: DataSource, serverKey: string, apnsP8: string, pushNotification: components["schemas"]["PushNotificationOnchainAddressGotPaid"]): Promise<void> {
Expand Down Expand Up @@ -224,24 +225,37 @@ export class GroundControlToMajorTom {
if (pushNotification.os === "ios") return GroundControlToMajorTom._pushToApns(dataSource, apnsP8, pushNotification.token, apnsPayload, pushNotification, pushNotification.hash);
}

protected static async _pushToApns(dataSource: DataSource, apnsP8: string, token: string, apnsPayload: object, pushNotification: components["schemas"]["PushNotificationBase"], collapseId): Promise<void> {
// collapse-id carries the sensitive preimage hash / txid
// never ship it on a redacted push
protected static _apnsHeaders(token: string, apnsP8: string, collapseId: string | undefined, redacted: boolean): Record<string, any> {
const headers: Record<string, any> = {
":method": "POST",
"apns-topic": process.env.APNS_TOPIC,
"apns-expiration": Math.floor(+new Date() / 1000 + 3600 * 24),
":scheme": "https",
":path": "/3/device/" + token,
authorization: `bearer ${apnsP8}`,
};
if (!redacted && collapseId) headers["apns-collapse-id"] = collapseId;
return headers;
}

protected static async _pushToApns(dataSource: DataSource, apnsP8: string, token: string, apnsPayload: object, pushNotification: components["schemas"]["PushNotificationBase"], collapseId?: string): Promise<void> {
if (pushNotification["redacted"]) {
apnsPayload["aps"]["alert"] = GroundControlToMajorTom.REDACTED_ALERT;
apnsPayload["data"] = {};
}
return new Promise(function (resolve) {
// we pass some of the notification properties as data properties to FCM payload:
for (let dataKey of Object.keys(pushNotification)) {
if (["token", "os", "badge", "level"].includes(dataKey)) continue;
apnsPayload["data"][dataKey] = pushNotification[dataKey];
if (!pushNotification["redacted"]) {
// we pass some of the notification properties as data properties to FCM payload:
for (let dataKey of Object.keys(pushNotification)) {
if (["token", "os", "badge", "level", "redacted"].includes(dataKey)) continue;
apnsPayload["data"][dataKey] = pushNotification[dataKey];
}
}
const client = http2.connect("https://api.push.apple.com");
client.on("error", (err) => console.error(err));
const headers = {
":method": "POST",
"apns-topic": process.env.APNS_TOPIC,
"apns-collapse-id": collapseId,
"apns-expiration": Math.floor(+new Date() / 1000 + 3600 * 24),
":scheme": "https",
":path": "/3/device/" + token,
authorization: `bearer ${apnsP8}`,
};
const headers = GroundControlToMajorTom._apnsHeaders(token, apnsP8, collapseId, !!pushNotification["redacted"]);
const request = client.request(headers);

let responseJson = {};
Expand Down Expand Up @@ -303,10 +317,17 @@ export class GroundControlToMajorTom {
protected static async _pushToFcm(dataSource: DataSource, bearer: string, token: string, fcmPayload: object, pushNotification: components["schemas"]["PushNotificationBase"]): Promise<void> {
fcmPayload["message"]["token"] = token;

// now, we pass some of the notification properties as data properties to FCM payload:
for (let dataKey of Object.keys(pushNotification)) {
if (["token", "os", "badge"].includes(dataKey)) continue;
fcmPayload["message"]["data"][dataKey] = String(pushNotification[dataKey]);
if (pushNotification["redacted"]) {
fcmPayload["message"]["notification"] = GroundControlToMajorTom.REDACTED_ALERT;
fcmPayload["message"]["data"] = {};
Comment thread
r6mez marked this conversation as resolved.
}

if (!pushNotification["redacted"]) {
// now, we pass some of the notification properties as data properties to FCM payload:
for (let dataKey of Object.keys(pushNotification)) {
Comment thread
r6mez marked this conversation as resolved.
if (["token", "os", "badge", "level", "redacted"].includes(dataKey)) continue;
Comment thread
r6mez marked this conversation as resolved.
fcmPayload["message"]["data"][dataKey] = String(pushNotification[dataKey]);
}
}

// @ts-ignore
Expand Down
20 changes: 11 additions & 9 deletions src/controller/GroundController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,17 +260,18 @@ export class GroundController {
tokenConfig = new TokenConfiguration();
tokenConfig.token = body.token;
tokenConfig.os = body.os;
} else {
if (typeof body.level_all !== "undefined") tokenConfig.level_all = !!body.level_all;
if (typeof body.level_transactions !== "undefined") tokenConfig.level_transactions = !!body.level_transactions;
if (typeof body.level_price !== "undefined") tokenConfig.level_price = !!body.level_price;
if (typeof body.level_news !== "undefined") tokenConfig.level_news = !!body.level_news;
if (typeof body.level_tips !== "undefined") tokenConfig.level_tips = !!body.level_tips;
if (typeof body.lang !== "undefined") tokenConfig.lang = String(body.lang);
if (typeof body.app_version !== "undefined") tokenConfig.app_version = String(body.app_version);
tokenConfig.last_online = new Date();
}

if (typeof body.level_all !== "undefined") tokenConfig.level_all = !!body.level_all;
if (typeof body.level_transactions !== "undefined") tokenConfig.level_transactions = !!body.level_transactions;
if (typeof body.level_price !== "undefined") tokenConfig.level_price = !!body.level_price;
if (typeof body.level_news !== "undefined") tokenConfig.level_news = !!body.level_news;
if (typeof body.level_tips !== "undefined") tokenConfig.level_tips = !!body.level_tips;
if (typeof body.redacted !== "undefined") tokenConfig.redacted = !!body.redacted;
if (typeof body.lang !== "undefined") tokenConfig.lang = String(body.lang);
if (typeof body.app_version !== "undefined") tokenConfig.app_version = String(body.app_version);
tokenConfig.last_online = new Date();

try {
await this.tokenConfigurationRepository.save(tokenConfig);
} catch (error) {
Expand Down Expand Up @@ -305,6 +306,7 @@ export class GroundController {
level_price: tokenConfig.level_price,
level_transactions: tokenConfig.level_transactions,
level_tips: tokenConfig.level_tips,
redacted: tokenConfig.redacted,
lang: tokenConfig.lang,
app_version: tokenConfig.app_version,
};
Expand Down
3 changes: 3 additions & 0 deletions src/entity/TokenConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export class TokenConfiguration {
@Column({ default: true })
level_tips: boolean;

@Column({ default: false })
redacted: boolean;

@Column({ default: "en" })
lang: string;

Expand Down
2 changes: 2 additions & 0 deletions src/openapi/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export type components = {
level_news?: boolean;
level_price?: boolean;
level_tips?: boolean;
/** @description when true, server sends generic push text and omits data payload */
redacted?: boolean;
lang?: string;
app_version?: string;
} & { [key: string]: unknown };
Expand Down
50 changes: 50 additions & 0 deletions src/tests/GroundControlToMajorTom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@

await GroundControlToMajorTom.pushMessage(mockDataSource, "server-key", "apns-p8", iosPushNotification);

expect(mockApnsPush).toHaveBeenCalledWith(

Check failure on line 320 in src/tests/GroundControlToMajorTom.test.ts

View workflow job for this annotation

GitHub Actions / test

src/tests/GroundControlToMajorTom.test.ts > GroundControlToMajorTom > pushMessage > should call APNS for iOS devices

AssertionError: expected "_pushToApns" to be called with arguments: [ …(6) ] Received: 1st _pushToApns call: @@ -2,25 +2,26 @@ { "getRepository": [Function spy], }, "apns-p8", "mock-token", - ObjectContaining { - "aps": ObjectContaining { - "alert": ObjectContaining { + { + "aps": { + "alert": { "body": "Hello, this is a test message!", "title": "Message", }, "badge": 1, + "sound": "default", }, + "data": {}, }, { "badge": 1, "level": "transactions", "os": "ios", "text": "Hello, this is a test message!", "token": "mock-token", "txid": "optional-txid", "type": 5, }, - "optional-txid", ] Number of calls: 1 ❯ src/tests/GroundControlToMajorTom.test.ts:320:28
mockDataSource,
"apns-p8",
"mock-token",
Expand Down Expand Up @@ -612,6 +612,25 @@
);
});

it("should redact text and strip data payload when pushNotification.redacted is set", async () => {
Comment thread
r6mez marked this conversation as resolved.
const mockResponse = { text: vi.fn().mockResolvedValue(JSON.stringify({ name: "projects/mock/messages/123" })) };
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
vi.spyOn(GroundControlToMajorTom, "processFcmResponse").mockReturnValue(true);

const fcmPayload = {
message: { token: "", data: { badge: "1" }, notification: { title: "+1000 sats", body: "Received on bc1qx....0wlh" } },
};
const pushNotification: any = { type: 2, token: "test-token", os: "android", badge: 1, level: "transactions", address: "bc1qsecret", txid: "deadbeef", sat: 1000, redacted: true };

await (GroundControlToMajorTom as any)._pushToFcm(mockDataSource, "bearer-token", "test-token", fcmPayload, pushNotification);

const sentBody = vi.mocked(global.fetch).mock.calls[0][1].body as string;
expect(sentBody).toContain("You have a new notification");
expect(sentBody).not.toContain("bc1qsecret");
expect(sentBody).not.toContain("deadbeef");
expect(fcmPayload.message.data).toEqual({});
});

it("should handle FCM network errors gracefully", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));

Expand All @@ -635,4 +654,35 @@
await expect((GroundControlToMajorTom as any)._pushToFcm(mockDataSource, "bearer-token", "test-token", fcmPayload, pushNotification)).rejects.toThrow("Network error");
});
});

describe("_pushToApns", () => {
it("should redact alert and strip data payload when pushNotification.redacted is set", async () => {
const apnsPayload: any = {
aps: { badge: 1, alert: { title: "+1000 sats", body: "Received on bc1qx....0wlh" }, sound: "default" },
data: {},
};
const pushNotification: any = { type: 2, token: "test-token", os: "ios", badge: 1, level: "transactions", address: "bc1qsecret", txid: "deadbeef", sat: 1000, redacted: true };

await (GroundControlToMajorTom as any)._pushToApns(mockDataSource, "apns-p8", "test-token", apnsPayload, pushNotification, "deadbeef").catch(() => {});
Comment thread
r6mez marked this conversation as resolved.

expect(apnsPayload.aps.alert).toEqual({ title: "BlueWallet", body: "You have a new notification" });
expect(apnsPayload.data).toEqual({});
const serialized = JSON.stringify(apnsPayload);
expect(serialized).not.toContain("bc1qsecret");
expect(serialized).not.toContain("deadbeef");
});
});

describe("_apnsHeaders", () => {
it("omits apns-collapse-id (the sensitive preimage hash) on a redacted push", () => {
const headers = (GroundControlToMajorTom as any)._apnsHeaders("test-token", "apns-p8", "deadbeef", true);
expect(headers["apns-collapse-id"]).toBeUndefined();
expect(JSON.stringify(headers)).not.toContain("deadbeef");
});

it("sends apns-collapse-id on a non-redacted push", () => {
const headers = (GroundControlToMajorTom as any)._apnsHeaders("test-token", "apns-p8", "deadbeef", false);
expect(headers["apns-collapse-id"]).toBe("deadbeef");
});
});
});
2 changes: 2 additions & 0 deletions src/worker-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ dataSource
continue;
}

payload.redacted = !!tokenConfig.redacted;
Comment thread
r6mez marked this conversation as resolved.

const timeoutId = setTimeout(() => {
console.error("timeout pushing to token, comitting suicide");
process.exit(2);
Expand Down
Loading