feat(bskyweb): sign and zip Apple .pkpass bundles
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user