-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecrets-api.php
More file actions
351 lines (306 loc) · 13.2 KB
/
Copy pathsecrets-api.php
File metadata and controls
351 lines (306 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
<?php
/**
* Plugin Name: Secrets API
* Plugin URI: https://github.com/ericmann/secrets-api
* Description: Feature plugin for the WordPress Secrets API proposed for 7.2. Encrypted, versioned credential storage with pluggable storage and keyring back ends.
* Version: 0.1.0
* Requires at least: 6.6
* Requires PHP: 7.4
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: secrets-api
*
* @package SecretsAPI
*/
/*
* The text domain here is the plugin's own. Everything under src/ uses core's
* 'default' domain instead, because those files are written to be copied into
* wordpress-develop unchanged; only this wrapper and the plugin-only directories
* carry a plugin domain.
*/
defined( 'ABSPATH' ) || exit;
/**
* Plugin version.
*/
define( 'WP_SECRETS_API_PLUGIN_VERSION', '0.1.0' );
/**
* The WordPress version expected to ship the Secrets API in core.
*
* Overridable from wp-config.php for the case where the API lands in a different
* release than currently planned. The proposal's own timeline allows for the API to
* be deferred to 7.3, which is why this gate is never used on its own -- see below.
*/
defined( 'WP_SECRETS_API_CORE_VERSION' ) || define( 'WP_SECRETS_API_CORE_VERSION', '7.2' );
/**
* Absolute path to the plugin directory, with a trailing slash.
*/
define( 'WP_SECRETS_API_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
/**
* Decide whether to load, and load.
*
* The entire no-op decision lives here. Files under src/ carry no function_exists() or
* class_exists() guards at all, for two reasons:
*
* 1. src/ is destined to be copied verbatim into wordpress-develop, and core's
* wp-includes files do not guard their own function declarations.
* 2. A per-function guard on a credential retrieval function is an overloading surface.
* An mu-plugin that declared wp_get_secret() first would silently win, and every
* secret read on the site would flow through it. All-or-nothing is the only safe
* granularity here.
*
* @return void
*/
function wp_secrets_api_bootstrap() {
global $wp_version;
$symbol_taken = function_exists( 'wp_get_secret' );
// Core ships the API. Load nothing; the plugin is redundant.
if ( version_compare( $wp_version, WP_SECRETS_API_CORE_VERSION, '>=' ) && $symbol_taken ) {
add_action( 'admin_notices', 'wp_secrets_api_notice_superseded' );
return;
}
/*
* The version gate is deliberately ANDed with a positive probe rather than used on
* its own. If 7.2 ships without the API, a bare ">= 7.2" check would silently
* disable this plugin and strand every site relying on it.
*/
// Something other than core already claimed the symbol. Refuse loudly rather than
// deferring to an unknown implementation of a credential store.
if ( $symbol_taken ) {
add_action( 'admin_notices', 'wp_secrets_api_notice_conflict' );
return;
}
/*
* Dependency order: public helper functions first (WP_Secret's destructor calls
* wp_secrets_memzero()), then value objects, then crypto, then storage.
*/
$core_bound = array(
'secrets.php',
'class-wp-secret-version.php',
'class-wp-secret.php',
'interface-wp-secrets-provider.php',
'interface-wp-secrets-keyring.php',
'class-wp-secrets-config-key-provider.php',
'class-wp-secrets-broken-keyring.php',
'class-wp-secrets-cipher.php',
'class-wp-secrets-key-manager.php',
'interface-wp-secrets-store.php',
'class-wp-secrets-option-store.php',
'class-wp-secrets-broken-store.php',
'class-wp-secrets-libsodium-provider.php',
'class-wp-secrets-broken-provider.php',
);
foreach ( $core_bound as $file ) {
require_once WP_SECRETS_API_PLUGIN_DIR . 'src/wp-includes/' . $file;
}
/*
* Site Health's own hooks ('site_status_tests', 'debug_information') only ever
* fire in wp-admin, but this is loaded unconditionally rather than gated on
* is_admin(): registering two filters is negligible overhead, and is_admin()
* is false during some of the same contexts Site Health's own async REST checks
* run in, which would make the gate unreliable in exactly the cases it matters.
*/
require_once WP_SECRETS_API_PLUGIN_DIR . 'src/wp-admin/includes/secrets-site-health.php';
/*
* Installed as the default store so that a site carrying prototype-format
* rows keeps answering wp_get_secret() once the plugins reading those rows
* move to this API, without anyone having to run a migration first. See the
* class docblock -- this is an adoption path, not a compatibility layer.
*
* Set before the drop-in loads, deliberately: a drop-in that installs a host
* store overwrites this global and the fallback disappears with it, which is
* the right outcome. A host serving secrets from its own platform has no
* prototype rows to inherit.
*/
require_once WP_SECRETS_API_PLUGIN_DIR . 'plugin/class-secrets-api-legacy-reader.php';
require_once WP_SECRETS_API_PLUGIN_DIR . 'plugin/class-secrets-api-prototype-fallback-store.php';
$GLOBALS['wp_secrets_store'] = new Secrets_API_Prototype_Fallback_Store( new WP_Secrets_Option_Store() );
/*
* Loaded only now, after every core-bound interface and class this plugin
* defines: a drop-in that declares `class My_Store implements
* WP_Secrets_Store` needs that interface to already exist to compile at all.
* This is "as early as it can" for a plugin specifically because of that
* ordering constraint; the core patch moves this into wp-settings.php, ahead
* of plugins_loaded entirely, once the interfaces live in wp-includes from
* the start of the request.
*/
wp_secrets_api_load_dropin();
register_activation_hook( __FILE__, 'wp_secrets_api_activate' );
register_uninstall_hook( __FILE__, 'wp_secrets_api_uninstall' );
/*
* Granting manage_network_secrets runs on every request rather than once at
* activation: unlike the administrator role, there is no persistent "network
* administrator" role object to add a capability to, so super admin status has
* to be checked live, the same way core itself gates network-only screens.
*/
add_filter( 'user_has_cap', 'wp_secrets_api_grant_network_cap_to_super_admins', 10, 4 );
// cli/ is never copied to core and is registered only under real WP-CLI --
// or, in this plugin's own test suite, the mock WP_CLI test double that
// tests/bootstrap.php defines before the plugin ever loads.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
/*
* The only code in this plugin that knows the prototype's on-disk format
* exists. Both files are read-only with respect to it, and both load only
* here, because `wp secret migrate-legacy` is their sole caller.
*
* This plugin deliberately provides no compatibility layer over the
* prototype -- no get_secret()/set_secret() shims, no reimplemented
* filters, nothing that lets prototype-era code keep running against the
* new API. What it guarantees instead is non-interference: the two option
* namespaces do not overlap ('_secret_' vs '_wp_secret_',
* '_secrets_master_key' vs '_wp_secrets_master_key'), so both systems run
* on one site without corrupting each other, and nothing here ever writes
* to or deletes a prototype-owned row. That property is enforced by
* test_never_writes_to_a_prototype_owned_option() rather than left to
* good intentions -- the AI plugin's vendored copy is actively reading
* those rows.
*
* The reader itself is loaded unconditionally above, since the read-time
* fallback store needs it on every request. Only the bulk migrator is
* CLI-only.
*
* THE DELETION SEAM. When the compatibility window closes, the entire
* prototype surface is these files and nothing else:
*
* - plugin/class-secrets-api-legacy-reader.php
* - plugin/class-secrets-api-migrator.php
* - plugin/class-secrets-api-prototype-fallback-store.php
* - WP_CLI_Secret_Command::migrate_legacy(), one method
* - the fallback store's installation a few lines above
* - tests/includes/class-legacy-fixture-writer.php and the three
* test-secrets-api-{legacy-reader,migrator,prototype-fallback-store}.php
* files
* - docs/migrating-from-displace.md
*
* Nothing under src/ references any of it, no core-bound file knows it
* exists, and an architectural test enforces both. Deleting that list
* removes prototype compatibility entirely, in one commit, with no
* migration of its own and nothing left behind in the shipped API.
*/
require_once WP_SECRETS_API_PLUGIN_DIR . 'plugin/class-secrets-api-migrator.php';
require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-command.php';
require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-network-command.php';
WP_CLI::add_command( 'secret', 'WP_CLI_Secret_Command' );
WP_CLI::add_command( 'network-secret', 'WP_CLI_Secret_Network_Command' );
}
}
/**
* Admin notice shown when core supersedes this plugin.
*
* @return void
*/
function wp_secrets_api_notice_superseded() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
wp_admin_notice(
esc_html__( 'This version of WordPress provides the Secrets API natively. The Secrets API feature plugin is no longer doing anything and can be deactivated.', 'secrets-api' ),
array( 'type' => 'info' )
);
}
/**
* Admin notice shown when another plugin has already declared the Secrets API.
*
* @return void
*/
function wp_secrets_api_notice_conflict() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
wp_admin_notice(
esc_html__( 'The Secrets API feature plugin did not load: another plugin or mu-plugin has already declared wp_get_secret(). Two implementations of a credential store cannot safely coexist. Deactivate one of them.', 'secrets-api' ),
array( 'type' => 'error' )
);
}
/**
* Loads the secrets.php drop-in, if one exists, and records whether it left the
* store and keyring overrides in a usable state.
*
* Idempotent: guarded by a static flag rather than relying on require_once alone,
* since the caching in _wp_secrets_get_store()/_wp_secrets_get_key_manager() means
* this only ever needs to run once regardless of how many times it is called.
*
* A malformed drop-in must not turn into a white screen for the rest of the site.
* A syntax error, a thrown exception, or most runtime errors in the drop-in are
* caught here and turned into WP_Secrets_Broken_Store / WP_Secrets_Broken_Keyring
* for every operation instead. This is not airtight: PHP treats some class
* declaration errors -- notably a class that `implements` an interface but omits
* a required method -- as an uncatchable fatal even inside a try/catch around the
* require, confirmed empirically on both PHP 7.4 and 8.5 before writing this
* comment. That gap is unavoidable from userland and is recorded in
* docs/open-questions.md rather than silently assumed away.
*
* @return void
*/
function wp_secrets_api_load_dropin() {
static $loaded = false;
if ( $loaded ) {
return;
}
$loaded = true;
$dropin_path = WP_CONTENT_DIR . '/secrets.php';
if ( ! file_exists( $dropin_path ) ) {
return;
}
$GLOBALS['wp_secrets_dropin_loaded'] = true;
try {
require $dropin_path;
} catch ( \Throwable $e ) {
$GLOBALS['wp_secrets_dropin_broken'] = true;
return;
}
/*
* Not set at all is fine -- a drop-in overriding only the keyring, say,
* legitimately leaves the store and provider globals untouched. Set to the wrong
* thing is not: that is exactly the case the getters fail closed for.
*/
if ( isset( $GLOBALS['wp_secrets_store'] ) && ! ( $GLOBALS['wp_secrets_store'] instanceof WP_Secrets_Store ) ) {
$GLOBALS['wp_secrets_dropin_broken'] = true;
}
if ( isset( $GLOBALS['wp_secrets_keyring'] ) && ! ( $GLOBALS['wp_secrets_keyring'] instanceof WP_Secrets_Keyring ) ) {
$GLOBALS['wp_secrets_dropin_broken'] = true;
}
if ( isset( $GLOBALS['wp_secrets_provider'] ) && ! ( $GLOBALS['wp_secrets_provider'] instanceof WP_Secrets_Provider ) ) {
$GLOBALS['wp_secrets_dropin_broken'] = true;
}
}
/**
* Grants the site-scope management capability to administrators.
*
* @return void
*/
function wp_secrets_api_activate() {
$administrator = get_role( 'administrator' );
if ( $administrator ) {
$administrator->add_cap( WP_SECRETS_CAP_MANAGE );
}
}
/**
* Removes the capability this plugin granted, on uninstall -- not on deactivation.
* Deactivating and reactivating the plugin must not silently strip a capability an
* administrator may have started relying on for something else in the meantime.
*
* @return void
*/
function wp_secrets_api_uninstall() {
$administrator = get_role( 'administrator' );
if ( $administrator ) {
$administrator->remove_cap( WP_SECRETS_CAP_MANAGE );
}
}
/**
* Grants manage_network_secrets to super admins.
*
* @param array $allcaps All capabilities of the user.
* @param array $caps Required primitive capabilities for the requested capability.
* @param array $args Arguments passed to current_user_can().
* @param WP_User $user The user object.
*
* @return array
*/
function wp_secrets_api_grant_network_cap_to_super_admins( $allcaps, $caps, $args, $user ) {
if ( is_multisite() && in_array( WP_SECRETS_CAP_MANAGE_NETWORK, $caps, true ) && is_super_admin( $user->ID ) ) {
$allcaps[ WP_SECRETS_CAP_MANAGE_NETWORK ] = true;
}
return $allcaps;
}
wp_secrets_api_bootstrap();