mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba28ceb6ce | ||
|
|
c906503d0c | ||
|
|
7b30f8f5a8 | ||
|
|
dd7c4d8876 | ||
|
|
6478f5eeb0 |
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 () => {
|
||||
|
||||
+155
@@ -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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user