feat(sign): add sign command to sign Emacs.app bundles with codesign

The sign command signs Emacs.app application bundles with Apple's
codesign utility.

It does a few things outside of just executing codesign:

- Is aware of *.eln native-compilation files, which need to be
  explicitly searched for on disk and passed to codesign, as they are
  not detected when using the "--deep" option.
- Is aware of Contents/MacOS/bin/emacs CLI helper tool which we add into
  the application bundle, and specifically passed it to codesign as
  well.
- By default provides a set of entitlements which are relevant for Emacs
  when running codesign.
This commit is contained in:
2021-05-23 18:30:10 +01:00
parent 1ffd735c23
commit 698756ac55
10 changed files with 545 additions and 2 deletions

3
go.mod
View File

@@ -9,9 +9,10 @@ require (
github.com/google/go-github/v35 v35.3.0
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-hclog v0.16.1
github.com/jimeh/undent v1.1.0
github.com/mattn/go-isatty v0.0.13 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/testify v1.6.1 // indirect
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.3.0
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect

7
go.sum
View File

@@ -117,6 +117,8 @@ github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jimeh/undent v1.1.0 h1:Cge7P4Ws6buy0SVuHBluY/aOKdFuJUMzoJswfAHZ4zE=
github.com/jimeh/undent v1.1.0/go.mod h1:oxYCIzdbyQNy8GXnCnjRJ2NS6Uq4p4yWoeawiGFqoHI=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -136,6 +138,8 @@ github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rhysd/go-fakeio v1.0.0 h1:+TjiKCOs32dONY7DaoVz/VPOdvRkPfBkEyUDIpM8FQY=
github.com/rhysd/go-fakeio v1.0.0/go.mod h1:joYxF906trVwp2JLrE4jlN7A0z6wrz8O6o1UjarbFzE=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -144,8 +148,9 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

View File

@@ -45,6 +45,7 @@ func New(version, commit, date string) *CLI {
},
Commands: []*cli2.Command{
planCmd(),
signCmd(),
{
Name: "version",
Usage: "print the version",

114
pkg/cli/sign.go Normal file
View File

@@ -0,0 +1,114 @@
package cli
import (
"os"
"path/filepath"
"github.com/jimeh/build-emacs-for-macos/pkg/plan"
"github.com/jimeh/build-emacs-for-macos/pkg/sign"
cli2 "github.com/urfave/cli/v2"
)
func signCmd() *cli2.Command {
return &cli2.Command{
Name: "sign",
Usage: "sign a Emacs.app bundle with codesign",
ArgsUsage: "<emacs-app>",
Flags: []cli2.Flag{
&cli2.StringFlag{
Name: "sign",
Aliases: []string{"s"},
Usage: "signing identity passed to codeside",
EnvVars: []string{"AC_SIGN_IDENTITY"},
Required: true,
},
&cli2.StringSliceFlag{
Name: "entitlements",
Aliases: []string{"e"},
Usage: "comma-separated list of entitlements to enable",
Value: cli2.NewStringSlice(sign.DefaultEmacsEntitlements...),
},
&cli2.BoolFlag{
Name: "deep",
Aliases: []string{"d"},
Usage: "pass --deep to codesign",
Value: true,
},
&cli2.BoolFlag{
Name: "timestamp",
Aliases: []string{"t"},
Usage: "pass --timestamp to codesign",
Value: true,
},
&cli2.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "pass --force to codesign",
Value: true,
},
&cli2.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "pass --verbose to codesign",
Value: false,
},
&cli2.StringSliceFlag{
Name: "options",
Aliases: []string{"o"},
Usage: "options passed to codesign",
Value: cli2.NewStringSlice("runtime"),
},
&cli2.StringFlag{
Name: "codesign",
Usage: "specify custom path to codesign executable",
},
&cli2.StringFlag{
Name: "plan",
Usage: "path to build plan YAML file produced by " +
"emacs-builder plan",
Aliases: []string{"p"},
EnvVars: []string{"EMACS_BUILDER_PLAN"},
TakesFile: true,
},
},
Action: actionWrapper(signAction),
}
}
func signAction(c *cli2.Context, opts *Options) error {
signOpts := &sign.Options{
Identity: c.String("sign"),
Options: c.StringSlice("options"),
Deep: c.Bool("deep"),
Timestamp: c.Bool("timestamp"),
Force: c.Bool("force"),
Verbose: c.Bool("verbose"),
CodeSignCmd: c.String("codesign"),
}
if v := c.StringSlice("entitlements"); len(v) > 0 {
e := sign.Entitlements(v)
signOpts.Entitlements = &e
}
if !opts.quiet {
signOpts.Output = os.Stdout
}
app := c.Args().Get(0)
if f := c.String("plan"); f != "" {
p, err := plan.Load(f)
if err != nil {
return err
}
if p.Output != nil && p.Build != nil {
app = filepath.Join(
p.Output.Directory, p.Build.Name, "Emacs.app",
)
}
}
return sign.Emacs(c.Context, app, signOpts)
}

159
pkg/sign/emacs.go Normal file
View File

@@ -0,0 +1,159 @@
package sign
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/hashicorp/go-hclog"
)
// Emacs signs a Emacs.app application bundle with Apple's codesign utility,
// using correct default entitlements, and also pre-signing any *.eln files
// which are in the bundle, as codesign will not detect them as requiring
// signing even with the --deep flag.
func Emacs(ctx context.Context, appBundle string, opts *Options) error {
if !strings.HasSuffix(appBundle, ".app") {
return fmt.Errorf("%s is not a .app application bundle", appBundle)
}
appBundle, err := filepath.Abs(appBundle)
if err != nil {
return err
}
_, err = os.Stat(appBundle)
if err != nil {
return err
}
logger := hclog.FromContext(ctx).Named("sign")
logger.Info("preparing to sign Emacs.app", "app", appBundle)
newOpts := *opts
if newOpts.EntitlementsFile == "" {
if newOpts.Entitlements == nil {
e := Entitlements(DefaultEmacsEntitlements)
newOpts.Entitlements = &e
}
f, err2 := newOpts.Entitlements.TempFile()
if err2 != nil {
return err2
}
defer os.Remove(f)
newOpts.EntitlementsFile = f
newOpts.Entitlements = nil
}
err = signElnFiles(ctx, appBundle, &newOpts)
if err != nil {
return err
}
err = signCLIHelper(ctx, appBundle, &newOpts)
if err != nil {
return err
}
// Ensure app bundle is signed last, as modifications to the bundle after
// signing will invalidate the signature. Hence anything within it that
// needs to be separately signed, has to happen before signing the whole
// application bundle.
return Files(ctx, []string{appBundle}, &newOpts)
}
func signElnFiles(ctx context.Context, appBundle string, opts *Options) error {
logger := hclog.FromContext(ctx).Named("sign")
elnFiles, err := elnFiles(appBundle)
if err != nil {
return err
}
if len(elnFiles) == 0 {
return nil
}
logger.Info(fmt.Sprintf(
"found %d native-lisp *.eln files in %s to sign",
len(elnFiles), filepath.Base(appBundle),
))
for _, file := range elnFiles {
err := Files(ctx, []string{file}, opts)
if err != nil {
return err
}
}
return nil
}
func signCLIHelper(ctx context.Context, appBundle string, opts *Options) error {
logger := hclog.FromContext(ctx).Named("sign")
cliHelper := filepath.Join(appBundle, "Contents", "MacOS", "bin", "emacs")
fi, err := os.Stat(cliHelper)
if err != nil && !os.IsNotExist(err) {
return err
} else if err == nil && fi.Mode().IsRegular() {
logger.Info(fmt.Sprintf(
"found Contents/MacOS/bin/emacs CLI helper script in %s to sign",
filepath.Base(appBundle),
))
err = Files(ctx, []string{cliHelper}, opts)
if err != nil {
return err
}
}
return nil
}
// elnFiles finds all native-compilation *.eln files within a Emacs.app bundle,
// based on expected paths they might be stored in.
func elnFiles(emacsApp string) ([]string, error) {
dirs := []string{
// Current *.eln location.
filepath.Join(emacsApp, "Contents", "Resources", "native-lisp"),
// Legacy *.eln location.
filepath.Join(emacsApp, "Contents", "MacOS", "lib", "emacs"),
}
var files []string
walkDirFunc := func(path string, _d fs.DirEntry, _err error) error {
if strings.HasSuffix(path, ".eln") {
files = append(files, path)
}
return nil
}
for _, dir := range dirs {
fi, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
if !fi.IsDir() {
continue
}
err = filepath.WalkDir(dir, walkDirFunc)
if err != nil {
return nil, err
}
}
return files, nil
}

54
pkg/sign/entitlements.go Normal file
View File

@@ -0,0 +1,54 @@
package sign
import (
"bytes"
_ "embed"
"io"
"os"
"text/template"
)
// DefaultEmacsEntitlements is the default set of entitlements application
// bundles are signed with if no entitlements are provided.
var DefaultEmacsEntitlements = []string{
"com.apple.security.cs.allow-jit",
"com.apple.security.network.client",
"com.apple.security.cs.disable-library-validation",
"com.apple.security.automation.apple-events",
}
//go:embed entitlements.tpl
var entitlementsTemplate string
type Entitlements []string
func (e Entitlements) XML() ([]byte, error) {
var buf bytes.Buffer
err := e.Write(&buf)
return buf.Bytes(), err
}
func (e Entitlements) Write(w io.Writer) error {
tpl, err := template.New("entitlements.plist").Parse(entitlementsTemplate)
if err != nil {
return err
}
return tpl.Execute(w, e)
}
func (e Entitlements) TempFile() (string, error) {
f, err := os.CreateTemp("", "*.entitlements.plist")
if err != nil {
return "", err
}
defer f.Close()
err = e.Write(f)
if err != nil {
return "", err
}
return f.Name(), nil
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
{{- range . }}
<key>{{ . }}</key>
<true/>{{ end }}
</dict>
</plist>

View File

@@ -0,0 +1,117 @@
package sign
import (
"bytes"
"os"
"strings"
"testing"
"github.com/jimeh/undent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var entitlementsTestCases = []struct {
name string
entitlements Entitlements
want string
}{
{
name: "none",
entitlements: Entitlements{},
//nolint:lll
want: undent.String(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>`,
),
},
{
name: "one",
entitlements: Entitlements{"com.apple.security.cs.allow-jit"},
//nolint:lll
want: undent.String(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>`,
),
},
{
name: "many",
entitlements: Entitlements{
"com.apple.security.cs.allow-jit",
"com.apple.security.network.client",
"com.apple.security.cs.disable-library-validation",
"com.apple.security.automation.apple-events",
},
//nolint:lll
want: undent.String(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>`,
),
},
}
func TestDefaultEmacsEntitlements(t *testing.T) {
assert.Equal(t,
[]string{
"com.apple.security.cs.allow-jit",
"com.apple.security.network.client",
"com.apple.security.cs.disable-library-validation",
"com.apple.security.automation.apple-events",
},
DefaultEmacsEntitlements,
)
}
func TestEntitlements_Write(t *testing.T) {
for _, tt := range entitlementsTestCases {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
err := tt.entitlements.Write(&buf)
require.NoError(t, err)
assert.Equal(t, tt.want, strings.TrimSpace(buf.String()))
})
}
}
func TestEntitlements_TempFile(t *testing.T) {
for _, tt := range entitlementsTestCases {
t.Run(tt.name, func(t *testing.T) {
tmpFile, err := tt.entitlements.TempFile()
require.NoError(t, err)
defer os.Remove(tmpFile)
content, err := os.ReadFile(tmpFile)
require.NoError(t, err)
assert.Equal(t, tt.want, strings.TrimSpace(string(content)))
assert.True(t,
strings.HasSuffix(tmpFile, ".entitlements.plist"),
"temp file name does not match \"*.entitlements.plist\"",
)
})
}
}

67
pkg/sign/files.go Normal file
View File

@@ -0,0 +1,67 @@
package sign
import (
"context"
"os"
"os/exec"
"strings"
"github.com/hashicorp/go-hclog"
)
func Files(ctx context.Context, files []string, opts *Options) error {
logger := hclog.FromContext(ctx).Named("sign")
args := []string{}
if opts.Identity != "" {
args = append(args, "--sign", opts.Identity)
}
if opts.Deep {
args = append(args, "--deep")
}
if opts.Timestamp {
args = append(args, "--timestamp")
}
if opts.Force {
args = append(args, "--force")
}
if opts.Verbose {
args = append(args, "--verbose")
}
if len(opts.Options) > 0 {
args = append(args, "--options", strings.Join(opts.Options, ","))
}
if opts.EntitlementsFile != "" {
args = append(args, "--entitlements", opts.EntitlementsFile)
} else if opts.Entitlements != nil {
entitlementsFile, err := opts.Entitlements.TempFile()
if err != nil {
return err
}
defer os.Remove(entitlementsFile)
logger.Debug("wrote entitlements", "file", entitlementsFile)
args = append(args, "--entitlements", entitlementsFile)
}
baseCmd := opts.CodeSignCmd
if baseCmd == "" {
path, err := exec.LookPath("codesign")
if err != nil {
return err
}
baseCmd = path
}
args = append(args, files...)
logger.Debug("executing", "command", baseCmd, "args", args)
cmd := exec.CommandContext(ctx, baseCmd, args...)
if opts.Output != nil {
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
}
return cmd.Run()
}

16
pkg/sign/options.go Normal file
View File

@@ -0,0 +1,16 @@
package sign
import "io"
type Options struct {
Identity string
Entitlements *Entitlements
EntitlementsFile string
Options []string
Deep bool
Timestamp bool
Force bool
Verbose bool
Output io.Writer
CodeSignCmd string
}