fastlane-plugin-voip_push_certificate 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +138 -0
- data/lib/fastlane/plugin/voip_push_certificate/actions/get_voip_push_certificate_action.rb +193 -0
- data/lib/fastlane/plugin/voip_push_certificate/helper/voip_push_certificate_helper.rb +178 -0
- data/lib/fastlane/plugin/voip_push_certificate/version.rb +5 -0
- data/lib/fastlane/plugin/voip_push_certificate.rb +16 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b816333567b5063e96d1581ed6cf42362824ae82ec9783cccc09ab1c6efa8578
|
|
4
|
+
data.tar.gz: 8f0fc9c35319c303b6d1ee8041ac18063157a19250b4e333330db038cc20f8cc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7a2cdc229c63491b18f06539c66c2a038114c0748bb3985b08489f5d9f4993bbd27218e81c47c584a01043566bbd33d59e16285bf31fd8a96c38257b18666c1e
|
|
7
|
+
data.tar.gz: 2fb9a43b615924d7c3c2adf9f1045a25766de244bd45363d7cee98618ce1c4d465a6c3c5cc23285fa9bb37f943d044546e63c5312971877cf767f33076c4a585
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `get_voip_push_certificate` action, creating and renewing Apple VoIP Services push
|
|
15
|
+
certificates through Spaceship's Developer Portal API.
|
|
16
|
+
- Writes `.pem` and `.p12` files, with optional `.pkey` and raw DER `.cer` output.
|
|
17
|
+
- `active_days_limit` (default 30) and `force` options controlling when an existing
|
|
18
|
+
certificate is renewed, matching the behaviour of `get_push_certificate`.
|
|
19
|
+
- `revoke_existing` option, disabled by default, to revoke the matching certificate
|
|
20
|
+
before creating a new one.
|
|
21
|
+
- `bag_attributes` option, writing the `.pem` with OpenSSL `friendlyName` and
|
|
22
|
+
`localKeyID` headers for push providers that require that format.
|
|
23
|
+
- Generated paths exposed through the lane context as
|
|
24
|
+
`VOIP_PUSH_CERTIFICATE_{PEM,P12,PKEY,CER}_PATH`.
|
|
25
|
+
|
|
26
|
+
[Unreleased]: https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate/compare/v0.1.0...HEAD
|
|
27
|
+
[0.1.0]: https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rafael Ferreira <rafinha951026@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# fastlane-plugin-voip_push_certificate
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate/actions/workflows/test.yml)
|
|
4
|
+
|
|
5
|
+
Create and renew Apple **VoIP Services** push certificates (the certificate type PushKit
|
|
6
|
+
requires) from a fastlane lane.
|
|
7
|
+
|
|
8
|
+
fastlane's built-in [`get_push_certificate`](https://docs.fastlane.tools/actions/get_push_certificate/)
|
|
9
|
+
(`pem`) handles development, production and website push certificates, but not VoIP ones.
|
|
10
|
+
This plugin fills that gap using the same Spaceship APIs, so it behaves the way you already
|
|
11
|
+
expect `pem` to behave.
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
Requires Ruby 3.1 or newer.
|
|
16
|
+
|
|
17
|
+
Add the plugin to your project:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
fastlane add_plugin voip_push_certificate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or, to use it straight from source without installing the gem, add this to your `fastlane/Pluginfile`:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem 'fastlane-plugin-voip_push_certificate', git: 'https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
lane :renew_voip do
|
|
33
|
+
get_voip_push_certificate(
|
|
34
|
+
app_identifier: "com.example.app",
|
|
35
|
+
output_path: "./certs"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
By default this creates `voip_com.example.app.pem` and `voip_com.example.app.p12` (plus the
|
|
41
|
+
private key) in `output_path`, but **only if** the current certificate expires within 30 days.
|
|
42
|
+
Otherwise it logs how long the existing one is still valid and does nothing.
|
|
43
|
+
|
|
44
|
+
The action returns the path to the generated `.pem`, or `nil` when the existing certificate
|
|
45
|
+
was kept. All generated paths are also exposed through the lane context:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PEM_PATH]
|
|
49
|
+
lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_P12_PATH]
|
|
50
|
+
lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PKEY_PATH]
|
|
51
|
+
lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_CER_PATH]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Options
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Description |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `app_identifier` | String | from `Appfile` | The bundle identifier of your app |
|
|
59
|
+
| `username` | String | from `Appfile` | Your Apple ID username |
|
|
60
|
+
| `team_id` | String | from `Appfile` | Developer Portal team ID, if you're in multiple teams |
|
|
61
|
+
| `team_name` | String | from `Appfile` | Developer Portal team name, if you're in multiple teams |
|
|
62
|
+
| `active_days_limit` | Integer | `30` | Renew only if the current certificate expires within this many days |
|
|
63
|
+
| `force` | Boolean | `false` | Create a new certificate even if the current one is still valid |
|
|
64
|
+
| `revoke_existing` | Boolean | `false` | Revoke the matching certificate before creating a new one. See the warning below |
|
|
65
|
+
| `generate_p12` | Boolean | `true` | Also write a `.p12` file |
|
|
66
|
+
| `p12_password` | String | none | Password for the `.p12` file |
|
|
67
|
+
| `save_private_key` | Boolean | `true` | Also write the private key as a `.pkey` file |
|
|
68
|
+
| `save_cer` | Boolean | `false` | Also write the raw DER certificate as a `.cer` file |
|
|
69
|
+
| `bag_attributes` | Boolean | `false` | Write the `.pem` with OpenSSL bag attributes. See below |
|
|
70
|
+
| `pem_name` | String | `voip_<app_identifier>` | Base file name for the generated files |
|
|
71
|
+
| `output_path` | String | `.` | Directory to write the certificates into |
|
|
72
|
+
|
|
73
|
+
Every option also has an environment variable, e.g. `VOIP_PUSH_CERTIFICATE_APP_IDENTIFIER`.
|
|
74
|
+
|
|
75
|
+
## Things worth knowing
|
|
76
|
+
|
|
77
|
+
### There is no development/production distinction
|
|
78
|
+
|
|
79
|
+
Apple issues a single VoIP Services certificate that is valid for **both** the sandbox and the
|
|
80
|
+
production APNs environments. That is why, unlike `get_push_certificate`, this action has no
|
|
81
|
+
`development` option.
|
|
82
|
+
|
|
83
|
+
### Revoking is opt-in, on purpose
|
|
84
|
+
|
|
85
|
+
Apple allows at most two active VoIP certificates per App ID. When that limit is reached the
|
|
86
|
+
action fails with an explanation rather than revoking anything, because revoking a certificate
|
|
87
|
+
**immediately** stops every push provider still using it, including your production one.
|
|
88
|
+
|
|
89
|
+
Set `revoke_existing: true` only when you are sure nothing depends on the old certificate:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
get_voip_push_certificate(
|
|
93
|
+
app_identifier: "com.example.app",
|
|
94
|
+
force: true,
|
|
95
|
+
revoke_existing: true
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### The `bag_attributes` option
|
|
100
|
+
|
|
101
|
+
Some push providers reject a plain concatenated PEM and expect the format that
|
|
102
|
+
`openssl pkcs12 -nodes` produces, with `friendlyName` and `localKeyID` headers around each
|
|
103
|
+
block. If yours does, set `bag_attributes: true`. The default (`false`) matches what
|
|
104
|
+
`get_push_certificate` writes: the certificate followed by the private key, nothing else.
|
|
105
|
+
|
|
106
|
+
### Authentication
|
|
107
|
+
|
|
108
|
+
This uses Spaceship's Apple Developer Portal API, which needs an Apple ID and password:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
export FASTLANE_USER="you@example.com"
|
|
112
|
+
export FASTLANE_PASSWORD="…"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Two-factor authentication is supported through fastlane's usual session mechanism
|
|
116
|
+
(`FASTLANE_SESSION`, `fastlane spaceauth`).
|
|
117
|
+
|
|
118
|
+
**App Store Connect API keys are not supported.** Apple's App Store Connect API does not cover
|
|
119
|
+
push certificates, so the portal API is the only option. This is a limitation of Apple's API,
|
|
120
|
+
not of this plugin. `get_push_certificate` has exactly the same constraint.
|
|
121
|
+
|
|
122
|
+
## Running tests
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
bundle install
|
|
126
|
+
bundle exec rake
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Relationship to fastlane
|
|
130
|
+
|
|
131
|
+
This is an unofficial plugin and is not affiliated with fastlane or Apple. Adding VoIP support
|
|
132
|
+
to `pem` itself has been requested since 2017 in
|
|
133
|
+
[fastlane/fastlane#8145](https://github.com/fastlane/fastlane/issues/8145); if it ever lands
|
|
134
|
+
upstream, this plugin will be deprecated in favour of it.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require 'fastlane/action'
|
|
2
|
+
require 'fastlane_core/configuration/config_item'
|
|
3
|
+
require 'credentials_manager/appfile_config'
|
|
4
|
+
require_relative '../helper/voip_push_certificate_helper'
|
|
5
|
+
|
|
6
|
+
module Fastlane
|
|
7
|
+
module Actions
|
|
8
|
+
module SharedValues
|
|
9
|
+
VOIP_PUSH_CERTIFICATE_PEM_PATH = :VOIP_PUSH_CERTIFICATE_PEM_PATH
|
|
10
|
+
VOIP_PUSH_CERTIFICATE_P12_PATH = :VOIP_PUSH_CERTIFICATE_P12_PATH
|
|
11
|
+
VOIP_PUSH_CERTIFICATE_PKEY_PATH = :VOIP_PUSH_CERTIFICATE_PKEY_PATH
|
|
12
|
+
VOIP_PUSH_CERTIFICATE_CER_PATH = :VOIP_PUSH_CERTIFICATE_CER_PATH
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class GetVoipPushCertificateAction < Action
|
|
16
|
+
def self.run(params)
|
|
17
|
+
paths = Helper::VoipPushCertificateHelper.new(params).run
|
|
18
|
+
return nil if paths.nil?
|
|
19
|
+
|
|
20
|
+
Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PEM_PATH] = paths[:pem]
|
|
21
|
+
Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_P12_PATH] = paths[:p12]
|
|
22
|
+
Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PKEY_PATH] = paths[:pkey]
|
|
23
|
+
Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_CER_PATH] = paths[:cer]
|
|
24
|
+
|
|
25
|
+
paths[:pem]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.description
|
|
29
|
+
"Create and renew Apple VoIP Services push certificates"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.details
|
|
33
|
+
[
|
|
34
|
+
"Generates an Apple *VoIP Services* push certificate (the certificate type used by PushKit)",
|
|
35
|
+
"and writes it to disk as `.pem`, `.p12` and, optionally, `.cer`.",
|
|
36
|
+
"",
|
|
37
|
+
"fastlane's built-in `get_push_certificate` covers development, production and website push",
|
|
38
|
+
"certificates, but not VoIP ones. This plugin fills that gap using the same Spaceship APIs.",
|
|
39
|
+
"",
|
|
40
|
+
"Apple issues a single VoIP Services certificate that works for both the sandbox and the",
|
|
41
|
+
"production APNs environments, so there is no `development` option here.",
|
|
42
|
+
"",
|
|
43
|
+
"By default an existing certificate is never revoked: if the app already has two active VoIP",
|
|
44
|
+
"certificates the action fails with an explanation. Set `revoke_existing` to true only if you",
|
|
45
|
+
"are sure no push provider still depends on the old certificate.",
|
|
46
|
+
"",
|
|
47
|
+
"Authentication goes through Spaceship's Apple Developer Portal API, which requires an Apple ID",
|
|
48
|
+
"and password (`FASTLANE_USER` / `FASTLANE_PASSWORD`) plus 2FA. App Store Connect API keys are",
|
|
49
|
+
"not supported for push certificates by Apple's portal API."
|
|
50
|
+
].join("\n")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.return_value
|
|
54
|
+
"The path to the generated .pem file, or nil when the existing certificate was still valid and was kept"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.output
|
|
58
|
+
[
|
|
59
|
+
['VOIP_PUSH_CERTIFICATE_PEM_PATH', 'The path to the generated .pem file'],
|
|
60
|
+
['VOIP_PUSH_CERTIFICATE_P12_PATH', 'The path to the generated .p12 file'],
|
|
61
|
+
['VOIP_PUSH_CERTIFICATE_PKEY_PATH', 'The path to the generated private key'],
|
|
62
|
+
['VOIP_PUSH_CERTIFICATE_CER_PATH', 'The path to the generated .cer file']
|
|
63
|
+
]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.authors
|
|
67
|
+
["Rafael Ferreira"]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.example_code
|
|
71
|
+
[
|
|
72
|
+
'get_voip_push_certificate',
|
|
73
|
+
'get_voip_push_certificate(
|
|
74
|
+
app_identifier: "com.example.app",
|
|
75
|
+
output_path: "./certs"
|
|
76
|
+
)',
|
|
77
|
+
'# Renew even if the current certificate is still valid for a long time
|
|
78
|
+
get_voip_push_certificate(
|
|
79
|
+
app_identifier: "com.example.app",
|
|
80
|
+
force: true,
|
|
81
|
+
revoke_existing: true
|
|
82
|
+
)'
|
|
83
|
+
]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.category
|
|
87
|
+
:push
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.available_options
|
|
91
|
+
user = CredentialsManager::AppfileConfig.try_fetch_value(:apple_dev_portal_id)
|
|
92
|
+
user ||= CredentialsManager::AppfileConfig.try_fetch_value(:apple_id)
|
|
93
|
+
|
|
94
|
+
[
|
|
95
|
+
FastlaneCore::ConfigItem.new(key: :app_identifier,
|
|
96
|
+
short_option: "-a",
|
|
97
|
+
env_name: "VOIP_PUSH_CERTIFICATE_APP_IDENTIFIER",
|
|
98
|
+
description: "The bundle identifier of your app",
|
|
99
|
+
code_gen_sensitive: true,
|
|
100
|
+
default_value: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier),
|
|
101
|
+
default_value_dynamic: true),
|
|
102
|
+
FastlaneCore::ConfigItem.new(key: :username,
|
|
103
|
+
short_option: "-u",
|
|
104
|
+
env_name: "VOIP_PUSH_CERTIFICATE_USERNAME",
|
|
105
|
+
description: "Your Apple ID username",
|
|
106
|
+
default_value: user,
|
|
107
|
+
default_value_dynamic: true),
|
|
108
|
+
FastlaneCore::ConfigItem.new(key: :team_id,
|
|
109
|
+
short_option: "-b",
|
|
110
|
+
env_name: "VOIP_PUSH_CERTIFICATE_TEAM_ID",
|
|
111
|
+
description: "The ID of your Developer Portal team if you're in multiple teams",
|
|
112
|
+
code_gen_sensitive: true,
|
|
113
|
+
default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_id),
|
|
114
|
+
default_value_dynamic: true,
|
|
115
|
+
optional: true,
|
|
116
|
+
verify_block: proc do |value|
|
|
117
|
+
ENV["FASTLANE_TEAM_ID"] = value.to_s
|
|
118
|
+
end),
|
|
119
|
+
FastlaneCore::ConfigItem.new(key: :team_name,
|
|
120
|
+
short_option: "-l",
|
|
121
|
+
env_name: "VOIP_PUSH_CERTIFICATE_TEAM_NAME",
|
|
122
|
+
description: "The name of your Developer Portal team if you're in multiple teams",
|
|
123
|
+
code_gen_sensitive: true,
|
|
124
|
+
default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_name),
|
|
125
|
+
default_value_dynamic: true,
|
|
126
|
+
optional: true,
|
|
127
|
+
verify_block: proc do |value|
|
|
128
|
+
ENV["FASTLANE_TEAM_NAME"] = value.to_s
|
|
129
|
+
end),
|
|
130
|
+
FastlaneCore::ConfigItem.new(key: :active_days_limit,
|
|
131
|
+
env_name: "VOIP_PUSH_CERTIFICATE_ACTIVE_DAYS_LIMIT",
|
|
132
|
+
description: "If the current certificate is active for less than this number of days, generate a new one",
|
|
133
|
+
default_value: 30,
|
|
134
|
+
type: Integer,
|
|
135
|
+
verify_block: proc do |value|
|
|
136
|
+
UI.user_error!("Value of active_days_limit must be a positive integer") unless value.kind_of?(Integer) && value > 0
|
|
137
|
+
end),
|
|
138
|
+
FastlaneCore::ConfigItem.new(key: :force,
|
|
139
|
+
env_name: "VOIP_PUSH_CERTIFICATE_FORCE",
|
|
140
|
+
description: "Create a new certificate, even if the current one is active for more than `active_days_limit` days",
|
|
141
|
+
type: Boolean,
|
|
142
|
+
default_value: false),
|
|
143
|
+
FastlaneCore::ConfigItem.new(key: :revoke_existing,
|
|
144
|
+
env_name: "VOIP_PUSH_CERTIFICATE_REVOKE_EXISTING",
|
|
145
|
+
description: "Revoke the matching existing certificate before creating a new one. This immediately breaks any push provider still using it",
|
|
146
|
+
type: Boolean,
|
|
147
|
+
default_value: false),
|
|
148
|
+
FastlaneCore::ConfigItem.new(key: :generate_p12,
|
|
149
|
+
env_name: "VOIP_PUSH_CERTIFICATE_GENERATE_P12_FILE",
|
|
150
|
+
description: "Generate a p12 file additionally to the PEM file",
|
|
151
|
+
type: Boolean,
|
|
152
|
+
default_value: true),
|
|
153
|
+
FastlaneCore::ConfigItem.new(key: :p12_password,
|
|
154
|
+
short_option: "-p",
|
|
155
|
+
env_name: "VOIP_PUSH_CERTIFICATE_P12_PASSWORD",
|
|
156
|
+
sensitive: true,
|
|
157
|
+
description: "The password that is used for your p12 file",
|
|
158
|
+
optional: true),
|
|
159
|
+
FastlaneCore::ConfigItem.new(key: :save_private_key,
|
|
160
|
+
short_option: "-s",
|
|
161
|
+
env_name: "VOIP_PUSH_CERTIFICATE_SAVE_PRIVATE_KEY",
|
|
162
|
+
description: "Set to save the private RSA key as a separate .pkey file",
|
|
163
|
+
type: Boolean,
|
|
164
|
+
default_value: true),
|
|
165
|
+
FastlaneCore::ConfigItem.new(key: :save_cer,
|
|
166
|
+
env_name: "VOIP_PUSH_CERTIFICATE_SAVE_CER",
|
|
167
|
+
description: "Additionally save the raw DER certificate as a .cer file",
|
|
168
|
+
type: Boolean,
|
|
169
|
+
default_value: false),
|
|
170
|
+
FastlaneCore::ConfigItem.new(key: :bag_attributes,
|
|
171
|
+
env_name: "VOIP_PUSH_CERTIFICATE_BAG_ATTRIBUTES",
|
|
172
|
+
description: "Write the .pem with OpenSSL bag attributes (friendlyName / localKeyID), mimicking the output of `openssl pkcs12 -nodes`. Some push providers require this format",
|
|
173
|
+
type: Boolean,
|
|
174
|
+
default_value: false),
|
|
175
|
+
FastlaneCore::ConfigItem.new(key: :pem_name,
|
|
176
|
+
short_option: "-o",
|
|
177
|
+
env_name: "VOIP_PUSH_CERTIFICATE_PEM_NAME",
|
|
178
|
+
description: "The base file name of the generated files. Defaults to `voip_<app_identifier>`",
|
|
179
|
+
optional: true),
|
|
180
|
+
FastlaneCore::ConfigItem.new(key: :output_path,
|
|
181
|
+
short_option: "-e",
|
|
182
|
+
env_name: "VOIP_PUSH_CERTIFICATE_OUTPUT_PATH",
|
|
183
|
+
description: "The path to a directory in which all certificates and private keys should be stored",
|
|
184
|
+
default_value: ".")
|
|
185
|
+
]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.is_supported?(platform)
|
|
189
|
+
[:ios].include?(platform)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
require 'fastlane_core/ui/ui'
|
|
2
|
+
require 'spaceship'
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
|
|
7
|
+
module Fastlane
|
|
8
|
+
UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
|
|
9
|
+
|
|
10
|
+
module Helper
|
|
11
|
+
# Creates and renews Apple "VoIP Services" push certificates. Apple issues a single
|
|
12
|
+
# VoIP certificate valid for both the sandbox and the production APNs environments,
|
|
13
|
+
# so there is no development/production distinction here.
|
|
14
|
+
class VoipPushCertificateHelper
|
|
15
|
+
CERTIFICATE_LABEL = "VoIP Services".freeze
|
|
16
|
+
|
|
17
|
+
def initialize(params)
|
|
18
|
+
@params = params
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash, nil] paths of the generated files, or nil when the existing
|
|
22
|
+
# certificate is still valid and no new one was created.
|
|
23
|
+
def run
|
|
24
|
+
login
|
|
25
|
+
|
|
26
|
+
existing = existing_certificate
|
|
27
|
+
if existing
|
|
28
|
+
days = remaining_days(existing)
|
|
29
|
+
UI.message("Existing VoIP Services certificate for '#{existing.owner_name}' is valid for #{days.round} more days.")
|
|
30
|
+
return nil if keep_existing?(days)
|
|
31
|
+
|
|
32
|
+
revoke!(existing) if @params[:revoke_existing]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
create_certificate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def app_identifier
|
|
41
|
+
@params[:app_identifier]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def login
|
|
45
|
+
UI.message("Starting login with user '#{@params[:username]}'")
|
|
46
|
+
Spaceship.login(@params[:username], nil)
|
|
47
|
+
Spaceship.client.select_team
|
|
48
|
+
UI.message("Successfully logged in")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `Spaceship.certificate.voip_push` resolves to
|
|
52
|
+
# `Spaceship::Portal::Certificate::VoipPush` through Spaceship's method_missing.
|
|
53
|
+
def certificate_class
|
|
54
|
+
Spaceship.certificate.voip_push
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def existing_certificate
|
|
58
|
+
certificate_class.all.sort { |x, y| y.expires <=> x.expires }.detect do |cert|
|
|
59
|
+
cert.owner_name == app_identifier
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def remaining_days(certificate)
|
|
64
|
+
(certificate.expires - Time.now) / 60 / 60 / 24
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def keep_existing?(days)
|
|
68
|
+
return false if days <= @params[:active_days_limit]
|
|
69
|
+
|
|
70
|
+
if @params[:force]
|
|
71
|
+
UI.success("You already have an existing VoIP certificate, but a new one will be created since the `force` option has been set.")
|
|
72
|
+
return false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
UI.success("You already have a VoIP certificate, which is active for more than #{@params[:active_days_limit]} more days. No need to create a new one.")
|
|
76
|
+
UI.success("If you still want to create a new one, use the `force` option.")
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def revoke!(certificate)
|
|
81
|
+
UI.important("Revoking the existing VoIP Services certificate for '#{certificate.owner_name}' (expires #{certificate.expires}).")
|
|
82
|
+
UI.important("Any push provider still using that certificate will stop being able to send VoIP pushes.")
|
|
83
|
+
certificate.revoke!
|
|
84
|
+
UI.success("Revoked the previous certificate.")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def create_certificate
|
|
88
|
+
verify_app_exists!
|
|
89
|
+
UI.important("Creating a new VoIP Services certificate for app '#{app_identifier}'.")
|
|
90
|
+
|
|
91
|
+
csr, pkey = Spaceship.certificate.create_certificate_signing_request
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
certificate = certificate_class.create!(csr: csr, bundle_id: app_identifier)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
if e.to_s.include?("You already have a current")
|
|
97
|
+
UI.message(e.to_s)
|
|
98
|
+
UI.user_error!("You already have 2 active VoIP Services certificates for '#{app_identifier}'. " \
|
|
99
|
+
"Revoke one in the Apple Developer Portal, or re-run with `revoke_existing: true` " \
|
|
100
|
+
"to let this action revoke the matching one for you.")
|
|
101
|
+
else
|
|
102
|
+
raise e
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
write_files(certificate.download, pkey)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# `create!` already raises when the bundle id is unknown, but it does so from
|
|
110
|
+
# deep inside Spaceship. Failing early gives a much clearer message.
|
|
111
|
+
def verify_app_exists!
|
|
112
|
+
app = Spaceship.app.find(app_identifier)
|
|
113
|
+
UI.user_error!("Could not find an App ID for '#{app_identifier}' in the Apple Developer Portal.") unless app
|
|
114
|
+
UI.message("Found app '#{app.name}' for bundle id '#{app_identifier}'")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def write_files(x509_certificate, pkey)
|
|
118
|
+
output_path = File.expand_path(@params[:output_path])
|
|
119
|
+
FileUtils.mkdir_p(output_path)
|
|
120
|
+
|
|
121
|
+
filename_base = @params[:pem_name] || "voip_#{app_identifier}"
|
|
122
|
+
filename_base = File.basename(filename_base, ".pem")
|
|
123
|
+
|
|
124
|
+
paths = {}
|
|
125
|
+
|
|
126
|
+
if @params[:save_private_key]
|
|
127
|
+
paths[:pkey] = File.join(output_path, "#{filename_base}.pkey")
|
|
128
|
+
File.write(paths[:pkey], pkey.to_pem)
|
|
129
|
+
UI.message("Private key: ".green + Pathname.new(paths[:pkey]).realpath.to_s)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if @params[:save_cer]
|
|
133
|
+
paths[:cer] = File.join(output_path, "#{filename_base}.cer")
|
|
134
|
+
File.binwrite(paths[:cer], x509_certificate.to_der)
|
|
135
|
+
UI.message("Certificate (DER): ".green + Pathname.new(paths[:cer]).realpath.to_s)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if @params[:generate_p12]
|
|
139
|
+
paths[:p12] = File.join(output_path, "#{filename_base}.p12")
|
|
140
|
+
p12_password = @params[:p12_password] == "" ? nil : @params[:p12_password]
|
|
141
|
+
p12 = OpenSSL::PKCS12.create(p12_password, friendly_name, pkey, x509_certificate)
|
|
142
|
+
File.binwrite(paths[:p12], p12.to_der)
|
|
143
|
+
UI.message("p12 certificate: ".green + Pathname.new(paths[:p12]).realpath.to_s)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
paths[:pem] = File.join(output_path, "#{filename_base}.pem")
|
|
147
|
+
File.write(paths[:pem], pem_contents(x509_certificate, pkey))
|
|
148
|
+
UI.message("PEM: ".green + Pathname.new(paths[:pem]).realpath.to_s)
|
|
149
|
+
|
|
150
|
+
paths
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def friendly_name
|
|
154
|
+
"#{CERTIFICATE_LABEL}: #{app_identifier}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def pem_contents(x509_certificate, pkey)
|
|
158
|
+
return x509_certificate.to_pem + pkey.to_pem unless @params[:bag_attributes]
|
|
159
|
+
|
|
160
|
+
header = "Bag Attributes\n " \
|
|
161
|
+
"friendlyName: #{friendly_name}\n " \
|
|
162
|
+
"localKeyID: #{local_key_id(pkey)} \n"
|
|
163
|
+
|
|
164
|
+
"#{header}subject=#{x509_certificate.subject}\n" \
|
|
165
|
+
"issuer=#{x509_certificate.issuer}\n" \
|
|
166
|
+
"#{x509_certificate.to_pem.strip}\n" \
|
|
167
|
+
"#{header}Key Attributes: <No Attributes>\n" \
|
|
168
|
+
"#{pkey.to_pem.strip}\n"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Mimics the localKeyID that `openssl pkcs12 -nodes` writes: the SHA1 digest
|
|
172
|
+
# of the DER-encoded public key, as space separated uppercase hex bytes.
|
|
173
|
+
def local_key_id(pkey)
|
|
174
|
+
OpenSSL::Digest::SHA1.digest(pkey.public_key.to_der).bytes.map { |byte| format("%02X", byte) }.join(" ")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require 'fastlane/plugin/voip_push_certificate/version'
|
|
2
|
+
|
|
3
|
+
module Fastlane
|
|
4
|
+
module VoipPushCertificate
|
|
5
|
+
# Return all .rb files inside the "actions" and "helper" directory
|
|
6
|
+
def self.all_classes
|
|
7
|
+
Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))]
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# By default we want to import all available actions and helpers
|
|
13
|
+
# A plugin can contain any number of actions and plugins
|
|
14
|
+
Fastlane::VoipPushCertificate.all_classes.each do |current|
|
|
15
|
+
require current
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fastlane-plugin-voip_push_certificate
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rafael Ferreira
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email: rafinha951026@gmail.com
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- CHANGELOG.md
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/fastlane/plugin/voip_push_certificate.rb
|
|
23
|
+
- lib/fastlane/plugin/voip_push_certificate/actions/get_voip_push_certificate_action.rb
|
|
24
|
+
- lib/fastlane/plugin/voip_push_certificate/helper/voip_push_certificate_helper.rb
|
|
25
|
+
- lib/fastlane/plugin/voip_push_certificate/version.rb
|
|
26
|
+
homepage: https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata:
|
|
30
|
+
rubygems_mfa_required: 'true'
|
|
31
|
+
post_install_message:
|
|
32
|
+
rdoc_options: []
|
|
33
|
+
require_paths:
|
|
34
|
+
- lib
|
|
35
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.1'
|
|
40
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '0'
|
|
45
|
+
requirements: []
|
|
46
|
+
rubygems_version: 3.5.22
|
|
47
|
+
signing_key:
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: Create and renew Apple VoIP Services push certificates
|
|
50
|
+
test_files: []
|