@limrun/ui 0.14.0-rc.3 → 0.14.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-store-relay/index.cjs +3 -3
- package/dist/app-store-relay/index.d.ts +108 -4
- package/dist/app-store-relay/index.js +353 -160
- package/dist/app-store-relay/react.cjs +1 -1
- package/dist/app-store-relay/react.js +32 -33
- package/dist/apple-credentials/index.cjs +1 -0
- package/dist/apple-credentials/index.d.ts +110 -0
- package/dist/apple-credentials/index.js +291 -0
- package/dist/browser-storage-BnO-Ma-e.mjs +219 -0
- package/dist/browser-storage-DLxIqZk5.js +1 -0
- package/dist/client-C9z_sgOf.js +1 -0
- package/dist/{client-C8ZDEjtD.mjs → client-CFDB4mX5.mjs} +38 -31
- package/dist/components/remote-control.d.ts +156 -0
- package/dist/core/device-install/apple/provisioning.d.ts +25 -0
- package/dist/core/device-install/apple/relay.d.ts +13 -0
- package/dist/core/device-install/storage/browser-storage.d.ts +8 -0
- package/dist/core/device-install/storage/index.d.ts +1 -0
- package/dist/core/device-install/storage/secret-store.d.ts +124 -0
- package/dist/core/device-install/types.d.ts +6 -0
- package/dist/core/errors.d.ts +1 -0
- package/dist/core/play-publish/google.d.ts +1 -1
- package/dist/device-build/index.cjs +1 -1
- package/dist/device-build/index.js +21 -20
- package/dist/device-build/react.cjs +1 -1
- package/dist/device-build/react.js +42 -43
- package/dist/device-install/index.cjs +1 -1
- package/dist/device-install/index.js +64 -63
- package/dist/device-install/react.cjs +1 -1
- package/dist/device-install/react.js +47 -49
- package/dist/errors-BSkqS7U-.js +1 -0
- package/dist/errors-gy8yQJos.mjs +6 -0
- package/dist/gsa-srp-Bme3JLEi.mjs +266 -0
- package/dist/gsa-srp-CCIfUz7Y.js +1 -0
- package/dist/index-ChhtuQ7e.js +22 -0
- package/dist/index-YDu8K9a4.mjs +11279 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1405 -1125
- package/dist/play-publish/index.cjs +1 -1
- package/dist/play-publish/index.js +27 -24
- package/dist/play-publish/react.cjs +1 -1
- package/dist/play-publish/react.d.ts +12 -2
- package/dist/play-publish/react.js +70 -60
- package/dist/{provisioning-BmsEnF6X.mjs → provisioning-CDPvxd1h.mjs} +64 -33
- package/dist/provisioning-CbC4h--7.js +1 -0
- package/package.json +6 -1
- package/src/app-store-relay/index.ts +358 -23
- package/src/app-store-relay/react.ts +1 -4
- package/src/apple-credentials/index.test.ts +180 -0
- package/src/apple-credentials/index.ts +388 -0
- package/src/components/remote-control.css +32 -0
- package/src/components/remote-control.tsx +865 -5
- package/src/core/device-install/apple/client.ts +13 -6
- package/src/core/device-install/apple/provisioning.test.ts +30 -0
- package/src/core/device-install/apple/provisioning.ts +39 -0
- package/src/core/device-install/apple/relay.ts +20 -0
- package/src/core/device-install/operations/operations.ts +7 -5
- package/src/core/device-install/storage/browser-storage.test.ts +65 -0
- package/src/core/device-install/storage/browser-storage.ts +33 -0
- package/src/core/device-install/storage/index.ts +1 -0
- package/src/core/device-install/storage/secret-store.ts +332 -0
- package/src/core/device-install/types.ts +6 -0
- package/src/core/errors.ts +3 -0
- package/src/core/play-publish/google.ts +11 -2
- package/src/device-build/react.ts +1 -4
- package/src/device-install/demo/demo.tsx +1 -4
- package/src/device-install/react.ts +1 -4
- package/src/play-publish/README.md +5 -4
- package/src/play-publish/react.ts +72 -32
- package/vite.config.ts +1 -0
- package/dist/browser-storage-Cx9eW-bm.mjs +0 -205
- package/dist/browser-storage-jJaIht4K.js +0 -1
- package/dist/client-BtC4n_qf.js +0 -1
- package/dist/index-CMeQfhYy.js +0 -22
- package/dist/index-DFdCvi2_.mjs +0 -11543
- package/dist/provisioning-Va0PH4Kk.js +0 -1
|
@@ -63,12 +63,15 @@ export async function startBrowserOwnedAppleIDLogin({
|
|
|
63
63
|
if (requiresTwoFactor) {
|
|
64
64
|
twoFactorChallengeResponse = await triggerTrustedDeviceTwoFactor(relay);
|
|
65
65
|
const phone = trustedPhoneNumberFromChallenge(twoFactorChallengeResponse.body);
|
|
66
|
-
//
|
|
67
|
-
// falls back to phone verification
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// trusted-device
|
|
71
|
-
|
|
66
|
+
// Route codes through the phone/SMS endpoint only when Apple actually
|
|
67
|
+
// falls back to phone verification: HTTP 412 on the trusted-device
|
|
68
|
+
// trigger, or a 2xx challenge whose body says noTrustedDevices (seen on
|
|
69
|
+
// managed Apple accounts). The trusted-device challenge body frequently
|
|
70
|
+
// includes trusted-phone metadata even for a normal device push;
|
|
71
|
+
// routing those codes to the phone endpoint breaks trusted-device
|
|
72
|
+
// login, while submitting an SMS-fallback code to the trusted-device
|
|
73
|
+
// endpoint returns 201 without completing authentication.
|
|
74
|
+
if (twoFactorChallengeResponse.status === 412 || challengeHasNoTrustedDevices(twoFactorChallengeResponse.body)) {
|
|
72
75
|
if (!phone) {
|
|
73
76
|
throw new Error('Apple requested phone verification but did not include a trusted phone number.');
|
|
74
77
|
}
|
|
@@ -117,6 +120,10 @@ export async function startBrowserOwnedAppleIDLogin({
|
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
function challengeHasNoTrustedDevices(body: unknown) {
|
|
124
|
+
return isRecord(body) && body.noTrustedDevices === true;
|
|
125
|
+
}
|
|
126
|
+
|
|
120
127
|
function trustedPhoneNumberFromChallenge(body: unknown) {
|
|
121
128
|
if (!isRecord(body)) return undefined;
|
|
122
129
|
const verification = isRecord(body.phoneNumberVerification) ? body.phoneNumberVerification : undefined;
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import { describe, expect, test } from 'vitest';
|
|
4
4
|
import {
|
|
5
5
|
createAdHocProfileRequest,
|
|
6
|
+
createAppStoreProfileRequest,
|
|
6
7
|
createDevelopmentProfileRequest,
|
|
7
8
|
downloadDistributionCertificateRequest,
|
|
8
9
|
findAdHocProfilesRequest,
|
|
10
|
+
findAppStoreProfilesRequest,
|
|
9
11
|
findDevelopmentCertificatesRequest,
|
|
10
12
|
findDistributionCertificatesRequest,
|
|
11
13
|
submitDistributionCSRRequest,
|
|
@@ -81,4 +83,32 @@ describe('Apple provisioning request helpers', () => {
|
|
|
81
83
|
distributionType: 'adhoc',
|
|
82
84
|
});
|
|
83
85
|
});
|
|
86
|
+
|
|
87
|
+
test('builds App Store profile payloads without devices and with the caller-provided name', () => {
|
|
88
|
+
expect(
|
|
89
|
+
createAppStoreProfileRequest({
|
|
90
|
+
teamID: 'TEAM',
|
|
91
|
+
appIDID: 'APP',
|
|
92
|
+
certificateID: 'CERT',
|
|
93
|
+
name: 'Acme App Store com.example.app',
|
|
94
|
+
}).payload,
|
|
95
|
+
).toEqual({
|
|
96
|
+
teamId: 'TEAM',
|
|
97
|
+
provisioningProfileName: 'Acme App Store com.example.app',
|
|
98
|
+
certificateIds: ['CERT'],
|
|
99
|
+
appIdId: 'APP',
|
|
100
|
+
distributionType: 'store',
|
|
101
|
+
subPlatform: 'ios',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('filters App Store profile list requests by store distribution type', () => {
|
|
106
|
+
expect(
|
|
107
|
+
findAppStoreProfilesRequest({ bundleID: 'com.example.app', teamID: 'TEAM' }).payload,
|
|
108
|
+
).toMatchObject({
|
|
109
|
+
teamId: 'TEAM',
|
|
110
|
+
search: 'com.example.app',
|
|
111
|
+
distributionType: 'store',
|
|
112
|
+
});
|
|
113
|
+
});
|
|
84
114
|
});
|
|
@@ -141,6 +141,17 @@ export function findAdHocProfilesRequest({
|
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
export function findAppStoreProfilesRequest({
|
|
145
|
+
bundleID,
|
|
146
|
+
teamID = '',
|
|
147
|
+
}: Pick<AppleProvisioningContext, 'bundleID' | 'teamID'>) {
|
|
148
|
+
return pagedRequest('/account/ios/profile/listProvisioningProfiles.action', teamID, {
|
|
149
|
+
search: bundleID,
|
|
150
|
+
sort: 'name=asc',
|
|
151
|
+
distributionType: 'store',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
144
155
|
export function registerDeviceRequest({
|
|
145
156
|
deviceUDID,
|
|
146
157
|
teamID = '',
|
|
@@ -280,6 +291,34 @@ export function createAdHocProfileRequest({
|
|
|
280
291
|
} satisfies AppleProvisioningRequest;
|
|
281
292
|
}
|
|
282
293
|
|
|
294
|
+
/**
|
|
295
|
+
* App Store distribution profiles bind no devices, and the name is required
|
|
296
|
+
* because implementors brand profiles themselves; there is no default.
|
|
297
|
+
*/
|
|
298
|
+
export function createAppStoreProfileRequest({
|
|
299
|
+
teamID = '',
|
|
300
|
+
appIDID,
|
|
301
|
+
certificateID,
|
|
302
|
+
name,
|
|
303
|
+
}: Pick<AppleProvisioningContext, 'teamID'> & {
|
|
304
|
+
appIDID: string;
|
|
305
|
+
certificateID: string;
|
|
306
|
+
name: string;
|
|
307
|
+
}) {
|
|
308
|
+
return {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
path: '/account/ios/profile/createProvisioningProfile.action',
|
|
311
|
+
payload: {
|
|
312
|
+
teamId: teamID,
|
|
313
|
+
provisioningProfileName: name,
|
|
314
|
+
certificateIds: [certificateID],
|
|
315
|
+
appIdId: appIDID,
|
|
316
|
+
distributionType: 'store',
|
|
317
|
+
subPlatform: 'ios',
|
|
318
|
+
},
|
|
319
|
+
} satisfies AppleProvisioningRequest;
|
|
320
|
+
}
|
|
321
|
+
|
|
283
322
|
export function downloadProfileRequest(profileID: string, teamID = '') {
|
|
284
323
|
return {
|
|
285
324
|
method: 'GET',
|
|
@@ -15,6 +15,19 @@ export type AppleProvisioningRequest = {
|
|
|
15
15
|
payload?: unknown;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A session-authenticated App Store Connect request proxied by the relay.
|
|
20
|
+
* Unlike provisioning requests, payloads pass through as JSON and GET
|
|
21
|
+
* parameters go into an explicit query map because the App Store Connect
|
|
22
|
+
* API uses bracketed JSON:API keys like fields[apiKeys].
|
|
23
|
+
*/
|
|
24
|
+
export type AppStoreConnectRequest = {
|
|
25
|
+
method?: 'GET' | 'POST';
|
|
26
|
+
path: string;
|
|
27
|
+
query?: Record<string, string>;
|
|
28
|
+
payload?: unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
18
31
|
type AppleRelayWebSocketMessage<T = unknown> = {
|
|
19
32
|
id: string;
|
|
20
33
|
ok: boolean;
|
|
@@ -154,6 +167,13 @@ export async function proxyProvisioningRequest<T = unknown>(
|
|
|
154
167
|
return relay.request<T>('provisioning', request);
|
|
155
168
|
}
|
|
156
169
|
|
|
170
|
+
export async function proxyAppStoreConnectRequest<T = unknown>(
|
|
171
|
+
relay: AppleRelayWebSocketClient,
|
|
172
|
+
request: AppStoreConnectRequest,
|
|
173
|
+
) {
|
|
174
|
+
return relay.request<T>('appstoreconnect', request);
|
|
175
|
+
}
|
|
176
|
+
|
|
157
177
|
function normalizeAppleProxyResponse<T>(response: AppleRelayResponse<T>) {
|
|
158
178
|
if (response.body !== undefined || !response.rawBody) {
|
|
159
179
|
return response;
|
|
@@ -4,6 +4,7 @@ import type { DeviceHello, DeviceInstallLog, PairRecordPayload } from '../types'
|
|
|
4
4
|
import { RelayClient } from './relay-client';
|
|
5
5
|
import { closeUsbmuxSession, createUsbmuxSession, type UsbmuxSession } from './usbmux';
|
|
6
6
|
import { claimUsbmux, findUsbmuxCandidates, requestAppleDevice, type UsbmuxCandidate } from './webusb';
|
|
7
|
+
import { errorMessage } from '../../errors';
|
|
7
8
|
|
|
8
9
|
export type DeviceRelayTarget = {
|
|
9
10
|
device: USBDevice;
|
|
@@ -179,7 +180,12 @@ async function claimBestUsbmuxCandidate(target: DeviceRelayTarget, log: DeviceIn
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
|
-
|
|
183
|
+
const detail = lastError ? ` (${errorMessage(lastError)})` : '';
|
|
184
|
+
throw new Error(
|
|
185
|
+
'Could not get exclusive USB access to the iPhone' +
|
|
186
|
+
`${detail}. Another app on this computer is likely connected to it. ` +
|
|
187
|
+
'Close other browser tabs that use this device, quit iPhone Mirroring, Xcode, eject iPhone in Finder windows showing the phone and try again.',
|
|
188
|
+
);
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
function pickUsbmuxCandidate(device: USBDevice) {
|
|
@@ -210,10 +216,6 @@ export function deviceRelayWebSocketUrl(registryApiUrl: string, token?: string,
|
|
|
210
216
|
return url.toString();
|
|
211
217
|
}
|
|
212
218
|
|
|
213
|
-
function errorMessage(error: unknown) {
|
|
214
|
-
return error instanceof Error ? error.message : String(error);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
219
|
async function resetUSBDevice(target: DeviceRelayTarget) {
|
|
218
220
|
if (target.claimedInterfaceNumber !== undefined) {
|
|
219
221
|
try {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import forge from 'node-forge';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { normalizeCertificateSerial, parseProvisioningProfileBase64 } from './browser-storage';
|
|
4
|
+
|
|
5
|
+
function selfSignedCertificateBase64(serialNumber: string) {
|
|
6
|
+
const keys = forge.pki.rsa.generateKeyPair(1024);
|
|
7
|
+
const cert = forge.pki.createCertificate();
|
|
8
|
+
cert.publicKey = keys.publicKey;
|
|
9
|
+
cert.serialNumber = serialNumber;
|
|
10
|
+
cert.validity.notBefore = new Date();
|
|
11
|
+
cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
12
|
+
const attrs = [{ name: 'commonName', value: 'Apple Development Test' }];
|
|
13
|
+
cert.setSubject(attrs);
|
|
14
|
+
cert.setIssuer(attrs);
|
|
15
|
+
cert.sign(keys.privateKey);
|
|
16
|
+
return forge.util.encode64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function profileBase64(certificatesBase64: string[]) {
|
|
20
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
21
|
+
<plist version="1.0">
|
|
22
|
+
<dict>
|
|
23
|
+
<key>Name</key><string>Limrun Dev</string>
|
|
24
|
+
<key>UUID</key><string>11111111-2222-3333-4444-555555555555</string>
|
|
25
|
+
<key>TeamIdentifier</key><array><string>TEAM123</string></array>
|
|
26
|
+
<key>ProvisionedDevices</key><array><string>00008030-000A</string></array>
|
|
27
|
+
<key>DeveloperCertificates</key>
|
|
28
|
+
<array>${certificatesBase64.map((c) => `<data>${c}</data>`).join('')}</array>
|
|
29
|
+
<key>Entitlements</key>
|
|
30
|
+
<dict>
|
|
31
|
+
<key>application-identifier</key><string>TEAM123.com.example.app</string>
|
|
32
|
+
<key>com.apple.developer.team-identifier</key><string>TEAM123</string>
|
|
33
|
+
</dict>
|
|
34
|
+
</dict>
|
|
35
|
+
</plist>`;
|
|
36
|
+
return btoa(xml);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('parseProvisioningProfileBase64', () => {
|
|
40
|
+
test('extracts embedded certificate serial numbers in normalized form', () => {
|
|
41
|
+
const parsed = parseProvisioningProfileBase64(
|
|
42
|
+
profileBase64([selfSignedCertificateBase64('00ab12cd'), selfSignedCertificateBase64('7f')]),
|
|
43
|
+
);
|
|
44
|
+
expect(parsed.certificateSerialNumbers).toEqual(['AB12CD', '7F']);
|
|
45
|
+
expect(parsed.bundleID).toBe('com.example.app');
|
|
46
|
+
expect(parsed.teamID).toBe('TEAM123');
|
|
47
|
+
expect(parsed.provisionedDevices).toEqual(['00008030-000A']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('skips unparseable certificates instead of failing the profile', () => {
|
|
51
|
+
const parsed = parseProvisioningProfileBase64(
|
|
52
|
+
profileBase64([btoa('not a certificate'), selfSignedCertificateBase64('1a')]),
|
|
53
|
+
);
|
|
54
|
+
expect(parsed.certificateSerialNumbers).toEqual(['1A']);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('normalizeCertificateSerial', () => {
|
|
59
|
+
test('uppercases and strips leading zeros', () => {
|
|
60
|
+
expect(normalizeCertificateSerial('00ab12')).toBe('AB12');
|
|
61
|
+
expect(normalizeCertificateSerial('AB12')).toBe('AB12');
|
|
62
|
+
expect(normalizeCertificateSerial('')).toBeUndefined();
|
|
63
|
+
expect(normalizeCertificateSerial(undefined)).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import forge from 'node-forge';
|
|
1
2
|
import type {
|
|
2
3
|
DeviceInstallSigningMode,
|
|
3
4
|
ProvisioningProfileInfo,
|
|
@@ -198,11 +199,43 @@ export function parseProvisioningProfileBytes(bytes: Uint8Array) {
|
|
|
198
199
|
applicationIdentifier,
|
|
199
200
|
bundleID,
|
|
200
201
|
provisionedDevices: stringArrayValue(value.ProvisionedDevices),
|
|
202
|
+
certificateSerialNumbers: certificateSerialNumbers(stringArrayValue(value.DeveloperCertificates)),
|
|
201
203
|
getTaskAllow: booleanValue(entitlements['get-task-allow']),
|
|
202
204
|
expirationDate: stringValue(value.ExpirationDate),
|
|
203
205
|
} satisfies ProvisioningProfileInfo;
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Normalizes a certificate serial number for comparison: uppercase hex
|
|
210
|
+
* without leading zeros, the format Apple's portal reports in serialNum.
|
|
211
|
+
*/
|
|
212
|
+
export function normalizeCertificateSerial(serial?: string) {
|
|
213
|
+
const normalized = (serial ?? '').replace(/^0+/, '').toUpperCase();
|
|
214
|
+
return normalized || undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Reads the serial numbers of the DER certificates embedded in a profile's
|
|
219
|
+
* DeveloperCertificates array. Unparseable entries are skipped: a profile
|
|
220
|
+
* with an exotic certificate should degrade to weaker filtering, not fail
|
|
221
|
+
* the whole parse.
|
|
222
|
+
*/
|
|
223
|
+
function certificateSerialNumbers(developerCertificatesBase64: string[]) {
|
|
224
|
+
const serials: string[] = [];
|
|
225
|
+
for (const entry of developerCertificatesBase64) {
|
|
226
|
+
try {
|
|
227
|
+
// Plist <data> payloads wrap base64 across lines; atob rejects whitespace.
|
|
228
|
+
const der = atob(entry.replace(/\s+/g, ''));
|
|
229
|
+
const certificate = forge.pki.certificateFromAsn1(forge.asn1.fromDer(forge.util.createBuffer(der)));
|
|
230
|
+
const serial = normalizeCertificateSerial(certificate.serialNumber);
|
|
231
|
+
if (serial && !serials.includes(serial)) serials.push(serial);
|
|
232
|
+
} catch {
|
|
233
|
+
// Skip certificates forge cannot parse.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return serials;
|
|
237
|
+
}
|
|
238
|
+
|
|
206
239
|
async function getSigningAssetsByID(id?: string) {
|
|
207
240
|
if (!id) return undefined;
|
|
208
241
|
const db = await openDB(SIGNING_DB_NAME, SIGNING_DB_VERSION, SIGNING_STORE_NAME, 'id');
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pluggable store for Apple signing secrets.
|
|
3
|
+
*
|
|
4
|
+
* Credential material always lands in the browser first (the Apple relay
|
|
5
|
+
* only proxies); the browser then writes it into a store implementing this
|
|
6
|
+
* interface. Limrun's org secret store is the default implementation, an
|
|
7
|
+
* IndexedDB-backed one keeps everything local, and customers can bring
|
|
8
|
+
* their own store by implementing the same interface.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const APPLE_CERTIFICATE_SECRET_TYPE = 'appleCertificate';
|
|
12
|
+
export const APPLE_PROVISIONING_PROFILE_SECRET_TYPE = 'appleProvisioningProfile';
|
|
13
|
+
export const APP_STORE_CONNECT_API_KEY_SECRET_TYPE = 'appStoreConnectApiKey';
|
|
14
|
+
|
|
15
|
+
export type SigningSecretType =
|
|
16
|
+
| typeof APPLE_CERTIFICATE_SECRET_TYPE
|
|
17
|
+
| typeof APPLE_PROVISIONING_PROFILE_SECRET_TYPE
|
|
18
|
+
| typeof APP_STORE_CONNECT_API_KEY_SECRET_TYPE;
|
|
19
|
+
|
|
20
|
+
export type SigningSecretMetadata = {
|
|
21
|
+
type: string;
|
|
22
|
+
name: string;
|
|
23
|
+
createdAt?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Type-specific flat key-value payload of a signing secret. */
|
|
27
|
+
export type SigningSecretData = Record<string, string>;
|
|
28
|
+
|
|
29
|
+
export type SigningSecret = SigningSecretMetadata & {
|
|
30
|
+
data: SigningSecretData;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface SigningSecretStore {
|
|
34
|
+
/**
|
|
35
|
+
* Stores a secret. When a secret with the same type and name already
|
|
36
|
+
* exists its data is overwritten. Returns the stored secret; callers
|
|
37
|
+
* should use the returned data.
|
|
38
|
+
*/
|
|
39
|
+
put(type: SigningSecretType, name: string, data: SigningSecretData): Promise<SigningSecret>;
|
|
40
|
+
/** Returns the secret including its data, or undefined when absent. */
|
|
41
|
+
get(type: SigningSecretType, name: string): Promise<SigningSecret | undefined>;
|
|
42
|
+
/** Lists metadata of all stored signing secrets, never their data. */
|
|
43
|
+
list(): Promise<SigningSecretMetadata[]>;
|
|
44
|
+
/** Deletes a secret; resolves even when the secret does not exist. */
|
|
45
|
+
delete(type: SigningSecretType, name: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apple's certificate types as defined by the App Store Connect API
|
|
50
|
+
* (https://developer.apple.com/documentation/appstoreconnectapi/certificatetype).
|
|
51
|
+
*/
|
|
52
|
+
export type AppleCertificateType =
|
|
53
|
+
| 'DEVELOPMENT'
|
|
54
|
+
| 'DISTRIBUTION'
|
|
55
|
+
| 'IOS_DEVELOPMENT'
|
|
56
|
+
| 'IOS_DISTRIBUTION'
|
|
57
|
+
| 'MAC_APP_DEVELOPMENT'
|
|
58
|
+
| 'MAC_APP_DISTRIBUTION'
|
|
59
|
+
| 'MAC_INSTALLER_DISTRIBUTION'
|
|
60
|
+
| 'DEVELOPER_ID_APPLICATION'
|
|
61
|
+
| 'DEVELOPER_ID_INSTALLER'
|
|
62
|
+
| 'DEVELOPER_ID_KEXT'
|
|
63
|
+
| 'PASS_TYPE_ID'
|
|
64
|
+
| 'PASS_TYPE_ID_WITH_NFC';
|
|
65
|
+
|
|
66
|
+
/** Data payload of an appleCertificate secret. */
|
|
67
|
+
export type AppleCertificateSecretData = {
|
|
68
|
+
certificateP12Base64: string;
|
|
69
|
+
/** Which of Apple's certificate types the p12 holds. */
|
|
70
|
+
certificateType: AppleCertificateType;
|
|
71
|
+
certificatePassword?: string;
|
|
72
|
+
teamID?: string;
|
|
73
|
+
/** Apple's portal certificate id, never a Limrun DB id. */
|
|
74
|
+
certificateID?: string;
|
|
75
|
+
/** The certificate's serial number: uppercase hex, no leading zeros. */
|
|
76
|
+
serialNumber?: string;
|
|
77
|
+
expirationDate?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Data payload of an appleProvisioningProfile secret. The
|
|
82
|
+
* certificateSerialNumbers, bundleIDs and deviceIDs fields duplicate what
|
|
83
|
+
* the signed profile binds (as comma-separated lists) so profiles can be
|
|
84
|
+
* filtered by certificate, bundle ID or device without parsing every
|
|
85
|
+
* entry. Certificates are referenced by serial number, the identifier
|
|
86
|
+
* Apple embeds in the profile itself.
|
|
87
|
+
*/
|
|
88
|
+
export type AppleProvisioningProfileSecretData = {
|
|
89
|
+
provisioningProfileBase64: string;
|
|
90
|
+
certificateSerialNumbers?: string;
|
|
91
|
+
bundleIDs?: string;
|
|
92
|
+
deviceIDs?: string;
|
|
93
|
+
teamID?: string;
|
|
94
|
+
profileName?: string;
|
|
95
|
+
uuid?: string;
|
|
96
|
+
expirationDate?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Data payload of an appStoreConnectApiKey secret: the private half of an
|
|
101
|
+
* App Store Connect API key plus the identifiers needed to sign JWTs with
|
|
102
|
+
* it. Apple serves the private key exactly once, so the stored copy is the
|
|
103
|
+
* only one.
|
|
104
|
+
*/
|
|
105
|
+
export type AppStoreConnectApiKeySecretData = {
|
|
106
|
+
/** Base64 of the .p8 private key PEM. */
|
|
107
|
+
privateKeyP8Base64: string;
|
|
108
|
+
/** Apple's key ID, e.g. 2X9R4HXF34. */
|
|
109
|
+
keyId: string;
|
|
110
|
+
/** Issuer ID for team keys. Absent for individual keys. */
|
|
111
|
+
issuerId?: string;
|
|
112
|
+
nickname?: string;
|
|
113
|
+
teamID?: string;
|
|
114
|
+
expirationDate?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function compactData(data: Record<string, string | undefined>): SigningSecretData {
|
|
118
|
+
const result: SigningSecretData = {};
|
|
119
|
+
for (const [key, value] of Object.entries(data)) {
|
|
120
|
+
if (value) result[key] = value;
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stores an Apple certificate bundle, conventionally named `${teamID}/${certificateType}`. */
|
|
126
|
+
export async function putAppleCertificateSecret(
|
|
127
|
+
store: SigningSecretStore,
|
|
128
|
+
name: string,
|
|
129
|
+
data: AppleCertificateSecretData,
|
|
130
|
+
) {
|
|
131
|
+
return store.put(APPLE_CERTIFICATE_SECRET_TYPE, name, compactData(data));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stores a provisioning profile, conventionally named `${teamID}/${uuid}`
|
|
136
|
+
* so a team can hold many profiles for the same bundle ID and certificate
|
|
137
|
+
* set; consumers select by the reference fields, not by name.
|
|
138
|
+
*/
|
|
139
|
+
export async function putAppleProvisioningProfileSecret(
|
|
140
|
+
store: SigningSecretStore,
|
|
141
|
+
name: string,
|
|
142
|
+
data: AppleProvisioningProfileSecretData,
|
|
143
|
+
) {
|
|
144
|
+
return store.put(APPLE_PROVISIONING_PROFILE_SECRET_TYPE, name, compactData(data));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Stores an App Store Connect API key, conventionally named
|
|
149
|
+
* `${teamID}/APP_STORE_CONNECT_API_KEY`: one shared key per team.
|
|
150
|
+
*/
|
|
151
|
+
export async function putAppStoreConnectApiKeySecret(
|
|
152
|
+
store: SigningSecretStore,
|
|
153
|
+
name: string,
|
|
154
|
+
data: AppStoreConnectApiKeySecretData,
|
|
155
|
+
) {
|
|
156
|
+
return store.put(APP_STORE_CONNECT_API_KEY_SECRET_TYPE, name, compactData(data));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type LimrunSecretStoreOptions = {
|
|
160
|
+
/** Base URL of the Limrun backend API, e.g. https://api.limrun.com */
|
|
161
|
+
apiUrl: string;
|
|
162
|
+
/** Bearer token: a user or organization token. */
|
|
163
|
+
token: string;
|
|
164
|
+
/** Organization TID owning the secrets. */
|
|
165
|
+
organizationId: string;
|
|
166
|
+
/** Custom fetch, mainly for tests. */
|
|
167
|
+
fetch?: typeof fetch;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
type BackendSecretResult = {
|
|
171
|
+
id: string;
|
|
172
|
+
type: string;
|
|
173
|
+
name: string;
|
|
174
|
+
organizationId: string;
|
|
175
|
+
data: SigningSecretData;
|
|
176
|
+
createdAt: string;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A SigningSecretStore backed by Limrun's organization secret store
|
|
181
|
+
* (`/v1/organizations/{org}/secrets`). Secrets are stored server-side and
|
|
182
|
+
* shared across the organization.
|
|
183
|
+
*/
|
|
184
|
+
export function createLimrunSecretStore(options: LimrunSecretStoreOptions): SigningSecretStore {
|
|
185
|
+
const doFetch = options.fetch ?? fetch.bind(globalThis);
|
|
186
|
+
const base = options.apiUrl.replace(/\/+$/, '');
|
|
187
|
+
const secretUrl = (type: string, name: string) =>
|
|
188
|
+
`${base}/v1/organizations/${encodeURIComponent(options.organizationId)}/secrets/${encodeURIComponent(
|
|
189
|
+
type,
|
|
190
|
+
)}/${encodeURIComponent(name)}`;
|
|
191
|
+
const headers = {
|
|
192
|
+
Authorization: `Bearer ${options.token}`,
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
async function fail(response: Response, action: string): Promise<never> {
|
|
197
|
+
let message = `${response.status}`;
|
|
198
|
+
try {
|
|
199
|
+
const body = (await response.json()) as { message?: string };
|
|
200
|
+
if (body.message) message = body.message;
|
|
201
|
+
} catch {
|
|
202
|
+
// Non-JSON error body; the status code is the best we have.
|
|
203
|
+
}
|
|
204
|
+
throw new Error(`Failed to ${action} secret: ${message}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
async put(type, name, data) {
|
|
209
|
+
const response = await doFetch(secretUrl(type, name), {
|
|
210
|
+
method: 'PUT',
|
|
211
|
+
headers,
|
|
212
|
+
body: JSON.stringify({ data, replace: true }),
|
|
213
|
+
});
|
|
214
|
+
if (!response.ok) await fail(response, 'store');
|
|
215
|
+
const result = (await response.json()) as BackendSecretResult;
|
|
216
|
+
return { type: result.type, name: result.name, createdAt: result.createdAt, data: result.data };
|
|
217
|
+
},
|
|
218
|
+
async get(type, name) {
|
|
219
|
+
const response = await doFetch(secretUrl(type, name), { method: 'GET', headers });
|
|
220
|
+
if (response.status === 404) return undefined;
|
|
221
|
+
if (!response.ok) await fail(response, 'fetch');
|
|
222
|
+
const result = (await response.json()) as BackendSecretResult;
|
|
223
|
+
return { type: result.type, name: result.name, createdAt: result.createdAt, data: result.data };
|
|
224
|
+
},
|
|
225
|
+
async list() {
|
|
226
|
+
const response = await doFetch(
|
|
227
|
+
`${base}/v1/organizations/${encodeURIComponent(options.organizationId)}/secrets`,
|
|
228
|
+
{ method: 'GET', headers },
|
|
229
|
+
);
|
|
230
|
+
if (!response.ok) await fail(response, 'list');
|
|
231
|
+
const result = (await response.json()) as Omit<BackendSecretResult, 'data'>[];
|
|
232
|
+
return result
|
|
233
|
+
.filter(
|
|
234
|
+
(s) =>
|
|
235
|
+
s.type === APPLE_CERTIFICATE_SECRET_TYPE ||
|
|
236
|
+
s.type === APPLE_PROVISIONING_PROFILE_SECRET_TYPE ||
|
|
237
|
+
s.type === APP_STORE_CONNECT_API_KEY_SECRET_TYPE,
|
|
238
|
+
)
|
|
239
|
+
.map((s) => ({ type: s.type, name: s.name, createdAt: s.createdAt }));
|
|
240
|
+
},
|
|
241
|
+
async delete(type, name) {
|
|
242
|
+
const response = await doFetch(secretUrl(type, name), { method: 'DELETE', headers });
|
|
243
|
+
if (response.status === 404) return;
|
|
244
|
+
if (!response.ok) await fail(response, 'delete');
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const SECRETS_DB_NAME = 'limrun-signing-secrets';
|
|
250
|
+
const SECRETS_DB_VERSION = 1;
|
|
251
|
+
const SECRETS_STORE_NAME = 'secrets';
|
|
252
|
+
|
|
253
|
+
type BrowserStoredSecret = {
|
|
254
|
+
id: string;
|
|
255
|
+
type: string;
|
|
256
|
+
name: string;
|
|
257
|
+
data: SigningSecretData;
|
|
258
|
+
createdAt: string;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A SigningSecretStore keeping everything in the browser's IndexedDB.
|
|
263
|
+
* Nothing leaves the machine; secrets are per-browser-profile.
|
|
264
|
+
*/
|
|
265
|
+
export function createBrowserSecretStore(): SigningSecretStore {
|
|
266
|
+
const idOf = (type: string, name: string) => `${type}:${name}`;
|
|
267
|
+
|
|
268
|
+
function open() {
|
|
269
|
+
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
270
|
+
const request = indexedDB.open(SECRETS_DB_NAME, SECRETS_DB_VERSION);
|
|
271
|
+
request.onupgradeneeded = () => {
|
|
272
|
+
const db = request.result;
|
|
273
|
+
if (!db.objectStoreNames.contains(SECRETS_STORE_NAME)) {
|
|
274
|
+
db.createObjectStore(SECRETS_STORE_NAME, { keyPath: 'id' });
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
request.onsuccess = () => resolve(request.result);
|
|
278
|
+
request.onerror = () => reject(request.error ?? new Error('Open IndexedDB failed'));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function toPromise<T = unknown>(request: IDBRequest<T>) {
|
|
283
|
+
return new Promise<T>((resolve, reject) => {
|
|
284
|
+
request.onsuccess = () => resolve(request.result);
|
|
285
|
+
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
async put(type, name, data) {
|
|
291
|
+
const db = await open();
|
|
292
|
+
const existing = await toPromise<BrowserStoredSecret | undefined>(
|
|
293
|
+
db.transaction(SECRETS_STORE_NAME, 'readonly').objectStore(SECRETS_STORE_NAME).get(idOf(type, name)),
|
|
294
|
+
);
|
|
295
|
+
const stored: BrowserStoredSecret = {
|
|
296
|
+
id: idOf(type, name),
|
|
297
|
+
type,
|
|
298
|
+
name,
|
|
299
|
+
data,
|
|
300
|
+
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
301
|
+
};
|
|
302
|
+
await toPromise(
|
|
303
|
+
db.transaction(SECRETS_STORE_NAME, 'readwrite').objectStore(SECRETS_STORE_NAME).put(stored),
|
|
304
|
+
);
|
|
305
|
+
return { type, name, createdAt: stored.createdAt, data };
|
|
306
|
+
},
|
|
307
|
+
async get(type, name) {
|
|
308
|
+
const db = await open();
|
|
309
|
+
const stored = await toPromise<BrowserStoredSecret | undefined>(
|
|
310
|
+
db.transaction(SECRETS_STORE_NAME, 'readonly').objectStore(SECRETS_STORE_NAME).get(idOf(type, name)),
|
|
311
|
+
);
|
|
312
|
+
if (!stored) return undefined;
|
|
313
|
+
return { type: stored.type, name: stored.name, createdAt: stored.createdAt, data: stored.data };
|
|
314
|
+
},
|
|
315
|
+
async list() {
|
|
316
|
+
const db = await open();
|
|
317
|
+
const all = await toPromise<BrowserStoredSecret[]>(
|
|
318
|
+
db.transaction(SECRETS_STORE_NAME, 'readonly').objectStore(SECRETS_STORE_NAME).getAll(),
|
|
319
|
+
);
|
|
320
|
+
return all.map((s) => ({ type: s.type, name: s.name, createdAt: s.createdAt }));
|
|
321
|
+
},
|
|
322
|
+
async delete(type, name) {
|
|
323
|
+
const db = await open();
|
|
324
|
+
await toPromise(
|
|
325
|
+
db
|
|
326
|
+
.transaction(SECRETS_STORE_NAME, 'readwrite')
|
|
327
|
+
.objectStore(SECRETS_STORE_NAME)
|
|
328
|
+
.delete(idOf(type, name)),
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
@@ -40,6 +40,12 @@ export type ProvisioningProfileInfo = {
|
|
|
40
40
|
applicationIdentifier?: string;
|
|
41
41
|
bundleID?: string;
|
|
42
42
|
provisionedDevices: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Serial numbers (uppercase hex, no leading zeros) of the developer
|
|
45
|
+
* certificates embedded in the profile; the profile is only usable for
|
|
46
|
+
* signing with one of these certificates.
|
|
47
|
+
*/
|
|
48
|
+
certificateSerialNumbers: string[];
|
|
43
49
|
getTaskAllow?: boolean;
|
|
44
50
|
expirationDate?: string;
|
|
45
51
|
};
|