Skip to content
Merged
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
139 changes: 139 additions & 0 deletions spec/util-entitlements.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import plist from 'plist';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { preAutoEntitlements } from '../src/util-entitlements.js';
import { Identity } from '../src/util-identities.js';
import { ProvisioningProfile } from '../src/util-provisioning-profiles.js';
import type { ValidatedSignOptions } from '../src/types.js';

const IDENTITY = new Identity('Developer ID Application: Example Corp (IDENTTEAM)');
const PROFILE = new ProvisioningProfile('/dev/null/example.provisionprofile', {
Name: 'Example',
Entitlements: { 'com.apple.developer.team-identifier': 'PROFTEAM00' },
});

let tmp: string;
let counter = 0;

/**
* Every fixture gets its own app + entitlements path because preAutoEntitlements
* memoizes on them for the lifetime of the process.
*/
function fixture(entitlements: Record<string, unknown>): {
opts: ValidatedSignOptions;
entitlementsPath: string;
infoPlistPath: string;
} {
const dir = path.join(tmp, `case-${counter++}`);
const app = path.join(dir, 'Fixture.app');
const infoPlistPath = path.join(app, 'Contents', 'Info.plist');
fs.mkdirSync(path.dirname(infoPlistPath), { recursive: true });
fs.writeFileSync(infoPlistPath, plist.build({ CFBundleIdentifier: 'com.example.fixture' }));
const entitlementsPath = path.join(dir, 'entitlements.plist');
fs.writeFileSync(entitlementsPath, plist.build(entitlements));
return {
opts: { app, platform: 'darwin', type: 'distribution' },
entitlementsPath,
infoPlistPath,
};
}

function readPlist(file: string): Record<string, any> {
return plist.parse(fs.readFileSync(file, 'utf8')) as Record<string, any>;
}

describe('preAutoEntitlements', () => {
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'osx-sign-entitlements-'));
});

afterAll(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

it('does nothing for a non-sandboxed app without a provisioning profile', async () => {
const { opts, entitlementsPath, infoPlistPath } = fixture({
'com.apple.security.cs.allow-jit': true,
});
const before = fs.readFileSync(infoPlistPath, 'utf8');
await expect(
preAutoEntitlements(opts, { entitlements: entitlementsPath }, { identity: IDENTITY }),
).resolves.toBeUndefined();
expect(fs.readFileSync(infoPlistPath, 'utf8')).toBe(before);
});

it('injects the identifier entitlements for a non-sandboxed app with a provisioning profile', async () => {
const { opts, entitlementsPath, infoPlistPath } = fixture({
'com.apple.security.cs.allow-jit': true,
'keychain-access-groups': ['PROFTEAM00.com.example.shared'],
});
const result = await preAutoEntitlements(
opts,
{ entitlements: entitlementsPath },
{ identity: IDENTITY, provisioningProfile: PROFILE },
);
expect(result).toBeTypeOf('string');
const entitlements = readPlist(result as string);
expect(entitlements).toEqual({
'com.apple.security.cs.allow-jit': true,
'keychain-access-groups': ['PROFTEAM00.com.example.shared'],
'com.apple.application-identifier': 'PROFTEAM00.com.example.fixture',
'com.apple.developer.team-identifier': 'PROFTEAM00',
});
// Specifically: no app group, that is a sandbox thing.
expect(entitlements).not.toHaveProperty('com.apple.security.application-groups');
// The team comes from the profile, not the identity.
expect(readPlist(infoPlistPath).ElectronTeamID).toBe('PROFTEAM00');
});

it('keeps identifier entitlements the caller already wrote', async () => {
const { opts, entitlementsPath } = fixture({
'com.apple.application-identifier': 'PROFTEAM00.com.example.custom',
'com.apple.developer.team-identifier': 'PROFTEAM00',
});
const result = await preAutoEntitlements(
opts,
{ entitlements: entitlementsPath },
{ identity: IDENTITY, provisioningProfile: PROFILE },
);
expect(readPlist(result as string)).toEqual({
'com.apple.application-identifier': 'PROFTEAM00.com.example.custom',
'com.apple.developer.team-identifier': 'PROFTEAM00',
});
});

it('still sets up the app group for a sandboxed app', async () => {
const { opts, entitlementsPath, infoPlistPath } = fixture({
'com.apple.security.app-sandbox': true,
});
const result = await preAutoEntitlements(
opts,
{ entitlements: entitlementsPath },
{ identity: IDENTITY },
);
expect(readPlist(result as string)).toEqual({
'com.apple.security.app-sandbox': true,
'com.apple.application-identifier': 'IDENTTEAM.com.example.fixture',
'com.apple.developer.team-identifier': 'IDENTTEAM',
'com.apple.security.application-groups': ['IDENTTEAM.com.example.fixture'],
});
expect(readPlist(infoPlistPath).ElectronTeamID).toBe('IDENTTEAM');
});

it('does not reuse a profile run for a later run without one', async () => {
const { opts, entitlementsPath } = fixture({});
const withProfile = await preAutoEntitlements(
opts,
{ entitlements: entitlementsPath },
{ identity: IDENTITY, provisioningProfile: PROFILE },
);
expect(withProfile).toBeTypeOf('string');
await expect(
preAutoEntitlements(opts, { entitlements: entitlementsPath }, { identity: IDENTITY }),
).resolves.toBeUndefined();
});
});
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,12 @@ export type OnlySignOptions = {
ignore?: string | string[] | ((file: string) => boolean);
/**
* Flag to enable/disable entitlements automation tasks necessary for code signing most Electron apps.
* * Adds [`com.apple.security.application-groups`](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups) to the entitlements file
* The automation runs when the app's entitlements enable `com.apple.security.app-sandbox`, or when a
* {@link OnlySignOptions.provisioningProfile | provisioning profile} is supplied (a profile only grants
* its restricted entitlements to code carrying the matching identifier entitlements below).
* * Fills in the `ElectronTeamID` property in `Info.plist` with the provisioning profile's Team Identifier or by parsing the identity name.
* * Adds `com.apple.application-identifier` and `com.apple.developer.team-identifier` to the entitlements file.
* * For sandboxed apps, also adds [`com.apple.security.application-groups`](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups) to the entitlements file.
*
* @defaultValue `true`
*/
Expand Down
72 changes: 47 additions & 25 deletions src/util-entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ const preAuthMemo = new Map<string, string>();
/**
* This function returns a promise completing the entitlements automation: The
* process includes checking in `Info.plist` for `ElectronTeamID` or setting
* parsed value from identity, and checking in entitlements file for
* `com.apple.security.application-groups` or inserting new into array. A
* temporary entitlements file may be created to replace the input for any
* changes introduced.
* parsed value from the provisioning profile or identity, and filling in the
* `com.apple.application-identifier` and `com.apple.developer.team-identifier`
* entitlements. Sandboxed apps additionally get their app group added to
* `com.apple.security.application-groups`. Runs for apps that enable the
* sandbox or supply a provisioning profile; a temporary entitlements file may
* be created to replace the input for any changes introduced.
*/
export async function preAutoEntitlements(
opts: ValidatedSignOptions,
Expand All @@ -31,7 +33,11 @@ export async function preAutoEntitlements(
): Promise<void | string> {
if (!perFileOpts.entitlements) return;

const memoKey = [opts.app, perFileOpts.entitlements].join('---');
const memoKey = [
opts.app,
perFileOpts.entitlements,
computed.provisioningProfile ? 'profile' : 'no-profile',
].join('---');
if (preAuthMemo.has(memoKey)) return preAuthMemo.get(memoKey);

// If entitlements file not provided, default will be used. Fixes #41
Expand All @@ -51,10 +57,22 @@ export async function preAutoEntitlements(
{},
);
}
if (!entitlements['com.apple.security.app-sandbox']) {
// Only automate when app sandbox enabled by user
const sandboxed = Boolean(entitlements['com.apple.security.app-sandbox']);
if (!sandboxed && !computed.provisioningProfile) {
// Nothing to automate: application-groups is a sandbox capability, and the
// application/team identifier entitlements only matter once a provisioning
// profile is in play (a plain Developer ID app doesn't need them).
return;
}
if (!sandboxed) {
// A provisioning profile only grants its restricted entitlements to code that
// also carries matching com.apple.application-identifier and
// com.apple.developer.team-identifier entitlements, so a Developer ID app that
// embeds a profile needs those injected just like a sandboxed app does.
debugLog(
'Provisioning profile supplied, automating identifier entitlements for a non-sandboxed app',
);
}

const appInfoContents = await fs.promises.readFile(appInfoPath, 'utf8');
const appInfo = plist.parse(appInfoContents) as Record<string, any>;
Expand Down Expand Up @@ -115,24 +133,28 @@ export async function preAutoEntitlements(
);
entitlements['com.apple.developer.team-identifier'] = appInfo.ElectronTeamID;
}
// Init entitlements app group key to array if not exists
if (!entitlements['com.apple.security.application-groups']) {
entitlements['com.apple.security.application-groups'] = [];
}
// Insert app group if not exists
if (
Array.isArray(entitlements['com.apple.security.application-groups']) &&
entitlements['com.apple.security.application-groups'].indexOf(appIdentifier) === -1
) {
debugLog(
'`com.apple.security.application-groups` not found in entitlements file, new inserted: ' +
appIdentifier,
);
entitlements['com.apple.security.application-groups'].push(appIdentifier);
} else {
debugLog(
'`com.apple.security.application-groups` found in entitlements file: ' + appIdentifier,
);
// The app group is what the sandbox automation exists to set up. A non-sandboxed
// app is only here for the identifiers above, so leave its groups alone.
if (sandboxed) {
// Init entitlements app group key to array if not exists
if (!entitlements['com.apple.security.application-groups']) {
entitlements['com.apple.security.application-groups'] = [];
}
// Insert app group if not exists
if (
Array.isArray(entitlements['com.apple.security.application-groups']) &&
entitlements['com.apple.security.application-groups'].indexOf(appIdentifier) === -1
) {
debugLog(
'`com.apple.security.application-groups` not found in entitlements file, new inserted: ' +
appIdentifier,
);
entitlements['com.apple.security.application-groups'].push(appIdentifier);
} else {
debugLog(
'`com.apple.security.application-groups` found in entitlements file: ' + appIdentifier,
);
}
}
// Create temporary entitlements file
const dir = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'tmp-entitlements-'));
Expand Down