Compare commits

...
8 changed files with 328 additions and 2 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Explain missing and invalid iOS signing identities with safe keychain diagnostics.
@@ -46,7 +46,7 @@ describe('Keychain class', () => {
teamId: distributionCertificate.teamId,
fingerprint: distributionCertificate.fingerprint,
})
).rejects.toThrowError(/hasn't been imported successfully/);
).rejects.toThrowError(/Signing identity validation failed/);
});
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
@@ -0,0 +1,155 @@
import { createLogger } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import forge from 'node-forge';
import os from 'os';
import path from 'path';
import Keychain from '../keychain';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.setTimeout(60000);
// Exercise the native command/output contract with disposable credentials only.
// Do not install a trusted root or change the user's trust settings.
(process.platform === 'darwin' ? describe : describe.skip)('native identity diagnostics', () => {
let keychain: Keychain;
const logger = createLogger({ name: 'native-identity-test' });
const logError = jest.spyOn(logger, 'error').mockImplementation();
let directory: string;
let keychainPath: string;
let certificate: forge.pki.Certificate;
let keys: forge.pki.rsa.KeyPair;
let issuerKeys: forge.pki.rsa.KeyPair;
const password = 'disposable-test-keychain';
beforeAll(() => {
keys = forge.pki.rsa.generateKeyPair(2048);
issuerKeys = forge.pki.rsa.generateKeyPair(2048);
});
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-identity-test-'));
keychain = new Keychain();
keychainPath = keychain.data.path;
await spawn('security', ['create-keychain', '-p', password, keychainPath]);
await spawn('security', ['unlock-keychain', '-p', password, keychainPath]);
certificate = forge.pki.createCertificate();
certificate.publicKey = keys.publicKey;
certificate.serialNumber = '01';
certificate.validity.notBefore = new Date(Date.now() - 86400000);
certificate.validity.notAfter = new Date(Date.now() + 86400000);
certificate.setSubject([{ name: 'commonName', value: 'Disposable signing test' }]);
certificate.setIssuer([{ name: 'commonName', value: 'Absent test issuer' }]);
certificate.setExtensions([
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true },
{ name: 'extKeyUsage', codeSigning: true },
]);
certificate.sign(issuerKeys.privateKey, forge.md.sha256.create());
});
afterEach(async () => {
try {
await spawn('security', ['delete-keychain', keychainPath]);
} finally {
await fs.remove(directory);
}
});
function fingerprint(): string {
return forge.md.sha1
.create()
.update(forge.asn1.toDer(forge.pki.certificateToAsn1(certificate)).getBytes())
.digest()
.toHex()
.toUpperCase();
}
async function diagnose(): Promise<Error> {
const result = await keychain
.ensureCertificateImported({
logger,
fingerprint: fingerprint(),
teamId: 'TEST',
})
.catch(error => error);
expect(result).toBeInstanceOf(Error);
expect(result.message).toContain('Signing identity validation failed');
expect(result.message).not.toContain("hasn't been imported successfully");
expect(JSON.stringify(logError.mock.calls)).not.toContain('Disposable signing test');
return result;
}
async function importIdentity(): Promise<void> {
const p12 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [certificate], password, {
algorithm: '3des',
});
const file = path.join(directory, 'identity.p12');
await fs.writeFile(file, Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'));
await spawn('security', ['import', file, '-k', keychainPath, '-P', password]);
}
it('reports absent credentials in an empty keychain', async () => {
const failure = await diagnose();
expect(logError).toHaveBeenCalledWith(
expect.objectContaining({
certificatePresent: false,
identityPresent: false,
}),
expect.any(String)
);
expect(failure.message).toContain('Read the certificate import logs');
});
it('reports a certificate without its matching private key identity', async () => {
const file = path.join(directory, 'certificate.pem');
await fs.writeFile(file, forge.pki.certificateToPem(certificate));
await spawn('security', ['import', file, '-k', keychainPath]);
const failure = await diagnose();
expect(logError).toHaveBeenCalledWith(
expect.objectContaining({
certificatePresent: true,
identityPresent: false,
}),
expect.any(String)
);
expect(failure.message).toContain(
'Check that the PKCS#12 file contains the matching private key'
);
});
it('reports an imported identity with an absent issuer without blaming import', async () => {
await importIdentity();
const failure = await diagnose();
expect(logError).toHaveBeenCalledWith(
expect.objectContaining({
certificatePresent: true,
identityPresent: true,
trustErrors: ['CSSMERR_TP_NOT_TRUSTED'],
}),
expect.any(String)
);
expect(failure.message).toContain('Check certificate dates and trust errors');
});
it('reports an expired imported identity and directs users to check certificate dates', async () => {
certificate.validity.notBefore = new Date(Date.now() - 2 * 86400000);
certificate.validity.notAfter = new Date(Date.now() - 86400000);
certificate.sign(issuerKeys.privateKey, forge.md.sha256.create());
await importIdentity();
const failure = await diagnose();
expect(logError).toHaveBeenCalledWith(
expect.objectContaining({
certificatePresent: true,
identityPresent: true,
}),
expect.any(String)
);
expect(failure.message).toContain('Check certificate dates');
});
});
@@ -0,0 +1,90 @@
import { createLogger } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import Keychain from '../keychain';
jest.mock('@expo/turtle-spawn');
const spawnMock = jest.mocked(spawn);
const fingerprint = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01';
const logger = createLogger({ name: 'test' });
const logError = jest.spyOn(logger, 'error').mockImplementation();
const options = { teamId: 'TEAM', fingerprint, logger };
const identity = ` 1) ${fingerprint} "Private certificate name"`;
beforeEach(() => {
spawnMock.mockReset();
logError.mockReset();
});
function output(stdout: string, stderr = ''): void {
spawnMock.mockResolvedValueOnce({
stdout,
stderr,
output: [stdout, stderr],
status: 0,
signal: null,
});
}
it.each([
['identity entry', identity, ''],
['fingerprint in a name', `1) ${'0'.repeat(40)} "${fingerprint}"`, ''],
['stderr output', '', identity],
])(
'preserves the original success check for %s without running diagnostics',
async (_, stdout, stderr) => {
output(stdout, stderr);
logError.mockImplementation(() => {
throw new Error('logging failed');
});
await expect(new Keychain().ensureCertificateImported(options)).resolves.toBeUndefined();
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(logError).not.toHaveBeenCalled();
}
);
it('preserves a primary command failure without running diagnostics', async () => {
const error = new Error('security failed');
spawnMock.mockRejectedValueOnce(error);
await expect(new Keychain().ensureCertificateImported(options)).rejects.toBe(error);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(logError).not.toHaveBeenCalled();
});
it('keeps failed diagnostic queries unknown without exposing their raw errors', async () => {
output('');
spawnMock.mockRejectedValue(new Error('private output'));
const failure = await new Keychain().ensureCertificateImported(options).catch(error => error);
expect(failure.message).toContain('Signing identity validation failed');
expect(logError).toHaveBeenCalledWith(
expect.objectContaining({ certificatePresent: null, identityPresent: null }),
expect.any(String)
);
expect(JSON.stringify([failure.message, logError.mock.calls])).not.toContain('private output');
});
it('preserves the validation failure if diagnostic logging throws', async () => {
output('');
output('');
output('');
logError.mockImplementation(() => {
throw new Error('logging failed');
});
await expect(new Keychain().ensureCertificateImported(options)).rejects.toThrow(
`Signing identity validation failed for distribution certificate with fingerprint ${fingerprint}.`
);
});
it('does not report an unrelated identity whose name contains the expected fingerprint', async () => {
output('');
output('');
output(`1) ${'0'.repeat(40)} "${fingerprint}"`);
await expect(new Keychain().ensureCertificateImported(options)).rejects.toThrow(
'Read the certificate import logs'
);
expect(logError).toHaveBeenCalledWith(
expect.objectContaining({ certificatePresent: false, identityPresent: false }),
expect.any(String)
);
expect(JSON.stringify(logError.mock.calls)).not.toContain('Private certificate name');
});
@@ -1,10 +1,12 @@
import { errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { findCertificateFingerprintsAsync, findIdentitiesAsync } from './security';
import { runFastlane } from '../fastlane';
// Do not log raw fastlane output or spawn errors: they can contain passwords,
@@ -137,16 +139,21 @@ export default class Keychain {
}
public async ensureCertificateImported({
logger,
teamId,
fingerprint,
}: {
logger?: bunyan;
teamId: string;
fingerprint: string;
}): Promise<void> {
const identities = await this.findIdentitiesByTeamId(teamId);
if (!identities.includes(fingerprint)) {
// Diagnostics must not change the existing pass/fail check or mask its failure.
const diagnosticResult = await asyncResult(this.diagnoseMissingIdentity(fingerprint, logger));
const explanation = diagnosticResult.ok ? diagnosticResult.value : '';
throw new Error(
`Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully`
`Signing identity validation failed for distribution certificate with fingerprint ${fingerprint}.${explanation ? ` ${explanation}` : ''}`
);
}
}
@@ -200,4 +207,42 @@ export default class Keychain {
);
return output.join('');
}
private async diagnoseMissingIdentity(fingerprint: string, logger?: bunyan): Promise<string> {
const [certificates, identities] = await Promise.all([
asyncResult(findCertificateFingerprintsAsync(this.keychainPath)),
asyncResult(findIdentitiesAsync(this.keychainPath)),
]);
const expectedFingerprint = fingerprint.toUpperCase();
const certificatePresent = certificates.ok
? certificates.value.includes(expectedFingerprint)
: null;
const matchingIdentities = identities.ok
? identities.value.filter(identity => identity.fingerprint === expectedFingerprint)
: null;
const identityPresent = matchingIdentities === null ? null : matchingIdentities.length > 0;
const trustErrors = [
...new Set(
(matchingIdentities ?? []).flatMap(identity =>
identity.trustError ? [identity.trustError] : []
)
),
];
// Never log raw command output, certificate names, or process errors.
logger?.error(
{ certificatePresent, identityPresent, trustErrors },
`iOS signing identity diagnostics: certificatePresent=${certificatePresent}, identityPresent=${identityPresent}, trustErrors=${trustErrors.join(',') || 'none reported'} (null means the diagnostic query failed)`
);
if (identityPresent) {
return 'The identity is present, but macOS did not report it as valid. Check certificate dates and trust errors on the build machine.';
}
if (certificatePresent && identityPresent === false) {
return 'The certificate is present, but macOS did not list a matching private key identity. Check that the PKCS#12 file contains the matching private key.';
}
if (certificatePresent === false && identityPresent === false) {
return 'macOS did not find the expected certificate or identity. Read the certificate import logs.';
}
return 'A diagnostic query failed. Certificate or identity presence is unknown.';
}
}
@@ -140,6 +140,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
'Validating whether the distribution certificate has been imported successfully'
);
await this.keychain.ensureCertificateImported({
logger: this.ctx.logger,
teamId: provisioningProfile.data.teamId,
fingerprint: certificateFingerprint,
});
@@ -0,0 +1,29 @@
import spawn from '@expo/turtle-spawn';
// Diagnostic queries only. Keep the existing validation command in Keychain unchanged.
export async function findCertificateFingerprintsAsync(keychainPath: string): Promise<string[]> {
const { stdout } = await spawn('security', ['find-certificate', '-a', '-Z', keychainPath], {
stdio: 'pipe',
timeout: 10000,
});
return [...stdout.matchAll(/^SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/gm)].map(match =>
match[1].toUpperCase()
);
}
type SecurityIdentity = {
fingerprint: string;
trustError: string | null;
};
export async function findIdentitiesAsync(keychainPath: string): Promise<SecurityIdentity[]> {
const { stdout } = await spawn('security', ['find-identity', keychainPath], {
stdio: 'pipe',
timeout: 10000,
});
// Return only fingerprints and symbolic trust errors, never certificate names.
return [...stdout.matchAll(/^\s*\d+\)\s+([A-Fa-f0-9]{40})\s+"([^\n]*)/gm)].map(match => ({
fingerprint: match[1].toUpperCase(),
trustError: match[2].match(/"\s+\((CSSMERR_[A-Z0-9_]+)\)\s*$/)?.[1] ?? null,
}));
}
@@ -126,6 +126,7 @@ export default class IosCredentialsManager {
logger.info('Validating whether distribution certificate has been imported successfully');
await this.keychain.ensureCertificateImported({
logger,
teamId: provisioningProfile.data.teamId,
fingerprint: certificateFingerprint,
});