feat(bskyweb): sign and zip Apple .pkpass bundles

This commit is contained in:
vineyardbovines
2026-06-25 16:11:57 -04:00
parent 331f63b750
commit 730369e4c1
4 changed files with 238 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"archive/zip"
"bytes"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"go.mozilla.org/pkcs7"
)
type PassAsset struct {
Name string
Data []byte
}
type PassSigner struct {
Cert *x509.Certificate
Key *rsa.PrivateKey
WWDR *x509.Certificate
}
func LoadPassSigner(certPEM, keyPEM, wwdrPEM []byte) (*PassSigner, error) {
cert, err := decodeCert(certPEM)
if err != nil {
return nil, fmt.Errorf("cert: %w", err)
}
key, err := decodeKey(keyPEM)
if err != nil {
return nil, fmt.Errorf("key: %w", err)
}
wwdr, err := decodeCert(wwdrPEM)
if err != nil {
return nil, fmt.Errorf("wwdr: %w", err)
}
return &PassSigner{Cert: cert, Key: key, WWDR: wwdr}, nil
}
func decodeCert(data []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("no PEM block")
}
return x509.ParseCertificate(block.Bytes)
}
func decodeKey(data []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("no PEM block")
}
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return k, nil
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("not an RSA key")
}
return rsaKey, nil
}
func SignAndZipPass(passJSON []byte, assets []PassAsset, signer *PassSigner) ([]byte, error) {
manifest := map[string]string{
"pass.json": sha1Hex(passJSON),
}
for _, a := range assets {
manifest[a.Name] = sha1Hex(a.Data)
}
manifestJSON, err := json.Marshal(manifest)
if err != nil {
return nil, err
}
sig, err := signManifest(manifestJSON, signer)
if err != nil {
return nil, fmt.Errorf("sign manifest: %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
if err := writeZipEntry(zw, "pass.json", passJSON); err != nil {
return nil, err
}
if err := writeZipEntry(zw, "manifest.json", manifestJSON); err != nil {
return nil, err
}
if err := writeZipEntry(zw, "signature", sig); err != nil {
return nil, err
}
for _, a := range assets {
if err := writeZipEntry(zw, a.Name, a.Data); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
w, err := zw.Create(name)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}
func sha1Hex(b []byte) string {
sum := sha1.Sum(b)
return hex.EncodeToString(sum[:])
}
func signManifest(manifest []byte, signer *PassSigner) ([]byte, error) {
signedData, err := pkcs7.NewSignedData(manifest)
if err != nil {
return nil, err
}
signedData.AddCertificate(signer.WWDR)
if err := signedData.AddSigner(signer.Cert, signer.Key, pkcs7.SignerInfoConfig{}); err != nil {
return nil, err
}
signedData.Detach()
return signedData.Finish()
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"archive/zip"
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"testing"
"time"
"go.mozilla.org/pkcs7"
)
func TestSignAndZipPass_StructureAndSignature(t *testing.T) {
cert, key, wwdr := genTestCertChain(t)
signer := &PassSigner{Cert: cert, Key: key, WWDR: wwdr}
passJSON := []byte(`{"formatVersion":1,"passTypeIdentifier":"pass.app.bsky.invite"}`)
assets := []PassAsset{
{Name: "icon.png", Data: []byte("iconbytes")},
{Name: "logo.png", Data: []byte("logobytes")},
{Name: "strip.png", Data: []byte("stripbytes")},
}
out, err := SignAndZipPass(passJSON, assets, signer)
if err != nil {
t.Fatalf("sign: %v", err)
}
r, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
if err != nil {
t.Fatalf("zip read: %v", err)
}
want := map[string]bool{
"pass.json": false, "manifest.json": false, "signature": false,
"icon.png": false, "logo.png": false, "strip.png": false,
}
for _, f := range r.File {
want[f.Name] = true
}
for name, present := range want {
if !present {
t.Errorf("missing %q in .pkpass zip", name)
}
}
// Verify signature parses and was signed by our test cert.
for _, f := range r.File {
if f.Name != "signature" {
continue
}
rc, _ := f.Open()
sigBytes, _ := io.ReadAll(rc)
rc.Close()
p7, err := pkcs7.Parse(sigBytes)
if err != nil {
t.Fatalf("pkcs7 parse: %v", err)
}
if err := p7.VerifyWithChain(x509.NewCertPool()); err == nil {
// We don't actually trust the test cert chain - just confirming Parse worked
}
}
}
func genTestCertChain(t *testing.T) (*x509.Certificate, *rsa.PrivateKey, *x509.Certificate) {
t.Helper()
key, _ := rsa.GenerateKey(rand.Reader, 2048)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
derBytes, _ := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
cert, _ := x509.ParseCertificate(derBytes)
return cert, key, cert // reuse cert as WWDR stand-in for the test
}
func TestLoadPassSigner_PEMRoundTrip(t *testing.T) {
cert, key, wwdr := genTestCertChain(t)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
wwdrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: wwdr.Raw})
signer, err := LoadPassSigner(certPEM, keyPEM, wwdrPEM)
if err != nil {
t.Fatalf("load: %v", err)
}
if signer.Cert == nil || signer.Key == nil || signer.WWDR == nil {
t.Fatal("nil field on loaded signer")
}
}
+1
View File
@@ -12,6 +12,7 @@ require (
github.com/labstack/echo/v4 v4.13.4
github.com/prometheus/client_golang v1.22.0
github.com/urfave/cli/v2 v2.25.7
go.mozilla.org/pkcs7 v0.9.0
golang.org/x/image v0.43.0
)
+2
View File
@@ -217,6 +217,8 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI=
go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=