使用 Node.js 创建 Safari 推送通知签名

Posted

技术标签:

【中文标题】使用 Node.js 创建 Safari 推送通知签名【英文标题】:Create Safari Push Notification signature with Node.js 【发布时间】:2020-10-10 03:26:10 【问题描述】:

我正在尝试按照 Node.js 中的here 所述实现 Safari 推送通知,以便在 Google Cloud Function 中运行。

我正在尝试使用 forge 创建分离的 PKCS#7 签名,但我的日志记录端点上总是出现 "Signature verification of push package failed" 错误。我尝试以 DER 和 PEM 格式对signature 进行编码,但均未成功。基于 Apple 的 php 示例,他们想要 DER。我也尝试过使用safari push notificationspackage,但没有成功。

代码如下:

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import fs from "fs";
import path from "path";
import express from "express";
import crypto from "crypto";
import forge from "node-forge";
import archiver from "archiver";

const app = express();

const iconFiles = [
    "icon_16x16.png",
    "icon_16x16@2x.png",
    "icon_32x32.png",
    "icon_32x32@2x.png",
    "icon_128x128.png",
    "icon_128x128@2x.png",
];

const websiteJson = 
    websiteName: "...",
    websitePushID: "web.<...>",
    allowedDomains: ["..."],
    urlFormatString: "...",
    authenticationToken: "...",
    webServiceURL: "...",
;

const p12Asn1 = forge.asn1.fromDer(fs.readFileSync("./certs/apple_push.p12", 'binary'));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, functions.config().safari.keypassword);

const certBags = p12.getBags(bagType: forge.pki.oids.certBag);
const certBag = certBags[forge.pki.oids.certBag];
const cert = certBag[0].cert;

const keyBags = p12.getBags(bagType: forge.pki.oids.pkcs8ShroudedKeyBag);
const keyBag = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
const key = keyBag[0].key;

const intermediate = forge.pki.certificateFromPem(fs.readFileSync("./certs/intermediate.pem", "utf8"));

app.post("/:version/pushPackages/:websitePushId", async (req, res) => 
    if (!cert) 
        console.log("cert is null");

        res.sendStatus(500);
        return;
    

    if (!key) 
        console.log("key is null");

        res.sendStatus(500);
        return;
    

    const iconSourceDir = "...";

    res.attachment("pushpackage.zip");

    const archive = archiver("zip");

    archive.on("error", function (err) 
        res.status(500).send( error: err.message );
        return;
    );

    archive.on("warning", function (err) 
        if (err.code === "ENOENT") 
            console.log(`Archive warning $err`);
         else 
            throw err;
        
    );

    archive.on("end", function () 
        console.log("Archive wrote %d bytes", archive.pointer());
    );

    archive.pipe(res);

    archive.directory(iconSourceDir, "icon.iconset");

    const manifest: 
        [key: string]:  hashType: string; hashValue: string ;
     = ;

    const readPromises: Promise<void>[] = [];

    iconFiles.forEach((i) =>
        readPromises.push(
            new Promise((resolve, reject) => 
                const hash = crypto.createHash("sha512");
                const readStream = fs.createReadStream(
                    path.join(iconSourceDir, i),
                     encoding: "utf8" 
                );

                readStream.on("data", (chunk) => 
                    hash.update(chunk);
                );

                readStream.on("end", () => 
                    const digest = hash.digest("hex");
                    manifest[`icon.iconset/$i`] = 
                        hashType: "sha512",
                        hashValue: `$digest`,
                    ;
                    resolve();
                );

                readStream.on("error", (err) => 
                    console.log(`Error on readStream for $i; $err`);
                    reject();
                );
            )
        )
    );

    try 
        await Promise.all(readPromises);
     catch (error) 
        console.log(`Error writing files; $error`);

        res.sendStatus(500);
        return;
    

    const webJSON = 
        ...websiteJson,
        ... authenticationToken: "..." ,
    ;
    const webHash = crypto.createHash("sha512");

    const webJSONString = JSON.stringify(webJSON);

    webHash.update(webJSONString);

    manifest["website.json"] = 
        hashType: "sha512",
        hashValue: `$webHash.digest("hex")`,
    ;

    const manifestJSONString = JSON.stringify(manifest);

    archive.append(webJSONString,  name: "website.json" );
    archive.append(manifestJSONString,  name: "manifest.json" );

    const p7 = forge.pkcs7.createSignedData();
    p7.content = forge.util.createBuffer(manifestJSONString, "utf8");
    p7.addCertificate(cert);
    p7.addCertificate(intermediate);
    p7.addSigner(
        // @ts-ignore
        key,
        certificate: cert,
        digestAlgorithm: forge.pki.oids.sha256,
        authenticatedAttributes: [
            type: forge.pki.oids.contentType,
            value: forge.pki.oids.data
          , 
            type: forge.pki.oids.messageDigest
          , 
            type: forge.pki.oids.signingTime,
            value: new Date().toString()
          ]
    );
    p7.sign( detached: true );

    const pem = forge.pkcs7.messageToPem(p7);
    archive.append(Buffer.from(pem, 'binary'),  name: "signature" );

    // Have also tried this:
    // archive.append(forge.asn1.toDer(p7.toAsn1()).getBytes(),  name: "signature" );

    try 
        await archive.finalize();
     catch (error) 
        console.log(`Error on archive.finalize(); $error`);

        res.sendStatus(500);
        return;
    
);

当我下载并解压缩我的包时,我运行以下命令:

openssl smime -verify -in signature -content manifest.json -inform der -noverify

然后返回:Verification successful

对我哪里出错有什么建议吗?

【问题讨论】:

我遇到了完全相同的问题...您最终找到解决方案了吗? 很遗憾,没有。我还是卡住了。 不知何故我让它工作了。刚刚发布在答案上,希望对您有所帮助! 【参考方案1】:

在测试完所有内容后,使用相同的签名方法,我做到了。没有更多的"Signature verification of push package failed"

由于我在本地检查时也获得了“有效签名”,因此我开始在其他地方寻找根本原因(而不是专注于节点伪造代码)。

我认为重要的一些事情(我在做了一堆更改后尝试过,所以我不确定哪个是解决方案):

1.首先,检查 Web Push Id。 确保 website.json 上的 websitePushID 与您在 Apple 创建 Web Push Certificate 以创建签名时键入的完全相同。我是从网络本身发出的 REST 请求中获取它的,但我完全忘记了这一点,所以在来自网络的调用中,我使用的变体与用于证书的变体不同。 (仔细检查下面的 javacript 代码):

window.safari.pushNotification.requestPermission(
            'https://...',
            WEB_PUSH_ID,  <--- THIS must match p12 cert web id. The Website Push ID.
            ,
            checkRemotePermission         // The callback function.
        );

另外,website.json 本身:

const websiteJson =  
    websiteName: "...",
    websitePushID: WEB_PUSH_ID, // <--- THIS must match p12 cert web id. The Website Push ID.
    allowedDomains: ["..."],
    urlFormatString: "...",
    authenticationToken: "...",
    webServiceURL: "...",
;

2。放置适当的图标资源 可能不是原因,但出于测试目的,我使用了相同的图标,重命名了 6 次,没有缩放。我刚刚为每种尺寸创建了适当的资产。

3.最后是我用来生成签名的sn-p 以防万一。 (请注意,我删除了所有额外的 ``,但可能不是这样,因为我之前得到了相同的结果。

function signature(manifestData, certOrCertPem, privateKeyAssociatedWithCert)

    //A. load the WWWDC cert, always the same
    var intermediateBinnary = fs.readFileSync(Path.resolve('.') + '/AppleWWDRCA.pem', 'utf8')
    //console.log('pem wwwdc ', intermediateBinnary);
    //B. continue signing
    var p7 = forge.pkcs7.createSignedData();
    p7.content = forge.util.createBuffer(manifestData, 'utf8');
    p7.addCertificate(certOrCertPem);
    p7.addSigner(
        key: privateKeyAssociatedWithCert,
        certificate: certOrCertPem,
        digestAlgorithm: forge.pki.oids.sha256
    );
    p7.addCertificate(intermediateBinnary);
    p7.sign(detached: true);
    //console.log('p7: ',p7)

    var pem = forge.pkcs7.messageToPem(p7);
    console.log('pem: ',pem)

    // var lines = pem.split('\n')
    // console.log('lines ',lines);

    // We need to turn into DER according to Apple (sure there are better ways tho)
    var preDer = pem.replace('-----BEGIN PKCS7-----\r\n','');
    preDer = preDer.replace('\r\n-----END PKCS7-----','');
    //console.log('-+pem: ',preDer)

    // var lines = preDer.split('\n')
    // console.log('lines ',lines);

    return preDer;



// I call this signature method from:
...

var contentSignature = signature(contentManifestString, certPem, privatePem);
var bufferFromPem = Buffer.from(contentSignature, 'base64'); 
...
// Just add the bufferFromPem to a file

4.还有一件事。 可能不相关,因为您似乎可以毫无问题地提取包、证书和密钥;但是由于我挣扎,变得空虚和未定义,我将离开这里我是如何做到的

    // Prepare
    var p12 = fs.readFileSync(Path.resolve('.') + '/Cert.p12', 'binary');
    var p12Asn1 = forge.asn1.fromDer(p12, false);
    var p12Parsed = forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, 'HERE_PASSWORD'); 

    // extract bags: https://github.com/digitalbazaar/forge/issues/533
    const keyData = p12Parsed.getBags( bagType: forge.pki.oids.pkcs8ShroudedKeyBag )[forge.pki.oids.pkcs8ShroudedKeyBag]
                    .concat(p12Parsed.getBags( bagType: forge.pki.oids.keyBag )[forge.pki.oids.keyBag]);
    const certBags = p12Parsed.getBags( bagType: forge.pki.oids.certBag )[forge.pki.oids.certBag];
    
    // convert a Forge private key to an ASN.1 RSAPrivateKey
    var rsaPrivateKey = forge.pki.privateKeyToAsn1(keyData[0].key);
    // wrap an RSAPrivateKey ASN.1 object in a PKCS#8 ASN.1 PrivateKeyInfo
    var privateKeyInfo = forge.pki.wrapRsaPrivateKey(rsaPrivateKey);
    // convert a PKCS#8 ASN.1 PrivateKeyInfo to PEM
    var privatePem = forge.pki.privateKeyInfoToPem(privateKeyInfo); // <- KEY
    // Get cert as well (pem)
    var certPem = forge.pki.certificateToPem(certBags[0].cert); // <- CERT

【讨论】:

以上是关于使用 Node.js 创建 Safari 推送通知签名的主要内容,如果未能解决你的问题,请参考以下文章

Azure 移动应用 Node.js 后端 - 推送通知错误状态代码 400

没有 node.JS 的推送系统

Safari 推送通知不起作用

如何发送推送通知 node.js everlive

向百万设备推送通知 + Apns + node.js

在 Openshift 上使用 Node.js 发送 iOS 推送通知