Repository navigation
Expand file tree
/
Copy pathconvertCore.php
More file actions
executable file
·5660 lines (5513 loc) · 464 KB
/
Copy pathconvertCore.php
File metadata and controls
executable file
·5660 lines (5513 loc) · 464 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
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php if (php_sapi_name() !== 'cli') print('<!DOCTYPE HTML>'.PHP_EOL);
// / -----------------------------------------------------------------------------------
// / Copyright Information ...
// / HRConvert2, Copyright on 9/22/2026 by Justin Grimes, www.github.com/zelon88
// /
// / License Information ...
// / This project is protected by the GNU GPLv3 Open-Source license.
// / https://www.gnu.org/licenses/gpl-3.0.html
// /
// / Application Information ...
// / This application is designed to provide a web-interface for converting file formats
// / on a server for users of any web browser without authentication.
// /
// / File Information ...
// / v4.1.3.
// / HRConvert2 Convert Core.
// / This file contains the core logic of the application.
// /
// / Hardware Requirements ...
// / This application requires at least a Raspberry Pi Model B+ or greater.
// / This application will run on just about any x86 or x64 computer.
// /
// / Dependency Requirements ...
// / The authoritative list of dependencies is Resources/Contract/depends.php & nothing else.
// / Run php convertCore.php -v to see what this installation has.
// /
// / <3 Open-Source
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to produce random number variables for truly unique per-session identifiers.
// / This function uses the built-in PHP "random_int" function to generate the random number.
// / This function does not generate its own number. It produces a number from random_int().
// / Random number range is hardcoded to 100000000000000000 to 999999999999999999 for consistency.
// / Returns FALSE in $RandomNumberCheck if the system entropy source was unavailable.
// / The caller MUST check $RandomNumberCheck. A predictable identifier is worse than none.
function generateRandomNumber() {
// / Set variables.
global $EnableMemoryProtection;
$RandomNumber = FALSE;
$RandomNumberCheck = TRUE;
// / random_int() throws rather than returning a poor result when entropy is unavailable.
try { $RandomNumber = random_int(100000000000000000, 999999999999999999); }
catch (Throwable $error) { $RandomNumberCheck = FALSE; }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
purgeSensitiveMemory($EnableMemoryProtection, $error);
return array($RandomNumber, $RandomNumberCheck); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to confirm the config.php file carries every setting this core requires.
// / A config file from an older release will be missing settings the core now depends on.
// / An undefined setting evaluates as NULL, which silently reads as FALSE or zero.
// / A missing timeout would become zero & a missing whitelist would become empty, without warning.
// / Detecting the absence outright is the only way an administrator learns what actually happened.
// / $ConfigVersion is advisory & explains WHY settings are missing.
// / The variable list is authoritative & reports WHICH settings are missing.
// / A config file predating the version stamp will report no version rather than a wrong one.
// / Version stamps carry a leading v which must be stripped before any numeric comparison.
// / Casting 'v3' to an integer yields 0, which would silently reduce this to a minor & patch check.
function verifyConfigVersion($RequiredConfigVersion) {
// / Set variables.
global $ConfigVersion, $EnableMemoryProtection;
$ConfigIsValid = TRUE;
$MissingConfigVars = array();
$DetectedConfigVersion = '';
$requiredConfigVars = $configVersionParts = $requiredVersionParts = array();
$requiredConfigVar = $cleanConfigVersion = $cleanRequiredVersion = '';
$configVersionIsCurrent = FALSE;
// / Every setting the core reads from config.php.
// / A setting added to config.php must be added here or this check stops covering it.
$requiredConfigVars = array(
'ConfigVersion',
'URL', 'InstLoc', 'ServerRootDir', 'ConvertLoc', 'LogDir', 'HomeLoc', 'ProprietaryLoc',
'ApplicationName', 'ApplicationTitle', 'Verbose', 'MaxLogSize', 'DeleteThreshold',
'BackupLoc', 'UniqueDailyLogHash', 'AppendLogHashToLogFiles',
'VirusScan', 'AllowUserVirusScan', 'ScanCoreMemoryLimit', 'ScanCoreChunkSize',
'ScanCoreDebug', 'ScanCoreVerbose', 'EnableMemoryProtection',
'SupportedLanguages', 'DefaultLanguage', 'AllowUserSelectableLanguage',
'SupportedGuis', 'DefaultGui', 'AllowUserSelectableGui',
'SupportedColors', 'AllowUserSelectableColor', 'ButtonStyle',
'Font', 'SpinnerStyle', 'SpinnerColor', 'UserEbookInputArray',
'ShowGUI', 'ShowFinePrint', 'TOSURL', 'PPURL', 'LogoURL', 'AllowUserShare', 'AllowUserIdentify',
// / A setting nothing reads is not required, because every installation would carry it for no reason.
'SupportedConversionTypes', 'RetryCount',
'RARArchiveMethod', 'AllowBootableIsoImage',
'DeleteBuildEnvironment', 'DeleteDevelopmentDocumentation', 'UserEbookOutputArray',
'UserArchiveArray', 'UserDearchiveArray', 'UserDocumentArray', 'UserSpreadsheetArray',
'UserPresentationInputArray', 'UserPresentationOutputArray',
'UserXPSInputArray', 'UserXPSOutputArray', 'UserImageArray',
'UserMediaInputArray', 'UserMediaOutputArray', 'UserBootableIsoArray',
'UserVideoInputArray', 'UserVideoOutputArray', 'UserStreamArray',
'UserDrawingArray', 'UserSVGInputArray', 'UserSVGOutputArray', 'UserModelArray',
'UserModelInputArray', 'UserModelOutputArray', 'EnvironmentManagerMayRepair', 'AllowUserURLDownload', 'DefaultVirusScanner',
'URLDownloadMaximumBytes', 'URLDownloadsPerSession', 'ScanURLDownloads', 'PermittedPrivateRanges', 'GuiMaxWidth',
'EnvironmentManagerMayRewriteConfigs',
'UserDrawingInputArray', 'UserDrawingOutputArray', 'UserImageInputArray',
'UserImageOutputArray', 'UserSCADInputArray', 'UserSCADOutputArray',
'UserSCADArray', 'UserSubtitleInputArray', 'UserSubtitleOutputArray', 'UserPDFWorkArr',
'AllowStreamOverHTTP', 'StreamWatchTimeout', 'StreamConnectionTimeout', 'StreamInspectionLayers', 'StreamInspectionFilesPerLayer', 'DefaultStreamInspectionForfeitAction', 'MaxStreamInspectionFileSize', 'RequireSandboxOnDocker',
'AllowSCADIncludeResolution', 'SCADConversionTimeout', 'RequireSandbox', 'ThrowSandboxWarning',
'UsePyMeshLab', 'EnableAutoUpdates',
'AutoUpdateTargetVersion', 'UpdateSourceRepository', 'MaxUpdatePackageSize', 'UpdateConnectionTimeout',
'EnableResourceAwareness', 'RequireResourceAwareness', 'CoreManagerSubprocessPollInterval',
'ResourcePollInterval', 'WorkerReapInterval', 'WorkerStaleGracePeriod', 'TotalResourceBudget',
'ReserveResourcePercentage', 'MaxConcurrentWorkers', 'MaxExpectedRuntime', 'MaxRuntimeExtensions',
'DefaultConversionCost', 'DefaultExpectedRuntime', 'LowStorageWarning', 'StorageReserve', 'MaintainHTAccess');
// / Check that every required setting actually exists in the global scope.
// / config.php is required at global scope, so every setting it defines lands in $GLOBALS.
foreach ($requiredConfigVars as $requiredConfigVar) {
// / isset RATHER THAN array_key_exists, & the difference is the whole check.
// / A global declaration anywhere in this file creates the key in $GLOBALS with a NULL
// / value, before config.php has assigned anything. array_key_exists then answers yes
// / for a setting the configuration never mentioned, so the check passed for every
// / required setting that some function had merely declared global.
// / Two settings were absent from config.php & from the template for an entire release
// / & this reported a valid configuration.
// / isset is FALSE for NULL & TRUE for FALSE, 0 & the empty string, which is exactly
// / the distinction wanted. A setting legitimately set to FALSE still passes.
if (!isset($GLOBALS[$requiredConfigVar])) array_push($MissingConfigVars, $requiredConfigVar); }
if (!empty($MissingConfigVars)) $ConfigIsValid = FALSE;
// / Compare the config version stamp against the version this core was written for.
// / This is advisory. It explains why settings are missing but never passes a config that is incomplete.
// / isset for the reason given above. The global declaration at the top of this function
// / creates the key, so array_key_exists always answered yes & NONE was never reported.
$DetectedConfigVersion = isset($GLOBALS['ConfigVersion']) ? (string)$ConfigVersion : '';
if ($DetectedConfigVersion !== '') {
$cleanConfigVersion = ltrim($DetectedConfigVersion, 'vV');
$cleanRequiredVersion = ltrim($RequiredConfigVersion, 'vV');
$configVersionParts = explode('.', $cleanConfigVersion);
$requiredVersionParts = explode('.', $cleanRequiredVersion);
// / Compare numerically, never as strings.
// / A string comparison would rank version 3.10 below version 3.9.
if ((int)($configVersionParts[0] ?? 0) > (int)($requiredVersionParts[0] ?? 0)) $configVersionIsCurrent = TRUE;
elseif ((int)($configVersionParts[0] ?? 0) === (int)($requiredVersionParts[0] ?? 0)) {
if ((int)($configVersionParts[1] ?? 0) > (int)($requiredVersionParts[1] ?? 0)) $configVersionIsCurrent = TRUE;
elseif ((int)($configVersionParts[1] ?? 0) === (int)($requiredVersionParts[1] ?? 0) && (int)($configVersionParts[2] ?? 0) >= (int)($requiredVersionParts[2] ?? 0)) $configVersionIsCurrent = TRUE; } }
// / A config file with no version stamp at all predates this check entirely.
else $DetectedConfigVersion = 'NONE';
// / Sanity check that the config.php file is valid.
// / $ConfigIsValid should already be false by now, but just in case.
if (!$configVersionIsCurrent && !$ConfigIsValid) $ConfigIsValid = FALSE;
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
purgeSensitiveMemory($EnableMemoryProtection, $requiredConfigVars, $requiredConfigVar, $configVersionParts, $requiredVersionParts, $configVersionIsCurrent, $cleanConfigVersion, $cleanRequiredVersion, $RequiredConfigVersion);
return array($ConfigIsValid, $MissingConfigVars, $DetectedConfigVersion); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to obfuscate the secret file contents.
// / Decoy secrets give a grep & regex scan seven plausible keys instead of one.
// / This raises the cost of an attack rather than preventing one.
// / A root or www-data command line user can still read the real key from this function.
// / The time an adversary spends adjusting an exploit chain is the product being built here.
// / Adversaries reading this are invited to star the repository & bring it to their next conference.
function addSillyString($Secret, $secretVersion) {
// / Set variables.
global $EnableMemoryProtection;
$FinalOutput = '';
// / A local separator that cannot be empty. An empty one removes the whitespace after
// / the opening tag, which stops PHP recognizing the file as PHP at all.
$newLine = PHP_EOL;
// / Match the newline count on either side of every secret, decoy & real alike.
$lolol2 = $newLine.$newLine.$newLine.$newLine;
// / Seven is the optimal number for the decoy loop.
$numberOfDecoys = 7;
// / Select the iteration of the decoy loop that will carry the real secret.
$randomNumber = random_int(1, $numberOfDecoys);
// / Buffer strings into an array before a single compilation step.
$parts = array();
// / Strip anything that is not a version character before it is written into PHP source.
$cleanVersion = preg_replace('/[^0-9A-Za-z.\-]/', '', (string)$secretVersion);
// / Open the PHP region & pin the version, then open the comment fence.
// / The newline directly after the opening tag is what makes this a PHP file.
$parts[] = '<?php'.$newLine.'$SecretVersion = \''.$cleanVersion.'\';'.$newLine.'/* ';
// / Loop to create the desired number of decoy secrets.
while ($numberOfDecoys > 0) {
// / Every decoy is drawn from the same source as the real secret, so no table of
// / candidate decoys can be precomputed to eliminate them.
if ($numberOfDecoys !== $randomNumber) $parts[] = $lolol2.'$SecretKey = \''.bin2hex(random_bytes(32)).'\';'.$lolol2;
// / Close the comment fence for the real secret & reopen it immediately afterwards.
if ($numberOfDecoys === $randomNumber) $parts[] = ' */'.$lolol2.'$SecretKey = \''.$Secret.'\';'.$lolol2.'/* ';
$numberOfDecoys--; }
// / Close the trailing comment fence, deliberately without a closing PHP tag.
$parts[] = $newLine.' */'.$newLine;
// / Compile the final output string.
$FinalOutput = implode('', $parts);
// / Manually clean up sensitive memory. Every target is passed directly by reference.
// / $FinalOutput is not purged, because it is the return value.
if (!purgeSensitiveMemory($EnableMemoryProtection, $Secret, $numberOfDecoys, $randomNumber, $lolol2, $newLine, $parts, $cleanVersion, $secretVersion)) {
// / The failure is already logged under this function's name & is not logged again here.
// / An unpurged buffer does not make the payload wrong, so the payload is still returned.
$FinalOutput = $FinalOutput; }
return $FinalOutput; }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / generateInstallSecret must not live in the Engine, because verifyInstallation & resolveSecretFile reach it before the Engine loads.
// / closeClientConnection is reached through quickDie, so it lives in the kernel beside the Engine.
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to generate the per-install secret used to derive session identifiers.
// / 32 bytes gives 256 bits of entropy & returns as a 64 hexadecimal character string.
function generateInstallSecret() {
// / Set variables.
global $EnableMemoryProtection;
$InstallSecret = FALSE;
$InstallSecretCheck = TRUE;
// / random_bytes() throws rather than returning a poor result when entropy is unavailable.
// / Fail closed. A predictable secret is worse than no installation at all.
try { $InstallSecret = bin2hex(random_bytes(32)); }
catch (Throwable $error) { $InstallSecretCheck = FALSE; }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
purgeSensitiveMemory($EnableMemoryProtection, $error);
return array($InstallSecret, $InstallSecretCheck); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to read an install secret without running it.
// / Accepts the path of the secret file.
// / Returns a success boolean, the key & the version it declares, in that order.
// /
// / Root never requires a secret file.
// / An account that can write the file could add a line, & root would run it.
// / So root reads the file as text with PHP's own tokenizer, which parses without executing.
// / The file must have exactly the shape addSillyString writes, which is an opening tag, the version & one live key, with every decoy inside a comment.
// / Anything else in it, a call, a second assignment or a stray line, means it is not a file this application wrote, & it is refused rather than partly believed.
// / A refused file is replaced, as a secret of the wrong version is, & every session it derived is invalid.
// / It is read with readFileWithoutFollowing, because its folder may belong to the web server account.
// / A link, a FIFO or a file with two names there is refused unread & replaced.
function readSecretWithoutExecuting($secretFile) {
// / Set variables.
global $EnableMemoryProtection;
$SecretWasRead = $secretWasOpened = FALSE;
$ReadSecretKey = $ReadSecretVersion = '';
$secretContents = $readNotice = '';
$secretTokens = $liveTokens = array();
$secretToken = NULL;
list ($secretWasOpened, $secretContents, $readNotice) = readFileWithoutFollowing($secretFile, 65536);
if ($secretWasOpened && $secretContents !== '') {
$secretTokens = token_get_all($secretContents);
foreach ($secretTokens as $secretToken) {
if (is_array($secretToken) && in_array($secretToken[0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), TRUE)) continue;
$liveTokens[] = is_array($secretToken) ? array($secretToken[0], $secretToken[1]) : array(0, $secretToken); }
// / Nine live tokens & nothing else. An opening tag, the version's assignment & the key's.
if (count($liveTokens) === 9 && $liveTokens[0][0] === T_OPEN_TAG && $liveTokens[1] === array(T_VARIABLE, '$SecretVersion') && $liveTokens[2] === array(0, '=') && $liveTokens[3][0] === T_CONSTANT_ENCAPSED_STRING && $liveTokens[4] === array(0, ';') && $liveTokens[5] === array(T_VARIABLE, '$SecretKey') && $liveTokens[6] === array(0, '=') && $liveTokens[7][0] === T_CONSTANT_ENCAPSED_STRING && $liveTokens[8] === array(0, ';')) {
$ReadSecretVersion = substr((string)$liveTokens[3][1], 1, -1);
$ReadSecretKey = substr((string)$liveTokens[7][1], 1, -1);
if (preg_match('/^[0-9a-f]{64}$/', $ReadSecretKey) === 1 && preg_match('/^[0-9A-Za-z.\-]+$/', $ReadSecretVersion) === 1) $SecretWasRead = TRUE; } }
if (!$SecretWasRead) $ReadSecretKey = $ReadSecretVersion = '';
// / Manually clean up sensitive memory. Every target is passed directly by reference.
// / $ReadSecretKey is not purged, because it is the return value.
purgeSensitiveMemory($EnableMemoryProtection, $secretWasOpened, $secretContents, $readNotice, $secretTokens, $liveTokens, $secretToken, $secretFile);
return array($SecretWasRead, $ReadSecretKey, $ReadSecretVersion); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to create the data location as root when it does not exist yet, & to give it back
// / to root when another account holds it.
// / Accepts the data location. Returns TRUE when it exists afterwards.
// / The install secret is written into the data location at boot, so a missing location would stop every root command before the installer could create it.
// / The location is created only beneath a folder root can trust, never beneath a system folder, & its parents are 0755 so the web server account can reach it.
// / The location itself is root's & 0755, & each folder below it has its own role.
// / Root takes the data location back when another account holds it, during a repair & whenever Secrets is in it.
// / Root reads & writes the install secret only where no account but root & the web server account could replace it, & in Secrets only below a folder only root can change.
// / A data location another account owns or can write, as an old chown -R repair, a restored backup or a bind mount leaves one, would put every copy out of reach.
// / -fp would then stop before its data-root role could give it back, so it is given back here first, root:root 0755, as that role gives it.
// / Any other command leaves a data location with no Secrets in it alone, because an older release may still keep its sessions directly in it.
// / A data location an account other than root & the web server account could write sets $SecretPlacesWereOpen before it is taken back.
// / So only a copy of the secret root owns counts for the rest of the command.
function prepareDataLocationAsRoot($dataLocation) {
// / Set variables.
global $SecretPlacesWereOpen, $EnableMemoryProtection;
$LocationExists = FALSE;
$existingAncestor = $systemFolder = $trustNotice = $resolvedAncestor = $ownerNotice = $resolvedLocation = $resolvedParent = $parentNotice = $cliCommand = $cliTarget = $cliSecondTarget = $openNotice = $resolvedOpen = '';
$ancestorIsTrusted = $ownerWasChanged = $locationIsTrusted = $modeWasChanged = $parentIsTrusted = $repairRequested = $cliConfirmed = $cliVectorParsed = $locationWasClosed = FALSE;
$cliArguments = array();
$cliArgumentCount = 0;
$systemFolders = array('/bin', '/boot', '/dev', '/etc', '/lib', '/lib32', '/lib64', '/proc', '/root', '/run', '/sbin', '/sys', '/usr');
$dataLocation = rtrim((string)$dataLocation, DIRECTORY_SEPARATOR);
if ($dataLocation === '' or is_dir($dataLocation)) {
$LocationExists = ($dataLocation !== '');
// / A repair is what the code gate lets through, & is told apart from the arguments the same way.
list ($cliArguments, $cliArgumentCount, $cliCommand, $cliTarget, $cliSecondTarget, $cliConfirmed, $cliVectorParsed) = parseArgumentVector(isset($_SERVER['argv']) ? $_SERVER['argv'] : array());
$repairRequested = (in_array($cliCommand, array('-fp', '--fix-permissions', '--setup'), TRUE) or ($cliCommand === '--config' && $cliTarget === '--repair'));
if ($LocationExists && ($repairRequested or is_dir($dataLocation.DIRECTORY_SEPARATOR.'Secrets'))) {
list ($locationIsTrusted, $resolvedLocation, $trustNotice) = pathIsTrustedByRoot($dataLocation);
list ($parentIsTrusted, $resolvedParent, $parentNotice) = pathIsTrustedByRoot(dirname($dataLocation));
list ($locationWasClosed, $resolvedOpen, $openNotice) = secretFolderIsTrusted($dataLocation);
if (!$locationIsTrusted && !$locationWasClosed) $SecretPlacesWereOpen = TRUE; }
// / Only the location itself is taken, by the path configured & never through a link, & only
// / beneath a folder only root can change, so nothing can swap it between the owner change &
// / the mode set by name next. Every command run as root stops with ERROR 31024 before this when
// / a folder above it is not one, & this checks again rather than rely on that.
if ($LocationExists && $resolvedLocation === $dataLocation && !$locationIsTrusted) {
if ($parentIsTrusted && $resolvedParent === dirname($dataLocation)) list ($ownerWasChanged, $ownerNotice) = changeOwnerWithoutFollowing($dataLocation, 'root', 'root');
else $ownerNotice = $parentNotice;
if ($ownerWasChanged) $modeWasChanged = @chmod($dataLocation, 0755);
if ($ownerWasChanged && $modeWasChanged) warningEntry('The data location at '.$dataLocation.' could be changed by another account, so root took it back, root:root 0755, as -fp does. '.$trustNotice);
else warningEntry('The data location at '.$dataLocation.' could be changed by another account & could not be given back to root, so the install secret below it is out of root\'s reach. '.$trustNotice.' '.$ownerNotice); } }
else {
$existingAncestor = dirname($dataLocation);
while ($existingAncestor !== DIRECTORY_SEPARATOR && !file_exists($existingAncestor)) $existingAncestor = dirname($existingAncestor);
list ($ancestorIsTrusted, $resolvedAncestor, $trustNotice) = pathIsTrustedByRoot($existingAncestor);
// / The folders are made by the path configured, so that path must be the one judged. A link
// / on the way could be pointed somewhere else between the check & the mkdir, by whoever owns
// / the folder the link sits in, & that folder is not on the path that was judged.
if ($ancestorIsTrusted && $resolvedAncestor !== $existingAncestor) {
$ancestorIsTrusted = FALSE;
$trustNotice = $existingAncestor.' leads to '.$resolvedAncestor.'.'; }
foreach ($systemFolders as $systemFolder) {
if ($dataLocation === $systemFolder or strpos($dataLocation.DIRECTORY_SEPARATOR, $systemFolder.DIRECTORY_SEPARATOR) === 0) $ancestorIsTrusted = FALSE; }
if (!$ancestorIsTrusted) warningEntry('The data location '.$dataLocation.' does not exist & was not created, because it is beneath a system folder, a folder another account can change or a link. '.$trustNotice);
else {
@mkdir(dirname($dataLocation), 0755, TRUE);
// / The data location is owned by root & 0755, so every account may traverse it to a folder of its own below, & none but root may change what is in it.
// / The install secret is kept below it, in Secrets, & a root owned data location is what keeps another account from replacing that folder.
// / The session, cache & log folders below are the web server account's, & --setup or -fp creates them with their roles, as it does Secrets.
if (@mkdir($dataLocation, 0755) && is_dir($dataLocation)) {
// / The location was created a moment ago in a folder only root can change, so the
// / name cannot have been swapped, & changeOwnerWithoutFollowing confirms that first.
list ($ownerWasChanged, $ownerNotice) = changeOwnerWithoutFollowing($dataLocation, 'root', 'root');
if (!$ownerWasChanged) warningEntry($ownerNotice);
$LocationExists = TRUE;
warningEntry('The data location did not exist & was created at '.$dataLocation.'.'); } } }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
purgeSensitiveMemory($EnableMemoryProtection, $existingAncestor, $systemFolder, $trustNotice, $resolvedAncestor, $resolvedLocation, $resolvedParent, $parentNotice, $cliCommand, $cliTarget, $cliSecondTarget, $openNotice, $resolvedOpen, $ancestorIsTrusted, $ownerWasChanged, $locationIsTrusted, $modeWasChanged, $parentIsTrusted, $repairRequested, $cliConfirmed, $cliVectorParsed, $locationWasClosed, $cliArguments, $cliArgumentCount, $ownerNotice, $systemFolders, $dataLocation);
return $LocationExists; }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to find the folders above the given locations that root cannot trust.
// / Accepts the locations, each the topmost folder -fp corrects on its path.
// / Returns whether every location could be judged & the folders that fail, in that order.
// / Each folder is keyed by its path & carries its owner, group & mode, & what it needs.
// /
// / -fp corrects the locations it manages & never a folder above them.
// / Root will not read, write or run anything beneath a folder another account can change, because that account could rename what is inside it aside & put its own in its place.
// / A folder above a managed location is the administrator's, so it is named rather than changed.
// / An upgraded host is where this is seen, because older install guides gave /var/www or /DATA to the web server account.
// / Folders are judged as pathIsTrustedByRoot judges them, on the resolved path, & every one that fails is named rather than the first.
// / Only a folder that exists is judged, because a missing one is created beneath a trusted folder when it is needed.
// / Only a folder that root or the web server account owns is to be given to root.
// / A folder another account owns, such as a home folder, & a folder every account shares by design, such as /tmp, are left to the administrator.
// / Changing either would decide something for somebody else.
function foldersRootCannotTrust($managedLocations) {
// / Set variables.
global $ApacheUser, $EnableMemoryProtection;
$FoldersWereJudged = TRUE;
$UntrustedFolders = array();
$managedLocation = $existingFolder = $resolvedFolder = $walkedPath = $pathComponent = $ownerName = $groupName = $folderAdvice = '';
$pathComponents = $componentStatus = $ownerRecord = $groupRecord = array();
$folderIsPlain = FALSE;
foreach ((array)$managedLocations as $managedLocation) {
$managedLocation = rtrim(trim((string)$managedLocation), DIRECTORY_SEPARATOR);
if ($managedLocation === '' or strpos($managedLocation, DIRECTORY_SEPARATOR) !== 0) continue;
$existingFolder = dirname($managedLocation);
while ($existingFolder !== DIRECTORY_SEPARATOR && !file_exists($existingFolder)) $existingFolder = dirname($existingFolder);
$resolvedFolder = (string)@realpath($existingFolder);
if ($resolvedFolder === '') $FoldersWereJudged = FALSE;
else {
$walkedPath = '';
$pathComponents = array_merge(array(''), array_values(array_filter(explode(DIRECTORY_SEPARATOR, $resolvedFolder), 'strlen')));
foreach ($pathComponents as $pathComponent) {
$walkedPath = rtrim($walkedPath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$pathComponent;
if (isset($UntrustedFolders[$walkedPath])) continue;
clearstatcache(TRUE, $walkedPath);
$componentStatus = @lstat($walkedPath);
if (!is_array($componentStatus)) {
$FoldersWereJudged = FALSE;
break; }
$folderIsPlain = (((int)$componentStatus['mode'] & 0170000) === 0040000);
if ($folderIsPlain && (int)$componentStatus['uid'] === 0 && ((int)$componentStatus['mode'] & 0022) === 0) continue;
$ownerRecord = function_exists('posix_getpwuid') ? @posix_getpwuid((int)$componentStatus['uid']) : array();
$groupRecord = function_exists('posix_getgrgid') ? @posix_getgrgid((int)$componentStatus['gid']) : array();
$ownerName = (is_array($ownerRecord) && isset($ownerRecord['name'])) ? (string)$ownerRecord['name'] : (string)$componentStatus['uid'];
$groupName = (is_array($groupRecord) && isset($groupRecord['name'])) ? (string)$groupRecord['name'] : (string)$componentStatus['gid'];
$folderAdvice = 'Give it to root:root, mode 755.';
if (!$folderIsPlain) $folderAdvice = 'Is not a folder. Point the location somewhere else.';
else if (((int)$componentStatus['mode'] & 01000) !== 0) $folderAdvice = 'Is shared by every account by design. Move the location out of it.';
else if ((int)$componentStatus['uid'] !== 0 && $ownerName !== (string)$ApacheUser) $folderAdvice = 'Belongs to '.$ownerName.'. Move the location out of it rather than taking it.';
$UntrustedFolders[$walkedPath] = array('Owner' => $ownerName, 'Group' => $groupName, 'Mode' => sprintf('%o', (int)$componentStatus['mode'] & 07777), 'Advice' => $folderAdvice); } } }
ksort($UntrustedFolders);
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $FoldersWereJudged & $UntrustedFolders are not purged, because they are the return values.
purgeSensitiveMemory($EnableMemoryProtection, $managedLocation, $existingFolder, $resolvedFolder, $walkedPath, $pathComponent, $ownerName, $groupName, $folderAdvice, $pathComponents, $componentStatus, $ownerRecord, $groupRecord, $folderIsPlain, $managedLocations);
return array($FoldersWereJudged, $UntrustedFolders); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to name the folders root cannot trust & what each needs.
// / Accepts the folders foldersRootCannotTrust found & whether root must stop without them.
// / Returns the number of folders named.
// / Only each folder itself changes, & nothing inside it. Mode 755 keeps every account able to
// / reach the folders below, which the web server account needs. The commands are in the
// / description of error 31024 rather than printed, as every instruction to change an owner is.
// / Root stops when the install secret is beneath one of them, & otherwise goes on & corrects
// / nothing beneath them.
function reportFoldersRootCannotTrust($untrustedFolders, $rootMustStop) {
// / Set variables.
global $EnableMemoryProtection;
$FoldersNamed = 0;
$folderPath = $folderLines = $reportText = '';
$folderDetail = array();
foreach ((array)$untrustedFolders as $folderPath => $folderDetail) {
$FoldersNamed++;
$folderLines = $folderLines.' '.str_pad((string)$folderPath, 24).' '.str_pad($folderDetail['Owner'].':'.$folderDetail['Group'].' '.$folderDetail['Mode'], 24).' '.$folderDetail['Advice'].PHP_EOL; }
$reportText = PHP_EOL.'Root will not work beneath a folder another account can change, & these can be changed.'.PHP_EOL.$folderLines
.'-fp corrects the locations it manages & never a folder above them. Change each folder itself'.PHP_EOL
.'& nothing inside it, then run the command again. Error 31024 in'.PHP_EOL
.'Documentation/ERROR_DESCRIPTIONS.txt shows how. Releases before v4.0.1 gave /var/www & /DATA'.PHP_EOL
.'to the web server account, & nothing needs that now.'.PHP_EOL
.($rootMustStop ? 'The install secret is beneath them, so root stopped.' : 'Root went on & corrected nothing beneath them.').PHP_EOL.PHP_EOL;
print($reportText);
if ($rootMustStop) errorEntry('Root will not work beneath '.implode(', ', array_keys((array)$untrustedFolders)).', because another account can change them. Give each back to root & run the command again.', 31024, TRUE);
else warningEntry('Root corrects nothing beneath '.implode(', ', array_keys((array)$untrustedFolders)).', because another account can change them.');
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $FoldersNamed is not purged, because it is the return value.
purgeSensitiveMemory($EnableMemoryProtection, $folderPath, $folderLines, $reportText, $folderDetail, $untrustedFolders, $rootMustStop);
return $FoldersNamed; }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to name the copy of the install secret at the top of the data location.
// / Accepts the install secret's file in Secrets.
// / Returns the same name at the top of the data location.
// / A release older than v4.0.4 keeps the secret there alone, & the same key is kept there & in Secrets.
// / Until Secrets has its role the copy there is the one the web server account reads first.
// / This is the one place the name is built.
function olderInstallSecretFile($secretFile) {
// / Set variables.
global $DataRootLoc, $EnableMemoryProtection;
$OlderSecretFile = rtrim((string)$DataRootLoc, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.basename((string)$secretFile);
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $OlderSecretFile is not purged, because it is the return value.
purgeSensitiveMemory($EnableMemoryProtection, $secretFile);
return $OlderSecretFile; }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to find the number of the web server account & of the group named after it.
// / Accepts nothing. Returns the account's number & the group's number, in that order, each -1
// / when it cannot be found.
// / The group is the one root gives the install secret & Secrets. Some systems do not have the
// / posix functions, so id & getent are asked instead, as elsewhere in this application. The
// / answer is kept for the rest of the process, because neither changes while it runs.
function webServerAccountNumbers() {
// / Set variables.
global $ApacheUser, $EnableMemoryProtection;
// / The answer is kept for the life of the process. It holds two numbers & nothing secret.
static $knownNumbers = NULL;
$WebAccountNumber = $WebGroupNumber = -1;
$accountRecord = $groupRecord = $commandOutput = $groupFields = array();
$commandStatus = 1;
$commandAnswer = '';
if (is_array($knownNumbers)) list ($WebAccountNumber, $WebGroupNumber) = $knownNumbers;
else if (trim((string)$ApacheUser) !== '') {
if (function_exists('posix_getpwnam')) $accountRecord = @posix_getpwnam((string)$ApacheUser);
else exec('id -u '.escapeshellarg((string)$ApacheUser).' 2>/dev/null', $commandOutput, $commandStatus);
$commandAnswer = trim(implode('', $commandOutput));
if (is_array($accountRecord) && isset($accountRecord['uid'])) $WebAccountNumber = (int)$accountRecord['uid'];
else if ($commandStatus === 0 && preg_match('/^[0-9]+$/', $commandAnswer) === 1) $WebAccountNumber = (int)$commandAnswer;
$commandOutput = array();
$commandStatus = 1;
if (function_exists('posix_getgrnam')) $groupRecord = @posix_getgrnam((string)$ApacheUser);
else exec('getent group '.escapeshellarg((string)$ApacheUser).' 2>/dev/null', $commandOutput, $commandStatus);
$groupFields = explode(':', trim(implode('', $commandOutput)));
if (is_array($groupRecord) && isset($groupRecord['gid'])) $WebGroupNumber = (int)$groupRecord['gid'];
else if ($commandStatus === 0 && isset($groupFields[2]) && preg_match('/^[0-9]+$/', $groupFields[2]) === 1) $WebGroupNumber = (int)$groupFields[2];
$knownNumbers = array($WebAccountNumber, $WebGroupNumber); }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $WebAccountNumber & $WebGroupNumber are not purged, because they are the return values.
purgeSensitiveMemory($EnableMemoryProtection, $accountRecord, $groupRecord, $commandOutput, $groupFields, $commandStatus, $commandAnswer);
return array($WebAccountNumber, $WebGroupNumber); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to decide whether only root or the web server account could change a folder of
// / the install secret's, or any folder above it.
// / Accepts the folder. Returns whether that holds, the folder it leads to & the reason it does
// / not, in that order.
// / Every folder from the filesystem root down must belong to root or the web server account, &
// / be writable by no account but its owner. An account that can write any of them could put a
// / file of its own at a name below it between any look & any read. A folder its group may write
// / is refused whoever the group is, because under an access control list the group bits are
// / only the list's mask, & cannot say which other accounts the list lets write. A folder reached
// / through a link is refused too, as folderIsHeldForRoot refuses one for root, because whoever
// / can change the link could point it somewhere else between the look & the read. Root writes
// / no copy of the secret where this does not hold, because the web server account would refuse
// / it there.
function secretFolderIsTrusted($secretFolder) {
// / Set variables.
global $EnableMemoryProtection;
$FolderIsTrusted = FALSE;
$ResolvedFolder = $TrustNotice = '';
$walkedPath = $pathComponent = '';
$pathComponents = $componentStatus = $trustedOwners = array();
$webAccountNumber = -1;
list ($webAccountNumber) = webServerAccountNumbers();
$trustedOwners = array(0);
if ($webAccountNumber >= 0) $trustedOwners[] = $webAccountNumber;
if (trim((string)$secretFolder) !== '') $ResolvedFolder = (string)@realpath((string)$secretFolder);
if ($ResolvedFolder === '') $TrustNotice = (string)$secretFolder.' does not exist.';
else if ($ResolvedFolder !== rtrim((string)$secretFolder, DIRECTORY_SEPARATOR)) $TrustNotice = (string)$secretFolder.' leads to '.$ResolvedFolder.'.';
else {
// / The empty first component is the filesystem root, which is judged like any other.
$pathComponents = array_merge(array(''), array_values(array_filter(explode(DIRECTORY_SEPARATOR, $ResolvedFolder), 'strlen')));
foreach ($pathComponents as $pathComponent) {
$walkedPath = rtrim($walkedPath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$pathComponent;
clearstatcache(TRUE, $walkedPath);
$componentStatus = @lstat($walkedPath);
if (!is_array($componentStatus)) $TrustNotice = $walkedPath.' could not be examined.';
else if (((int)$componentStatus['mode'] & 0170000) !== 0040000) $TrustNotice = $walkedPath.' is not a folder.';
else if (!in_array((int)$componentStatus['uid'], $trustedOwners, TRUE)) $TrustNotice = $walkedPath.' belongs to an account other than root & the web server account.';
else if (((int)$componentStatus['mode'] & 0002) !== 0) $TrustNotice = $walkedPath.' can be written by every account.';
else if (((int)$componentStatus['mode'] & 0020) !== 0) $TrustNotice = $walkedPath.' can be written by its group.';
if ($TrustNotice !== '') break; }
if ($TrustNotice === '') $FolderIsTrusted = TRUE; }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $FolderIsTrusted, $ResolvedFolder & $TrustNotice are not purged, because they are the return values.
purgeSensitiveMemory($EnableMemoryProtection, $walkedPath, $pathComponent, $pathComponents, $componentStatus, $trustedOwners, $webAccountNumber, $secretFolder);
return array($FolderIsTrusted, $ResolvedFolder, $TrustNotice); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to decide whether a copy of the install secret could only have been written by
// / root or the web server account.
// / Accepts the copy. Returns whether it may be used & the reason it may not, in that order.
// / A key another account could have written is a key that account may know.
// / The copy must be a plain file with one name in a folder secretFolderIsTrusted holds, belong to root or the web server account, & be writable by no account but its owner.
// / Then nothing but root or the web server account can change what is read, & both know the key anyway, so a copy is judged before it is read.
// / Root & the web server account judge every copy this way, so neither uses one the other would refuse.
// / The kernel reads no file with a second name, which fs.protected_hardlinks keeps any other account from making.
// / Once a command run as root has found a place the secret is kept open to another account, only a copy root owns counts for the rest of it.
// / $SecretPlacesWereOpen says so.
// / Root takes such a place back before it reads, but the account that could write there could have put a file the web server account owns at the name.
// / Every such file is a key that account chose.
// / The web server account never sets it.
function installSecretCopyIsTrusted($secretFile) {
// / Set variables.
global $SecretPlacesWereOpen, $EnableMemoryProtection;
$CopyIsTrusted = FALSE;
$TrustNotice = '';
$folderIsTrusted = FALSE;
$resolvedFolder = $judgedCopy = '';
$copyStatus = $trustedOwners = array();
$webAccountNumber = -1;
list ($folderIsTrusted, $resolvedFolder, $TrustNotice) = secretFolderIsTrusted(dirname((string)$secretFile));
if ($folderIsTrusted) {
list ($webAccountNumber) = webServerAccountNumbers();
$trustedOwners = array(0);
if ($webAccountNumber >= 0 && (!isset($SecretPlacesWereOpen) or $SecretPlacesWereOpen !== TRUE)) $trustedOwners[] = $webAccountNumber;
$judgedCopy = rtrim($resolvedFolder, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.basename((string)$secretFile);
clearstatcache(TRUE, $judgedCopy);
$copyStatus = @lstat($judgedCopy);
if (!is_array($copyStatus)) $TrustNotice = $judgedCopy.' could not be examined.';
else if (((int)$copyStatus['mode'] & 0170000) !== 0100000) $TrustNotice = $judgedCopy.' is not a plain file.';
else if ((int)$copyStatus['nlink'] !== 1) $TrustNotice = $judgedCopy.' has more than one name.';
else if (!in_array((int)$copyStatus['uid'], $trustedOwners, TRUE) && $webAccountNumber >= 0 && (int)$copyStatus['uid'] === $webAccountNumber) $TrustNotice = $judgedCopy.' belongs to the web server account, & only a copy root owns counts once a place the secret is kept was found open to another account.';
else if (!in_array((int)$copyStatus['uid'], $trustedOwners, TRUE) && $webAccountNumber < 0) $TrustNotice = $judgedCopy.' does not belong to root, & the web server account could not be looked up.';
else if (!in_array((int)$copyStatus['uid'], $trustedOwners, TRUE)) $TrustNotice = $judgedCopy.' belongs to an account other than root & the web server account.';
else if (((int)$copyStatus['mode'] & 0002) !== 0) $TrustNotice = $judgedCopy.' can be written by every account.';
else if (((int)$copyStatus['mode'] & 0020) !== 0) $TrustNotice = $judgedCopy.' can be written by its group.';
else $CopyIsTrusted = TRUE; }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $CopyIsTrusted & $TrustNotice are not purged, because they are the return values.
purgeSensitiveMemory($EnableMemoryProtection, $folderIsTrusted, $resolvedFolder, $judgedCopy, $copyStatus, $trustedOwners, $webAccountNumber, $secretFile);
return array($CopyIsTrusted, $TrustNotice); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to decide whether the copy of the install secret at the top of the data location
// / was written after the copy in Secrets.
// / Accepts the install secret's file in Secrets. Returns TRUE when both copies are plain files,
// / the one at the top is root's & no other account can write it, & it was written later.
// / A release older than v4.0.4 knows only the copy at the top.
// / Going back to one & rotating the key there, or letting it make a new one, leaves the copy in Secrets carrying the key that was replaced.
// / The copy written last carries the key in use, so when this holds root & the web server account both read the copy at the top first, & root then writes its key into Secrets.
// / Only a copy root owns is put first this way, so a copy another account owns never outranks one root wrote.
// / This release writes one key into both copies, so the order changes nothing until they disagree.
// / The times are whole seconds of the host's clock, so two copies written in the same second, or across a clock set back, keep the usual order.
function topSecretCopyIsNewer($secretFile) {
// / Set variables.
global $EnableMemoryProtection;
$TopIsNewer = FALSE;
$olderSecretFile = '';
$topStatus = $secretsStatus = array();
$olderSecretFile = olderInstallSecretFile($secretFile);
if ($olderSecretFile !== (string)$secretFile) {
clearstatcache(TRUE, $olderSecretFile);
clearstatcache(TRUE, (string)$secretFile);
$topStatus = @lstat($olderSecretFile);
$secretsStatus = @lstat((string)$secretFile);
$TopIsNewer = (is_array($topStatus) && is_array($secretsStatus) && ((int)$topStatus['mode'] & 0170000) === 0100000 && ((int)$secretsStatus['mode'] & 0170000) === 0100000 && (int)$topStatus['uid'] === 0 && ((int)$topStatus['mode'] & 0022) === 0 && (int)$topStatus['mtime'] > (int)$secretsStatus['mtime']); }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $TopIsNewer is not purged, because it is the return value.
purgeSensitiveMemory($EnableMemoryProtection, $olderSecretFile, $topStatus, $secretsStatus, $secretFile);
return $TopIsNewer; }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to examine the folder the install secret is kept in, without following anything.
// / Accepts the folder. Returns its state, its mode & its owner & group, in that order.
// / The state is ok, missing, not a folder, wrong or unjudged. The mode & the owner are empty
// / unless a folder is there. A folder is unjudged when the group named after the web server
// / account cannot be looked up, because then nothing can say whether its group is that one.
// / The group is compared by number, so a host without the posix functions judges it the same.
// / Setup Core gives the folder the secret role, which is root's, the web server account's group
// / & 0750, so that account may read the secret & change nothing, & no other account may list
// / the folder. The copy in a folder in any other state is never the one read first, because the
// / web server account might not be able to read it there, & every request would stop.
function secretFolderStatus($secretFolder) {
// / Set variables.
global $EnableMemoryProtection;
$FolderState = 'missing';
$FolderMode = $FolderOwnership = '';
$folderStatus = $ownerRecord = $groupRecord = array();
$ownerName = $groupName = '';
$webAccountNumber = $webGroupNumber = -1;
clearstatcache(TRUE, (string)$secretFolder);
if ((string)$secretFolder !== '') $folderStatus = @lstat((string)$secretFolder);
if (is_array($folderStatus) && ((int)$folderStatus['mode'] & 0170000) !== 0040000) $FolderState = 'not a folder';
else if (is_array($folderStatus)) {
if (function_exists('posix_getpwuid')) $ownerRecord = @posix_getpwuid((int)$folderStatus['uid']);
if (function_exists('posix_getgrgid')) $groupRecord = @posix_getgrgid((int)$folderStatus['gid']);
$ownerName = (is_array($ownerRecord) && isset($ownerRecord['name'])) ? (string)$ownerRecord['name'] : (string)$folderStatus['uid'];
$groupName = (is_array($groupRecord) && isset($groupRecord['name'])) ? (string)$groupRecord['name'] : (string)$folderStatus['gid'];
$FolderMode = sprintf('%04o', (int)$folderStatus['mode'] & 07777);
$FolderOwnership = $ownerName.':'.$groupName;
list ($webAccountNumber, $webGroupNumber) = webServerAccountNumbers();
if ($webGroupNumber < 0) $FolderState = 'unjudged';
else $FolderState = ((int)$folderStatus['uid'] === 0 && $FolderMode === '0750' && (int)$folderStatus['gid'] === $webGroupNumber) ? 'ok' : 'wrong'; }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $FolderState, $FolderMode & $FolderOwnership are not purged, because they are the return values.
purgeSensitiveMemory($EnableMemoryProtection, $folderStatus, $ownerRecord, $groupRecord, $ownerName, $groupName, $webAccountNumber, $webGroupNumber, $secretFolder);
return array($FolderState, $FolderMode, $FolderOwnership); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to write the install wide secret as root & read it back.
// / Accepts the file, the key & the version the file declares.
// / Returns whether the file holds the key, whether the web server account's group can read it,
// / & the reason the file does not hold the key, in that order.
// / The new copy is finished before it takes the name.
// / It is made beside the name as a staged copy, under a name of its own for every write, through the kernel.
// / So nothing planted at either name is followed, & nothing left at one can stop the next.
// / It is made empty, given the web server account's group & its mode, & judged, & only then is the key written into it.
// / The key is written through a handle the kernel compares with what was judged.
// / Then it is renamed over the name, so the name always holds a whole copy that account can read whenever its group could be given.
// / The owner stays root, & the file is read back exactly as root reads it next time.
// / A default access control list decides a new file's mode in place of the umask, & could leave the secret readable by every account, so the mode is set again by name.
// / That is done only in a folder root owns that no other account can write, because only there can nothing swap the staged name between the write & the change.
// / Every folder -fp gives a copy is one, & anywhere else a copy wider than 0640 is given no key & never takes the name.
// / A list that names another account can still let that account read a copy, which no mode shows.
// / A group that could not be changed is warned about & is not a failure to write, because root still reads the key.
// / The exception is a name holding a copy the web server account reads now, which is never replaced by one it could not read.
// / The caller decides the rest.
function writeInstallSecretAsRoot($secretFile, $secretKey, $secretVersion) {
// / Set variables.
global $ApacheUser, $EnableMemoryProtection;
$SecretWasWritten = $GroupWasChanged = FALSE;
$WriteFailure = '';
$fileWasWritten = $readBackSucceeded = $folderIsRootsAlone = $modeWasSet = $stagedWasRemoved = $stagedWasCreated = $placeIsReadable = $handleIsJudged = FALSE;
$secretFileContent = $writeNotice = $groupNotice = $readBackKey = $readBackVersion = $stagedFile = $removalNotice = $randomPart = $openNotice = '';
$folderStatus = $stagedStatus = $handleStatus = array();
$randomError = NULL;
$stagedHandle = FALSE;
$bytesWritten = 0;
$secretFileContent = addSillyString($secretKey, $secretVersion);
try { $randomPart = bin2hex(random_bytes(6)); }
catch (Throwable $randomError) { $randomPart = str_replace('.', '', uniqid('', TRUE)); }
$stagedFile = dirname((string)$secretFile).DIRECTORY_SEPARATOR.'.'.basename((string)$secretFile).'.'.$randomPart.'.staged';
list ($fileWasWritten, $writeNotice) = writeFileWithoutFollowing($stagedFile, '', 0640);
if (!$fileWasWritten) $WriteFailure = 'The install secret could not be written to '.$secretFile.'! '.$writeNotice;
else {
// / The owner stays root. Only the group becomes the web server's, so it can read the key.
list ($GroupWasChanged, $groupNotice) = changeOwnerWithoutFollowing($stagedFile, '', (string)$ApacheUser);
if (!$GroupWasChanged) warningEntry($groupNotice.' The web server account cannot read the install secret until it is.');
// / A copy that account reads is never replaced by one it cannot.
$placeIsReadable = (!$GroupWasChanged && webServerCanReadSecretCopy($secretFile));
clearstatcache(TRUE, dirname((string)$secretFile));
$folderStatus = @lstat(dirname((string)$secretFile));
$folderIsRootsAlone = (is_array($folderStatus) && ((int)$folderStatus['mode'] & 0170000) === 0040000 && (int)$folderStatus['uid'] === 0 && ((int)$folderStatus['mode'] & 0022) === 0);
if ($folderIsRootsAlone) $modeWasSet = @chmod($stagedFile, 0640);
clearstatcache(TRUE, $stagedFile);
$stagedStatus = @lstat($stagedFile);
if ($placeIsReadable) $WriteFailure = 'The install secret could not be written to '.$secretFile.'! Its group could not be given, & the copy there now is one the web server account reads.';
else if ($folderIsRootsAlone && !$modeWasSet) $WriteFailure = 'The install secret could not be written to '.$secretFile.'! Its mode could not be set.';
else if (!is_array($stagedStatus) or ((int)$stagedStatus['mode'] & 0037) !== 0) $WriteFailure = 'The install secret could not be written to '.$secretFile.'! The new copy was made wider than 0640, as a default access control list makes it.';
else {
// / Only now is the key written into it, & only into the file judged above, by device, inode,
// / owner, size & mode, so a file swapped in at the staged name since is never filled, even
// / one given the same inode number again.
list ($stagedHandle, $stagedWasCreated, $openNotice) = openFileWithoutFollowing($stagedFile, 0640);
if (is_resource($stagedHandle)) $handleStatus = @fstat($stagedHandle);
$handleIsJudged = (is_resource($stagedHandle) && !$stagedWasCreated && is_array($handleStatus) && $handleStatus['dev'] === $stagedStatus['dev'] && $handleStatus['ino'] === $stagedStatus['ino'] && (int)$handleStatus['uid'] === 0 && (int)$handleStatus['size'] === 0 && ((int)$handleStatus['mode'] & 0037) === 0);
if ($handleIsJudged) {
$bytesWritten = (int)@fwrite($stagedHandle, $secretFileContent);
@fflush($stagedHandle); }
if (is_resource($stagedHandle)) @fclose($stagedHandle);
if (!$handleIsJudged or $bytesWritten !== strlen($secretFileContent)) $WriteFailure = 'The install secret could not be written to '.$secretFile.'! The new copy could not be filled. '.$openNotice;
else if (!@rename($stagedFile, (string)$secretFile)) $WriteFailure = 'The install secret could not be written to '.$secretFile.'! The new copy could not be moved into place.';
else {
// / Read the new file back exactly as root will read it next time.
list ($readBackSucceeded, $readBackKey, $readBackVersion) = readSecretWithoutExecuting($secretFile);
if (!$readBackSucceeded or $readBackKey !== $secretKey) $WriteFailure = 'The install secret written to '.$secretFile.' did not read back correctly!';
else $SecretWasWritten = TRUE; } }
// / A staged copy that did not take the name is removed. Removing a name never follows it.
if (file_exists($stagedFile) or is_link($stagedFile)) list ($stagedWasRemoved, $removalNotice) = removeFileWithoutFollowing($stagedFile);
if (file_exists($stagedFile) or is_link($stagedFile)) warningEntry('The staged copy of the install secret at '.$stagedFile.' could not be removed. '.$removalNotice); }
// / Manually clean up sensitive memory. Every target is passed directly by reference.
// / $SecretWasWritten, $GroupWasChanged & $WriteFailure are not purged, because they are the return values.
purgeSensitiveMemory($EnableMemoryProtection, $fileWasWritten, $readBackSucceeded, $folderIsRootsAlone, $modeWasSet, $stagedWasRemoved, $stagedWasCreated, $placeIsReadable, $handleIsJudged, $handleStatus, $secretFileContent, $writeNotice, $groupNotice, $readBackKey, $readBackVersion, $stagedFile, $removalNotice, $randomPart, $openNotice, $folderStatus, $stagedStatus, $randomError, $stagedHandle, $bytesWritten, $secretFile, $secretKey, $secretVersion);
return array($SecretWasWritten, $GroupWasChanged, $WriteFailure); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to decide whether the web server account would read a copy of the install secret.
// / Accepts the copy. Returns TRUE when that account would judge it as it judges every copy, & could
// / open it.
// / Root cannot open a file as another account, so this is judged from the owner, the group & the
// / mode of the copy & of the folder it is in, the way the kernel would judge that account's open.
// / The account judges a copy without the closer rule a command run as root takes on once it finds
// / a place the secret is kept open, so a copy it owns counts here. When the account or its group
// / cannot be looked up, nothing can be judged, & a copy is taken to be readable, so a host where
// / they cannot be is not stopped by a check it cannot make, & root says so elsewhere.
function webServerCanReadSecretCopy($secretFile) {
// / Set variables.
global $EnableMemoryProtection;
$CopyIsReadable = $folderIsTrusted = FALSE;
$resolvedFolder = $trustNotice = '';
$copyStatus = $folderStatus = array();
$webAccountNumber = $webGroupNumber = -1;
$readBits = $searchBits = 0;
list ($webAccountNumber, $webGroupNumber) = webServerAccountNumbers();
list ($folderIsTrusted, $resolvedFolder, $trustNotice) = secretFolderIsTrusted(dirname((string)$secretFile));
clearstatcache(TRUE, (string)$secretFile);
clearstatcache(TRUE, dirname((string)$secretFile));
$copyStatus = @lstat((string)$secretFile);
$folderStatus = @lstat(dirname((string)$secretFile));
if ($folderIsTrusted && is_array($copyStatus) && is_array($folderStatus) && ((int)$copyStatus['mode'] & 0170000) === 0100000 && (int)$copyStatus['nlink'] === 1 && ((int)$copyStatus['mode'] & 0022) === 0) {
if ($webAccountNumber < 0 or $webGroupNumber < 0) $CopyIsReadable = TRUE;
else if (in_array((int)$copyStatus['uid'], array(0, $webAccountNumber), TRUE)) {
// / The owner's bits apply to the owner, the group's to a member, & the rest to anybody else.
$readBits = ((int)$copyStatus['uid'] === $webAccountNumber) ? 0400 : (((int)$copyStatus['gid'] === $webGroupNumber) ? 0040 : 0004);
$searchBits = ((int)$folderStatus['uid'] === $webAccountNumber) ? 0100 : (((int)$folderStatus['gid'] === $webGroupNumber) ? 0010 : 0001);
$CopyIsReadable = (((int)$copyStatus['mode'] & $readBits) !== 0 && ((int)$folderStatus['mode'] & $searchBits) !== 0); } }
// / Manually clean up sensitive memory. Helps to keep track of variable assignments.
// / $CopyIsReadable is not purged, because it is the return value.
purgeSensitiveMemory($EnableMemoryProtection, $folderIsTrusted, $resolvedFolder, $trustNotice, $copyStatus, $folderStatus, $webAccountNumber, $webGroupNumber, $readBits, $searchBits, $secretFile);
return $CopyIsReadable; }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to load, create or rotate the install wide secret as root.
// / Accepts the secret file, the version this core requires & whether a new key was asked for.
// / Returns a readiness boolean & the key, in that order.
// /
// / Root reads the secret with readSecretWithoutExecuting & writes it through writeInstallSecretAsRoot.
// / Nothing here requires the file or follows a link.
// /
// / Two copies carry one key, & neither is removed while it is known to carry it.
// / The secret is kept in Secrets once Setup Core has given the folder its role.
// / The copy at the top of the data location, where older releases read it, keeps the same key.
// / Every command run as root writes the key it holds into each copy that does not already carry it exactly.
// / So a web server running this release, an older one, or one not installed yet always finds the key, & going back to an older release costs no session.
// / The web server account reads the copy in Secrets first once the folder has its role & the data location is root's, & the copy at the top first until then.
// / topSecretCopyIsNewer puts the copy at the top first when root wrote it after the copy in Secrets.
// / A copy known to carry the key is never removed, because a web server that can no longer read its copy is a site that stops.
// / A release that reads only the top, or a hosted tree never installed, cannot be told apart from here.
// / The one copy removed is one not known to carry the key, that could not be written, & only once the web server account reads the key in the copy it reads first.
// /
// / The first usable copy wins.
// / The copy the web server account reads first is looked in, then the other, then the first again.
// / A copy that cannot be used is passed over & named, & is written again with the key in use.
// / A copy that vanishes or changes while it is read was moved or replaced by an older release running as root, & a later look finds it.
// /
// / A copy counts only when installSecretCopyIsTrusted says so, before it is read.
// / That is how the web server account judges the copy it reads too, so neither uses a copy the other refuses.
// / A copy the web server account owns is taken, because older releases & an old chown -R leave it so, & that account reads the key whoever owns the file.
// / It is written again as root's with its same key.
// /
// / A new key is written only when no usable copy is found, & never in place of a copy root cannot reach.
// / Root does not read in Secrets while another account could replace the folder, so it stops with error 31020 & names why rather than replace a copy that may be the good one.
// / prepareDataLocationAsRoot gives the data location back to root first, which is the usual cause.
// / A rotation writes its new key into every copy, & is refused with error 31020 while a copy in Secrets is out of reach, because that copy would keep the old key.
// / No copy is written where another account could replace it.
// /
// / The key is in use only when the web server account reads it.
// / Once the copies are settled, webServerCanReadSecretCopy judges which copy that account would read first, & the key is ready only when that copy carries it.
// / A rotation is done once the copy read first carries the new key.
// /
// / One command run as root settles the secret at a time.
// / A lock in Engine, root's own folder, opened through the kernel, is held while this runs, so a command that read the old key never writes it back over a new rotation.
// / It is taken only in an Engine that is root's own, because a lock another account could hold could stall every repair.
// / A command that has waited fifteen seconds goes on without it & says so.
// / A command that finds no Engine, as before the first -fp, goes on without it, because nothing else can be settling a secret there yet.
function resolveInstallSecretAsRoot($secretFile, $requiredSecretVersion, $rotationRequested) {
// / Set variables.
global $ApacheUser, $EngineDir, $SecretPlacesWereOpen, $EnableMemoryProtection;
$SecretIsReady = FALSE;
$ResolvedSecretKey = '';
$existingWasRead = $keyWasGenerated = $copyWasWritten = $groupWasChanged = $copyIsRight = $folderIsHeld = $folderIsReady = $secretIsUnreachable = $copyIsTrusted = $lockIsHeld = $lockWasCreated = $placeIsTrusted = $engineIsTrusted = $lockWouldBlock = $preferredIsRight = $keptWasRead = $keptIsTrusted = $preferredIsReadable = $staleWasRemoved = $judgedWasRead = $webReadsKey = FALSE;
$existingKey = $existingVersion = $candidateKey = $cleanRequiredVersion = $writeFailure = $trustNotice = $holdNotice = $lockNotice = $folderProblem = $refusalNotice = $resolvedPlace = $placeNotice = $resolvedEngine = $engineNotice = $keptKey = $keptVersion = $keptNotice = $removalNotice = '';
$secretFolder = $olderSecretFile = $preferredCopy = $otherCopy = $keptCopy = $sourceFile = $candidateFile = $refusedFile = $folderState = $folderMode = $folderOwnership = $lockPath = $writtenCopies = '';
$judgedCopy = $judgedKey = $judgedVersion = $webCopy = '';
$secretStatus = $candidateFiles = $refusedCopies = $keptCopies = array();
$webAccountNumber = $webGroupNumber = -1;
$lockDeadline = 0;
$lockHandle = FALSE;
$cleanRequiredVersion = ltrim(trim((string)$requiredSecretVersion), 'vV');
$secretFolder = dirname((string)$secretFile);
$olderSecretFile = olderInstallSecretFile($secretFile);
list ($webAccountNumber, $webGroupNumber) = webServerAccountNumbers();
if ($webAccountNumber < 0 or $webGroupNumber < 0) warningEntry('The web server account '.$ApacheUser.' or the group of the same name could not be looked up, so only a copy of the install secret root owns counts, & Secrets cannot be judged.');
// / The lock is taken before anything is looked at, & only in an Engine that is root's own. A
// / lock file another command run as root created a moment ago is opened again, once.
if (isset($EngineDir) && is_string($EngineDir) && $EngineDir !== '' && is_dir($EngineDir)) {
list ($engineIsTrusted, $resolvedEngine, $engineNotice) = pathIsTrustedByRoot($EngineDir);
if (!$engineIsTrusted or $resolvedEngine !== rtrim($EngineDir, DIRECTORY_SEPARATOR)) $lockNotice = 'Engine is not root\'s own yet. '.$engineNotice;
else {
$lockPath = $resolvedEngine.DIRECTORY_SEPARATOR.'secret.lock';
list ($lockHandle, $lockWasCreated, $lockNotice) = openFileWithoutFollowing($lockPath, 0600);
if (!is_resource($lockHandle)) list ($lockHandle, $lockWasCreated, $lockNotice) = openFileWithoutFollowing($lockPath, 0600);
$lockDeadline = hrtime(TRUE) + 15000000000;
while (is_resource($lockHandle) && !$lockIsHeld) {
$lockIsHeld = @flock($lockHandle, LOCK_EX | LOCK_NB, $lockWouldBlock);
if ($lockIsHeld or !$lockWouldBlock or hrtime(TRUE) >= $lockDeadline) break;
usleep(100000); }
if (is_resource($lockHandle) && !$lockIsHeld) $lockNotice = $lockWouldBlock ? 'Another command run as root held the lock for fifteen seconds.' : 'This filesystem cannot lock it.'; }
if (!$lockIsHeld) warningEntry('The install secret was settled without its lock, so another command run as root could have been settling it at the same moment. '.$lockNotice); }
// / The web server account reads the copy in Secrets first once the folder has its role & only
// / root could replace it, unless the copy at the top is root's & was written after it, & the
// / copy at the top first until then. Root looks in the same order.
list ($folderState, $folderMode, $folderOwnership) = secretFolderStatus($secretFolder);
list ($folderIsHeld, $holdNotice) = folderIsHeldForRoot($secretFolder);
$folderIsReady = ($folderState === 'ok' && $folderIsHeld && $olderSecretFile !== (string)$secretFile);
$preferredCopy = ($folderIsReady && !topSecretCopyIsNewer($secretFile)) ? (string)$secretFile : $olderSecretFile;
$otherCopy = ($preferredCopy === $olderSecretFile) ? (string)$secretFile : $olderSecretFile;
// / Root neither reads nor writes in Secrets while another account could replace the folder.
$secretIsUnreachable = (!$folderIsHeld && $olderSecretFile !== (string)$secretFile && (file_exists($secretFile) or is_link($secretFile)));
// / A place another account could write makes every copy there one only root's own may stand for.
list ($placeIsTrusted, $resolvedPlace, $placeNotice) = secretFolderIsTrusted($secretFolder);
if ($folderIsHeld && !$placeIsTrusted && file_exists($secretFolder)) $SecretPlacesWereOpen = TRUE;
// / The copies root keeps. The top always. Secrets once the folder has its role, & any copy root
// / can reach there before then, so it carries the same key.
$keptCopies = array($preferredCopy);
if ($otherCopy === $olderSecretFile or ($folderIsHeld && (file_exists($otherCopy) or is_link($otherCopy)))) $keptCopies[] = $otherCopy;
// / Find the first usable key, unless a new one was asked for.
if (!$rotationRequested) {
$candidateFiles = array($preferredCopy, $otherCopy, $preferredCopy);
foreach ($candidateFiles as $candidateFile) {
if (!file_exists($candidateFile) && !is_link($candidateFile)) continue;
if ($candidateFile === (string)$secretFile && !$folderIsHeld) continue;
$existingWasRead = FALSE;
$existingKey = $existingVersion = '';
list ($copyIsTrusted, $trustNotice) = installSecretCopyIsTrusted($candidateFile);
if ($copyIsTrusted) list ($existingWasRead, $existingKey, $existingVersion) = readSecretWithoutExecuting($candidateFile);
if ($copyIsTrusted && $existingWasRead && ltrim($existingVersion, 'vV') === $cleanRequiredVersion) {
$sourceFile = $candidateFile;
$candidateKey = $existingKey;
unset($refusedCopies[$candidateFile]);
break; }
// / A copy that is there & cannot be used is named once, with why, & the looks go on.
if (!isset($refusedCopies[$candidateFile]) && (file_exists($candidateFile) or is_link($candidateFile))) $refusedCopies[$candidateFile] = $copyIsTrusted ? 'It is not one this core can use, or carries something this application did not write.' : $trustNotice; }
foreach ($refusedCopies as $refusedFile => $refusalNotice) warningEntry('The copy of the install secret at '.$refusedFile.' is not used. '.$refusalNotice); }
// / A copy in Secrets root cannot reach may be the good one, so nothing is written in its place.
if ($rotationRequested && $secretIsUnreachable) errorEntry('The install secret was not rotated, because another account could replace '.$secretFolder.', & the copy there would keep the old key! '.$holdNotice, 31020, FALSE);
else if ($candidateKey === '' && $secretIsUnreachable) errorEntry('The install secret at '.$secretFile.' could not be reached, because another account could replace '.$secretFolder.', so nothing was replaced! '.$holdNotice, 31020, FALSE);
else {
if ($candidateKey === '') list ($candidateKey, $keyWasGenerated) = generateInstallSecret();
if (!is_string($candidateKey) or $candidateKey === '') errorEntry('An install secret could not be generated, because this host could not supply random bytes!', 31020, FALSE);
else {
// / Every copy root keeps is made to carry the key exactly, the one read first first. A copy
// / is right when it is root's, 0640, its group is the web server account's & it already
// / carries the key. No copy is written where another account could replace it.
foreach ($keptCopies as $keptCopy) {
$copyIsRight = $keptWasRead = $keptIsTrusted = $copyWasWritten = $groupWasChanged = FALSE;
$keptKey = $keptVersion = $writeFailure = $keptNotice = '';
$secretStatus = array();
if (!$keyWasGenerated && $keptCopy === $sourceFile) $keptKey = $candidateKey;
else if (!$keyWasGenerated && (file_exists($keptCopy) or is_link($keptCopy))) {
list ($keptIsTrusted, $keptNotice) = installSecretCopyIsTrusted($keptCopy);
if ($keptIsTrusted) list ($keptWasRead, $keptKey, $keptVersion) = readSecretWithoutExecuting($keptCopy); }
clearstatcache(TRUE, $keptCopy);
$secretStatus = @lstat($keptCopy);
// / When the group cannot be looked up, no copy's group can be judged, so a copy is not
// / written again on every command for a group nobody here can name.
$copyIsRight = ($keptKey === $candidateKey && is_array($secretStatus) && ((int)$secretStatus['mode'] & 0170000) === 0100000 && (int)$secretStatus['uid'] === 0 && ((int)$secretStatus['mode'] & 0777) === 0640 && ($webGroupNumber < 0 or (int)$secretStatus['gid'] === $webGroupNumber));
if (!$copyIsRight) {
list ($placeIsTrusted, $resolvedPlace, $placeNotice) = secretFolderIsTrusted(dirname($keptCopy));
if ($placeIsTrusted) list ($copyWasWritten, $groupWasChanged, $writeFailure) = writeInstallSecretAsRoot($keptCopy, $candidateKey, $cleanRequiredVersion);
else $writeFailure = 'The install secret was not written to '.$keptCopy.', because another account could replace it there. '.$placeNotice;
// / A copy written whose group could not be given is warned about by the writer. A copy
// / read first that could not be written is an error when the key is new or a rotation's.
// / Otherwise the key was found in a copy, which still carries it, so it is only warned
// / about, & whether the web server account reads the key is judged once every copy is
// / settled, below.
if (!$copyWasWritten && $keptCopy === $preferredCopy && ($sourceFile === '' or $rotationRequested or $keyWasGenerated)) errorEntry($writeFailure, 31020, FALSE);
else if (!$copyWasWritten && $keptCopy === $preferredCopy) warningEntry($writeFailure.' The copy at '.$sourceFile.' still carries the key, & the next command run as root tries again.');
// / A copy that is not there carries nothing & is only named.
else if (!$copyWasWritten && !file_exists($keptCopy) && !is_link($keptCopy)) warningEntry($writeFailure);
// / Another copy not known to carry the key, because it carries another, none, or could not
// / be trusted to be read, & that could not be written goes, once the web server account
// / can read the key in the copy it reads first, so no other key can ever be found there.
// / One known to carry the key is left, because it is right but for its owner or mode.
else if (!$copyWasWritten && $preferredIsReadable && $keptKey !== $candidateKey) {
list ($staleWasRemoved, $removalNotice) = removeFileWithoutFollowing($keptCopy);
if ($staleWasRemoved) warningEntry($writeFailure.' It was not known to carry the key in use, so it was removed. The copy at '.$preferredCopy.' carries the key.');
else errorEntry($writeFailure.' It is not known to carry the key in use & could not be removed either. '.$removalNotice, 31020, FALSE); }
// / While the copy read first is not right, the web server account may read this one, so
// / one not known to carry the key is left, & the judgment below says what it means.
else if (!$copyWasWritten && $keptKey !== $candidateKey && !$rotationRequested) warningEntry($writeFailure.' It is not known to carry the key in use, & is left while the copy at '.$preferredCopy.' is not right.');
else if (!$copyWasWritten && $rotationRequested) errorEntry($writeFailure.' It still carries the old key until the next command run as root writes the new one there.', 31020, FALSE);
else if (!$copyWasWritten) warningEntry($writeFailure.' It carries the key in use, & the next command run as root tries again.');
else if (!$keyWasGenerated && !$rotationRequested && ($keptCopy === $sourceFile or $keptKey === $candidateKey)) warningEntry('The install secret at '.$keptCopy.' was rewritten as root with its existing key. Every session is unaffected.');
else if (!$keyWasGenerated && !$rotationRequested && $keptWasRead && $keptKey !== '' && $keptKey !== $candidateKey && ltrim($keptVersion, 'vV') === $cleanRequiredVersion) warningEntry('The copy of the install secret at '.$keptCopy.' carried another key, & now carries the one at '.$sourceFile.'. Every session derived from its key is now invalid.');
else if (!$keyWasGenerated && !$rotationRequested) warningEntry('The install secret at '.$sourceFile.' was copied to '.$keptCopy.' with its existing key. Every session is unaffected.');
if ($copyWasWritten) $writtenCopies .= ($writtenCopies === '' ? '' : ' & ').$keptCopy; }
if ($keptCopy === $preferredCopy) {
$preferredIsRight = ($copyIsRight or $copyWasWritten);
$preferredIsReadable = ($copyIsRight or ($copyWasWritten && $groupWasChanged)); }
// / A rotation stops at the first copy it could not write, so no later copy gets a key the
// / earlier one does not carry.
if ($rotationRequested && !$copyIsRight && !$copyWasWritten) break; }
// / The key is in use only when the web server account reads it.
// / That account uses the first copy it can read & use, in the order root looked, & passes over one it cannot read & one not in the shape this core reads.
// / So each copy is judged & read the same way, & the key root holds is in use only when the copy that account would use carries it.
// / A rotation is done once the copy read first carries the new key, & is named here too when that account would not read it.
foreach (array($preferredCopy, $otherCopy) as $judgedCopy) {
if ($webCopy !== '' or !webServerCanReadSecretCopy($judgedCopy)) continue;
list ($judgedWasRead, $judgedKey, $judgedVersion) = readSecretWithoutExecuting($judgedCopy);
if ($judgedWasRead && ltrim($judgedVersion, 'vV') === $cleanRequiredVersion) {
$webCopy = $judgedCopy;
$webReadsKey = ($judgedKey === $candidateKey); } }
if (!$webReadsKey) errorEntry('The web server account would not read the install secret root holds! '.($webCopy !== '' ? 'The first copy it would use, at '.$webCopy.', carries another key.' : 'It can use neither copy.'), 31020, FALSE);
if (($rotationRequested && $preferredIsRight) or (!$rotationRequested && $webReadsKey)) {
$SecretIsReady = TRUE;
$ResolvedSecretKey = $candidateKey;
if ($rotationRequested) warningEntry('The install secret was rotated. Every session derived from the old one is now invalid.');
else if ($keyWasGenerated) warningEntry('A new install secret was written to '.$writtenCopies.'.'); } } }
// / A folder that is there without its role is what keeps the copy there from being read first,
// / so it is named, whether or not a key was put in place.
if (!$folderIsReady && $folderState !== 'missing' && $olderSecretFile !== (string)$secretFile) {
if ($folderState === 'not a folder') $folderProblem = $secretFolder.' is not a folder & is never followed. Remove it & run -fp as root.';
else if ($folderState === 'unjudged') $folderProblem = $secretFolder.' has a group that cannot be judged, because the group '.$ApacheUser.' could not be looked up.';
else if ($folderState === 'wrong') $folderProblem = $secretFolder.' is '.$folderOwnership.' '.$folderMode.' & needs root:'.$ApacheUser.' 0750. -fp gives the folder its role & copies the secret in.';
else $folderProblem = 'another account could replace '.$secretFolder.'. '.$holdNotice;
warningEntry('The web server account reads the install secret at '.$olderSecretFile.' first, because '.$folderProblem); }
// / The lock is released & closed whatever happened above.
if (is_resource($lockHandle)) {
if ($lockIsHeld) @flock($lockHandle, LOCK_UN);
@fclose($lockHandle); }
// / Manually clean up sensitive memory. Every target is passed directly by reference.
// / $ResolvedSecretKey is not purged, because it is the return value.
purgeSensitiveMemory($EnableMemoryProtection, $existingWasRead, $keyWasGenerated, $copyWasWritten, $groupWasChanged, $copyIsRight, $folderIsHeld, $folderIsReady, $secretIsUnreachable, $copyIsTrusted, $lockIsHeld, $lockWasCreated, $placeIsTrusted, $engineIsTrusted, $lockWouldBlock, $preferredIsRight, $keptWasRead, $keptIsTrusted, $preferredIsReadable, $staleWasRemoved, $judgedWasRead, $webReadsKey, $judgedKey, $judgedVersion, $judgedCopy, $webCopy, $existingKey, $existingVersion, $candidateKey, $cleanRequiredVersion, $writeFailure, $trustNotice, $holdNotice, $lockNotice, $folderProblem, $refusalNotice, $resolvedPlace, $placeNotice, $resolvedEngine, $engineNotice, $keptKey, $keptVersion, $keptNotice, $removalNotice, $secretFolder, $olderSecretFile, $preferredCopy, $otherCopy, $keptCopy, $sourceFile, $candidateFile, $refusedFile, $folderState, $folderMode, $folderOwnership, $lockPath, $writtenCopies, $secretStatus, $candidateFiles, $refusedCopies, $keptCopies, $webAccountNumber, $webGroupNumber, $lockDeadline, $lockHandle, $secretFile, $requiredSecretVersion, $rotationRequested);
return array($SecretIsReady, $ResolvedSecretKey); }
// / -----------------------------------------------------------------------------------
// / -----------------------------------------------------------------------------------
// / A function to load an install secret from a file, or to create one when none exists.
// / Accepts the absolute path of the secret file.
// / Returns a readiness boolean & the secret key, in that order.
// / A failure returns FALSE & an empty string, which the caller must treat as a failed installation.
// / The file is written with only the secret & is checked byte for byte afterwards, & a file that fails that check is deleted.
// / Appending to an existing file would produce a file that loads & carries a key nobody generated.
// / A per user secret is chmod 0600 on every path, because a secret another account can read is not a secret.
// / The install wide secret is never changed here, because the web server account only reads it.
// / This is called for the install wide secret & for a per user secret in a folder of its own, so it lives here rather than twice.
// / $SecretKey is capitalized against convention 3 because the secret file defines it under that name.
// / It is a function local here regardless of its case & is shredded before this function returns.
// /
// / Root does not come here, & the web server account only reads.
// / Root reads the install wide secret through resolveInstallSecretAsRoot, which never requires it.
// / The web server account neither removes nor creates the file root wrote, because a secret it could write is a secret root would one day have to read.
// / A file it cannot use is reported, & -fp as root replaces it.
// / The web server account never runs the install secret, & reads it with readSecretWithoutExecuting, as root does.
// / A per user secret is its own account's file, & is required & created by that account alone, in a folder only it can reach.
// / That includes the web server account's own per user secret.
// / The install wide secret is the one name ending in -secret.php.
// / verifyInstallation names a per user secret with its account after that, & it is the only place either name is built.
function resolveSecretFile($secretFile, $requiredSecretVersion) {
// / Set variables.
global $LogFile, $ApacheUser, $CurrentUser, $EnableMemoryProtection, $CoreLoaded;
$SecretIsReady = FALSE;
$ResolvedSecretKey = $secret = $secretFileContent = '';
$detectedSecretVersion = $cleanRequiredVersion = $rotationNotice = $strayOutput = '';
$secretGenerated = $existingIsUsable = $loggingIsReady = $payloadIsWellFormed = $secretIsReadable = $creationIsPermitted = $isInstallSecret = $secretWasRead = FALSE;
$bytesWritten = 0;
// / Both are only ever defined by the require or the read below.
// / They are initialized here so a path that reaches neither leaves neither undefined.
$SecretKey = '';
$SecretVersion = '';
$cleanRequiredVersion = ltrim(trim((string)$requiredSecretVersion), 'vV');
if (isset($LogFile) && is_string($LogFile) && $LogFile !== '') $loggingIsReady = TRUE;
$isInstallSecret = (substr(basename((string)$secretFile), -11) === '-secret.php');
$creationIsPermitted = ((string)$CurrentUser !== (string)$ApacheUser or !$isInstallSecret);
// / Load an existing secret file & decide whether this core may still use it.
if (file_exists($secretFile)) {
if ($creationIsPermitted) @chmod($secretFile, 0600);