-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathKeyring.php
More file actions
180 lines (155 loc) · 5.14 KB
/
Copy pathKeyring.php
File metadata and controls
180 lines (155 loc) · 5.14 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
<?php
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* Italix Crypto - Keyring
*
* @package Italix\Crypto
*/
declare(strict_types=1);
namespace Italix\Crypto;
/**
* The keys, and the fact that there can be more than one.
*
* Rotation is the reason this class exists rather than a `string $key`
* parameter. A key can only be replaced if the old one keeps *verifying* for as
* long as anything signed with it is still valid — a password-reset link mailed
* five minutes before the rotation has to keep working. So: **sign with the
* primary, verify against all**.
*
* Each key carries a short id which is written into the token, so verification
* tries the right key first instead of all of them, and so an operator can tell
* from a token which key produced it.
*
* APP_KEY=base64:xxxxxxxx… # primary
* APP_KEY_PREVIOUS=base64:yyyyyyyy… # accepted during the rotation window
*
* Keys are 32 bytes. Accepted as `base64:…`, `hex:…`, or 64 hex characters
* with no prefix.
*/
final class Keyring
{
public const KEY_BYTES = 32;
/** @var array<string, string> key id => raw 32-byte key, primary first */
private array $keys = [];
/**
* @param string[] $keys Encoded keys, primary first
*/
public function __construct(array $keys)
{
foreach ($keys as $encoded) {
$encoded = trim((string) $encoded);
if ($encoded === '') {
continue;
}
$raw = self::decode($encoded);
$this->keys[self::id_of($raw)] = $raw;
}
if ($this->keys === []) {
throw new CryptoException(
'A Keyring needs at least one key. Generate one with: '
. 'php -r \'echo "base64:", base64_encode(random_bytes(32)), "\n";\''
);
}
}
/**
* Build from environment variables.
*
* @param string[] $names in precedence order; the first present is primary
*/
public static function from_env(array $names = ['APP_KEY', 'APP_KEY_PREVIOUS']): self
{
$keys = [];
foreach ($names as $name) {
$value = $_ENV[$name] ?? getenv($name);
if (is_string($value) && trim($value) !== '') {
$keys[] = $value;
}
}
if ($keys === []) {
throw new CryptoException(
'No application key: set ' . reset($names) . ' in .env. Generate one with: '
. 'php bin/ix crypto:key'
);
}
return new self($keys);
}
/**
* A fresh key in the encoded form the environment expects.
*/
public static function generate(): string
{
return 'base64:' . base64_encode(random_bytes(self::KEY_BYTES));
}
public function primary_id(): string
{
return (string) array_key_first($this->keys);
}
public function primary(): string
{
return $this->keys[$this->primary_id()];
}
/**
* The key with this id, or null. Never throws: the id comes off the wire.
*/
public function by_id(string $key_id): ?string
{
return $this->keys[$key_id] ?? null;
}
/**
* Every key, primary first — the verification order when a token carries no
* usable id.
*
* @return array<string, string>
*/
public function all(): array
{
return $this->keys;
}
public function count(): int
{
return count($this->keys);
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
/**
* A short, non-secret identifier for a key.
*
* Derived by HMAC rather than by hashing the key directly, and truncated to
* 8 hex characters: it appears in every token, so it must reveal nothing
* useful about the key itself.
*/
private static function id_of(string $raw_key): string
{
return substr(hash_hmac('sha256', 'italix.keyring.id', $raw_key), 0, 8);
}
private static function decode(string $encoded): string
{
if (strncmp($encoded, 'base64:', 7) === 0) {
$raw = base64_decode(substr($encoded, 7), true);
} elseif (strncmp($encoded, 'hex:', 4) === 0) {
$raw = @hex2bin(substr($encoded, 4));
} elseif (preg_match('/^[0-9a-fA-F]{64}$/', $encoded) === 1) {
$raw = @hex2bin($encoded);
} else {
$raw = false;
}
if (!is_string($raw)) {
throw new CryptoException(
'Malformed application key: expected "base64:…", "hex:…" or 64 hex characters.'
);
}
if (strlen($raw) !== self::KEY_BYTES) {
throw new CryptoException(sprintf(
'Application key must be %d bytes, got %d. Generate one with: php bin/ix crypto:key',
self::KEY_BYTES,
strlen($raw)
));
}
return $raw;
}
}