@oneuptime/common 12.0.3 → 12.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/CephCluster.ts +14 -0
- package/Models/DatabaseModels/CodeRepository.ts +14 -6
- package/Models/DatabaseModels/DockerHost.ts +14 -0
- package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
- package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
- package/Models/DatabaseModels/IoTFleet.ts +14 -0
- package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
- package/Models/DatabaseModels/NetworkDevice.ts +14 -0
- package/Models/DatabaseModels/NetworkInterface.ts +18 -0
- package/Models/DatabaseModels/Project.ts +16 -2
- package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
- package/Models/DatabaseModels/Service.ts +30 -0
- package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
- package/Models/DatabaseModels/User.ts +30 -0
- package/Server/API/AIAgentDataAPI.ts +31 -0
- package/Server/API/DashboardAPI.ts +419 -12
- package/Server/API/GitHubAPI.ts +119 -283
- package/Server/API/UserAPI.ts +263 -1
- package/Server/EnvironmentConfig.ts +7 -3
- package/Server/Infrastructure/GlobalCache.ts +37 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
- package/Server/Services/AnalyticsDatabaseService.ts +72 -1
- package/Server/Services/CodeRepositoryService.ts +105 -2
- package/Server/Services/DatabaseService.ts +185 -2
- package/Server/Services/OpenTelemetryIngestService.ts +230 -26
- package/Server/Services/UserService.ts +184 -0
- package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
- package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
- package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
- package/Server/Utils/PasswordHash.ts +306 -0
- package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
- package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
- package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
- package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
- package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
- package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
- package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
- package/Tests/Server/API/Helpers.ts +6 -1
- package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
- package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
- package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
- package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
- package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
- package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
- package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
- package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
- package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
- package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
- package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
- package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
- package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
- package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
- package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
- package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
- package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
- package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
- package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
- package/Tests/Types/Database/ColumnLength.test.ts +7 -1
- package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
- package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
- package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
- package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
- package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
- package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
- package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
- package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
- package/Types/Database/ColumnLength.ts +9 -1
- package/Types/Database/TableColumn.ts +11 -0
- package/Types/Database/UnsynchronizedIndex.ts +48 -0
- package/Types/HashedString.ts +139 -4
- package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
- package/UI/Components/Navbar/NavBar.tsx +12 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
- package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
- package/Utils/TeamMembersByProject.ts +237 -0
- package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
- package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +16 -2
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Service.js +28 -1
- package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
- package/build/dist/Models/DatabaseModels/User.js +32 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
- package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +304 -10
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/GitHubAPI.js +94 -167
- package/build/dist/Server/API/GitHubAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +187 -2
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +7 -3
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
- package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
- package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +160 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +165 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
- package/build/dist/Server/Utils/PasswordHash.js +237 -0
- package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
- package/build/dist/Types/Database/ColumnLength.js +9 -1
- package/build/dist/Types/Database/ColumnLength.js.map +1 -1
- package/build/dist/Types/Database/TableColumn.js.map +1 -1
- package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
- package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
- package/build/dist/Types/HashedString.js +95 -5
- package/build/dist/Types/HashedString.js.map +1 -1
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
- package/build/dist/Utils/TeamMembersByProject.js +145 -0
- package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import HashedString from "../../Types/HashedString";
|
|
2
|
+
import ObjectID from "../../Types/ObjectID";
|
|
3
|
+
import CryptoJS from "crypto-js";
|
|
4
|
+
import { describe, expect, test } from "@jest/globals";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The two SHA-256 schemes that passwords used to be stored under.
|
|
8
|
+
*
|
|
9
|
+
* Passwords are hashed with scrypt now (see PasswordHash) — this class no
|
|
10
|
+
* longer hashes any of them. What it still has to do, forever, is VERIFY the
|
|
11
|
+
* hashes that came before, because every one of those rows is a user who
|
|
12
|
+
* would otherwise be locked out at their next login:
|
|
13
|
+
*
|
|
14
|
+
* no salt `SHA256(secret + password)`, from before per-user salts.
|
|
15
|
+
* salted `SHA256("v2:" + salt + ":" + secret + ":" + password)`.
|
|
16
|
+
*
|
|
17
|
+
* The salted branch is also what still hashes the high-entropy server-minted
|
|
18
|
+
* values that legitimately want a fast, searchable hash: session refresh
|
|
19
|
+
* tokens, reset tokens, permission fingerprints.
|
|
20
|
+
*
|
|
21
|
+
* These tests pin the three properties both schemes have to keep:
|
|
22
|
+
* 1. same input + different salt -> different digest,
|
|
23
|
+
* 2. same input + same salt -> same digest (verification must work),
|
|
24
|
+
* 3. the unsalted branch is byte-for-byte what it always was.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const SECRET: ObjectID = new ObjectID("secret");
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* Salts are minted by PasswordHash (server-side) now that passwords go
|
|
31
|
+
* through scrypt. This class only still knows how to VERIFY the two SHA-256
|
|
32
|
+
* schemes that came before, so these tests supply their own salts rather than
|
|
33
|
+
* reaching across into a Server util.
|
|
34
|
+
*/
|
|
35
|
+
type SaltFunction = () => string;
|
|
36
|
+
|
|
37
|
+
let saltCounter: number = 0;
|
|
38
|
+
|
|
39
|
+
const makeSalt: SaltFunction = (): string => {
|
|
40
|
+
saltCounter++;
|
|
41
|
+
return saltCounter.toString(16).padStart(64, "0");
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("HashedString salted hashing", () => {
|
|
45
|
+
test("the same password under two different salts produces two different hashes", async () => {
|
|
46
|
+
const password: string = "correct-horse-battery-staple";
|
|
47
|
+
|
|
48
|
+
const first: string = await HashedString.hashValue(
|
|
49
|
+
password,
|
|
50
|
+
SECRET,
|
|
51
|
+
makeSalt(),
|
|
52
|
+
);
|
|
53
|
+
const second: string = await HashedString.hashValue(
|
|
54
|
+
password,
|
|
55
|
+
SECRET,
|
|
56
|
+
makeSalt(),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(first).not.toBe(second);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("two users sharing a password do not share a stored hash", async () => {
|
|
63
|
+
const shared: string = "hunter2";
|
|
64
|
+
|
|
65
|
+
const userA: { salt: string; hash: string } = {
|
|
66
|
+
salt: makeSalt(),
|
|
67
|
+
hash: "",
|
|
68
|
+
};
|
|
69
|
+
const userB: { salt: string; hash: string } = {
|
|
70
|
+
salt: makeSalt(),
|
|
71
|
+
hash: "",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
userA.hash = await HashedString.hashValue(shared, SECRET, userA.salt);
|
|
75
|
+
userB.hash = await HashedString.hashValue(shared, SECRET, userB.salt);
|
|
76
|
+
|
|
77
|
+
expect(userA.hash).not.toBe(userB.hash);
|
|
78
|
+
|
|
79
|
+
// And neither hash verifies under the other user's salt.
|
|
80
|
+
await expect(
|
|
81
|
+
HashedString.verifyValue({
|
|
82
|
+
plainValue: shared,
|
|
83
|
+
hashedValue: userA.hash,
|
|
84
|
+
encryptionSecret: SECRET,
|
|
85
|
+
salt: userB.salt,
|
|
86
|
+
}),
|
|
87
|
+
).resolves.toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("the same password under the same salt is stable across calls", async () => {
|
|
91
|
+
const salt: string = makeSalt();
|
|
92
|
+
|
|
93
|
+
const first: string = await HashedString.hashValue(
|
|
94
|
+
"p4ssw0rd",
|
|
95
|
+
SECRET,
|
|
96
|
+
salt,
|
|
97
|
+
);
|
|
98
|
+
const second: string = await HashedString.hashValue(
|
|
99
|
+
"p4ssw0rd",
|
|
100
|
+
SECRET,
|
|
101
|
+
salt,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(first).toBe(second);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("different passwords under the same salt still differ", async () => {
|
|
108
|
+
const salt: string = makeSalt();
|
|
109
|
+
|
|
110
|
+
expect(await HashedString.hashValue("password-a", SECRET, salt)).not.toBe(
|
|
111
|
+
await HashedString.hashValue("password-b", SECRET, salt),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the encryption secret still participates — same password and salt, different secret", async () => {
|
|
116
|
+
const salt: string = makeSalt();
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
await HashedString.hashValue("password", new ObjectID("one"), salt),
|
|
120
|
+
).not.toBe(
|
|
121
|
+
await HashedString.hashValue("password", new ObjectID("two"), salt),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("produces a hex SHA-256 digest, the same shape the column already stores", async () => {
|
|
126
|
+
const hash: string = await HashedString.hashValue(
|
|
127
|
+
"password",
|
|
128
|
+
SECRET,
|
|
129
|
+
makeSalt(),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// A bare hex SHA-256 digest, which is all these two schemes ever wrote.
|
|
133
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the salted and unsalted schemes are domain separated", async () => {
|
|
137
|
+
/*
|
|
138
|
+
* Salted input is "v2:<salt>:<secret>:<value>", unsalted is
|
|
139
|
+
* "<secret><value>". Without the prefix and separators, a crafted salt
|
|
140
|
+
* could make a salted input collide with a legacy one, which would let a
|
|
141
|
+
* legacy rainbow table reach salted rows.
|
|
142
|
+
*/
|
|
143
|
+
const salt: string = makeSalt();
|
|
144
|
+
|
|
145
|
+
const salted: string = await HashedString.hashValue(
|
|
146
|
+
"password",
|
|
147
|
+
SECRET,
|
|
148
|
+
salt,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// The naive concatenation an attacker would try to line up against.
|
|
152
|
+
const naive: string = CryptoJS.SHA256(
|
|
153
|
+
salt + SECRET.toString() + "password",
|
|
154
|
+
).toString();
|
|
155
|
+
|
|
156
|
+
expect(salted).not.toBe(naive);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("an empty salt falls back to the legacy scheme rather than a half-salted one", async () => {
|
|
160
|
+
const withEmptySalt: string = await HashedString.hashValue(
|
|
161
|
+
"password",
|
|
162
|
+
SECRET,
|
|
163
|
+
"",
|
|
164
|
+
);
|
|
165
|
+
const legacy: string = await HashedString.hashValue("password", SECRET);
|
|
166
|
+
|
|
167
|
+
expect(withEmptySalt).toBe(legacy);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("an empty value hashes to an empty string, salt or not", async () => {
|
|
171
|
+
expect(await HashedString.hashValue("", SECRET, makeSalt())).toBe("");
|
|
172
|
+
expect(await HashedString.hashValue("", SECRET)).toBe("");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("HashedString legacy (pre-salt) compatibility", () => {
|
|
177
|
+
test("the unsalted digest is byte-for-byte what it has always been", async () => {
|
|
178
|
+
/*
|
|
179
|
+
* This is the contract that keeps every already-stored password working.
|
|
180
|
+
* If this assertion ever has to change, every existing user is locked out
|
|
181
|
+
* — the value below is the literal formula the original implementation
|
|
182
|
+
* used, not a value copied from the implementation under test.
|
|
183
|
+
*/
|
|
184
|
+
const expected: string = CryptoJS.SHA256(
|
|
185
|
+
SECRET.toString() + "my-old-password",
|
|
186
|
+
).toString();
|
|
187
|
+
|
|
188
|
+
expect(await HashedString.hashValue("my-old-password", SECRET)).toBe(
|
|
189
|
+
expected,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("omitting the salt argument entirely behaves like passing null", async () => {
|
|
194
|
+
expect(await HashedString.hashValue("password", SECRET)).toBe(
|
|
195
|
+
await HashedString.hashValue("password", SECRET, null),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a legacy hash verifies when no salt is supplied", async () => {
|
|
200
|
+
const legacyHash: string = await HashedString.hashValue(
|
|
201
|
+
"legacy-password",
|
|
202
|
+
SECRET,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
HashedString.verifyValue({
|
|
207
|
+
plainValue: "legacy-password",
|
|
208
|
+
hashedValue: legacyHash,
|
|
209
|
+
encryptionSecret: SECRET,
|
|
210
|
+
salt: null,
|
|
211
|
+
}),
|
|
212
|
+
).resolves.toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a legacy hash does NOT verify once a salt is supplied", async () => {
|
|
216
|
+
/*
|
|
217
|
+
* The upgrade path depends on this: a row whose salt column is set is
|
|
218
|
+
* never checked against the legacy scheme, so an un-upgraded hash must
|
|
219
|
+
* fail rather than silently pass under the wrong scheme.
|
|
220
|
+
*/
|
|
221
|
+
const legacyHash: string = await HashedString.hashValue(
|
|
222
|
+
"legacy-password",
|
|
223
|
+
SECRET,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
HashedString.verifyValue({
|
|
228
|
+
plainValue: "legacy-password",
|
|
229
|
+
hashedValue: legacyHash,
|
|
230
|
+
encryptionSecret: SECRET,
|
|
231
|
+
salt: makeSalt(),
|
|
232
|
+
}),
|
|
233
|
+
).resolves.toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a salted hash does NOT verify against the legacy scheme", async () => {
|
|
237
|
+
const salted: string = await HashedString.hashValue(
|
|
238
|
+
"password",
|
|
239
|
+
SECRET,
|
|
240
|
+
makeSalt(),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
HashedString.verifyValue({
|
|
245
|
+
plainValue: "password",
|
|
246
|
+
hashedValue: salted,
|
|
247
|
+
encryptionSecret: SECRET,
|
|
248
|
+
salt: null,
|
|
249
|
+
}),
|
|
250
|
+
).resolves.toBe(false);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("HashedString.verifyValue", () => {
|
|
255
|
+
test("accepts the right password with the right salt", async () => {
|
|
256
|
+
const salt: string = makeSalt();
|
|
257
|
+
const hash: string = await HashedString.hashValue(
|
|
258
|
+
"right-password",
|
|
259
|
+
SECRET,
|
|
260
|
+
salt,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await expect(
|
|
264
|
+
HashedString.verifyValue({
|
|
265
|
+
plainValue: "right-password",
|
|
266
|
+
hashedValue: hash,
|
|
267
|
+
encryptionSecret: SECRET,
|
|
268
|
+
salt: salt,
|
|
269
|
+
}),
|
|
270
|
+
).resolves.toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("rejects the wrong password", async () => {
|
|
274
|
+
const salt: string = makeSalt();
|
|
275
|
+
const hash: string = await HashedString.hashValue(
|
|
276
|
+
"right-password",
|
|
277
|
+
SECRET,
|
|
278
|
+
salt,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await expect(
|
|
282
|
+
HashedString.verifyValue({
|
|
283
|
+
plainValue: "wrong-password",
|
|
284
|
+
hashedValue: hash,
|
|
285
|
+
encryptionSecret: SECRET,
|
|
286
|
+
salt: salt,
|
|
287
|
+
}),
|
|
288
|
+
).resolves.toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("rejects a password that only differs in case", async () => {
|
|
292
|
+
const salt: string = makeSalt();
|
|
293
|
+
const hash: string = await HashedString.hashValue("Password", SECRET, salt);
|
|
294
|
+
|
|
295
|
+
await expect(
|
|
296
|
+
HashedString.verifyValue({
|
|
297
|
+
plainValue: "password",
|
|
298
|
+
hashedValue: hash,
|
|
299
|
+
encryptionSecret: SECRET,
|
|
300
|
+
salt: salt,
|
|
301
|
+
}),
|
|
302
|
+
).resolves.toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("rejects an empty plaintext even against an empty stored hash", async () => {
|
|
306
|
+
/*
|
|
307
|
+
* A user row with no password (invited but never signed up) stores null.
|
|
308
|
+
* An empty submitted password must never satisfy it.
|
|
309
|
+
*/
|
|
310
|
+
await expect(
|
|
311
|
+
HashedString.verifyValue({
|
|
312
|
+
plainValue: "",
|
|
313
|
+
hashedValue: "",
|
|
314
|
+
encryptionSecret: SECRET,
|
|
315
|
+
salt: makeSalt(),
|
|
316
|
+
}),
|
|
317
|
+
).resolves.toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("rejects any plaintext against an empty stored hash", async () => {
|
|
321
|
+
await expect(
|
|
322
|
+
HashedString.verifyValue({
|
|
323
|
+
plainValue: "anything",
|
|
324
|
+
hashedValue: "",
|
|
325
|
+
encryptionSecret: SECRET,
|
|
326
|
+
salt: makeSalt(),
|
|
327
|
+
}),
|
|
328
|
+
).resolves.toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("rejects when the salt is wrong even though the password is right", async () => {
|
|
332
|
+
const hash: string = await HashedString.hashValue(
|
|
333
|
+
"password",
|
|
334
|
+
SECRET,
|
|
335
|
+
makeSalt(),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
await expect(
|
|
339
|
+
HashedString.verifyValue({
|
|
340
|
+
plainValue: "password",
|
|
341
|
+
hashedValue: hash,
|
|
342
|
+
encryptionSecret: SECRET,
|
|
343
|
+
salt: makeSalt(),
|
|
344
|
+
}),
|
|
345
|
+
).resolves.toBe(false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("rejects when the encryption secret is wrong", async () => {
|
|
349
|
+
const salt: string = makeSalt();
|
|
350
|
+
const hash: string = await HashedString.hashValue(
|
|
351
|
+
"password",
|
|
352
|
+
new ObjectID("real-secret"),
|
|
353
|
+
salt,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
await expect(
|
|
357
|
+
HashedString.verifyValue({
|
|
358
|
+
plainValue: "password",
|
|
359
|
+
hashedValue: hash,
|
|
360
|
+
encryptionSecret: new ObjectID("guessed-secret"),
|
|
361
|
+
salt: salt,
|
|
362
|
+
}),
|
|
363
|
+
).resolves.toBe(false);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("HashedString.isEqual — constant-time digest comparison", () => {
|
|
368
|
+
test("equal strings compare equal", () => {
|
|
369
|
+
expect(HashedString.isEqual("abc123", "abc123")).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("different strings of the same length compare unequal", () => {
|
|
373
|
+
expect(HashedString.isEqual("abc123", "abc124")).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("a difference in the first character is caught", () => {
|
|
377
|
+
expect(HashedString.isEqual("Xbc123", "abc123")).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("a difference in the last character is caught", () => {
|
|
381
|
+
expect(HashedString.isEqual("abc12X", "abc123")).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("different lengths compare unequal", () => {
|
|
385
|
+
expect(HashedString.isEqual("abc", "abcd")).toBe(false);
|
|
386
|
+
expect(HashedString.isEqual("abcd", "abc")).toBe(false);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("a prefix is not treated as a match", () => {
|
|
390
|
+
const hash: string = CryptoJS.SHA256("x").toString();
|
|
391
|
+
|
|
392
|
+
expect(HashedString.isEqual(hash.substring(0, 32), hash)).toBe(false);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("two empty strings compare equal", () => {
|
|
396
|
+
expect(HashedString.isEqual("", "")).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("compares every character, not just up to the first difference", () => {
|
|
400
|
+
/*
|
|
401
|
+
* The loop must not early-exit. Two strings differing only in the LAST
|
|
402
|
+
* character must still take the same path as two that differ in the
|
|
403
|
+
* first — that is the whole point of the accumulator.
|
|
404
|
+
*/
|
|
405
|
+
const a: string = "a".repeat(63) + "b";
|
|
406
|
+
const b: string = "a".repeat(63) + "c";
|
|
407
|
+
|
|
408
|
+
expect(HashedString.isEqual(a, b)).toBe(false);
|
|
409
|
+
expect(HashedString.isEqual(a, a)).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("HashedString instance hashing with a salt", () => {
|
|
414
|
+
test("marks itself hashed and replaces its value", async () => {
|
|
415
|
+
const hashedString: HashedString = new HashedString("password");
|
|
416
|
+
const salt: string = makeSalt();
|
|
417
|
+
|
|
418
|
+
expect(hashedString.isValueHashed()).toBe(false);
|
|
419
|
+
|
|
420
|
+
const digest: string = await hashedString.hashValue(SECRET, salt);
|
|
421
|
+
|
|
422
|
+
expect(hashedString.isValueHashed()).toBe(true);
|
|
423
|
+
expect(hashedString.toString()).toBe(digest);
|
|
424
|
+
expect(hashedString.toString()).not.toBe("password");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("refuses to hash twice, so a salt can never be applied over a digest", async () => {
|
|
428
|
+
const hashedString: HashedString = new HashedString("password");
|
|
429
|
+
await hashedString.hashValue(SECRET, makeSalt());
|
|
430
|
+
|
|
431
|
+
await expect(hashedString.hashValue(SECRET, makeSalt())).rejects.toThrow(
|
|
432
|
+
"Value is already hashed",
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("a value read back from the database is already flagged hashed", async () => {
|
|
437
|
+
const fromDb: HashedString = new HashedString("stored-digest", true);
|
|
438
|
+
|
|
439
|
+
expect(fromDb.isValueHashed()).toBe(true);
|
|
440
|
+
await expect(fromDb.hashValue(SECRET, null)).rejects.toThrow(
|
|
441
|
+
"Value is already hashed",
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("setHashedValue adopts a digest computed elsewhere", () => {
|
|
446
|
+
/*
|
|
447
|
+
* How a scrypt hash, computed server-side by PasswordHash, gets onto the
|
|
448
|
+
* model this class represents.
|
|
449
|
+
*/
|
|
450
|
+
const hashedString: HashedString = new HashedString("password");
|
|
451
|
+
|
|
452
|
+
hashedString.setHashedValue("scrypt$N=16384,r=8,p=1$" + "a".repeat(64));
|
|
453
|
+
|
|
454
|
+
expect(hashedString.isValueHashed()).toBe(true);
|
|
455
|
+
expect(hashedString.toString()).toBe(
|
|
456
|
+
"scrypt$N=16384,r=8,p=1$" + "a".repeat(64),
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("setHashedValue refuses to write over an existing digest", async () => {
|
|
461
|
+
const hashedString: HashedString = new HashedString("password");
|
|
462
|
+
await hashedString.hashValue(SECRET, makeSalt());
|
|
463
|
+
|
|
464
|
+
expect(() => {
|
|
465
|
+
hashedString.setHashedValue("something-else");
|
|
466
|
+
}).toThrow("Value is already hashed");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("computeHash matches what the instance method produces", async () => {
|
|
470
|
+
const salt: string = makeSalt();
|
|
471
|
+
|
|
472
|
+
expect(HashedString.computeHash("password", SECRET, salt)).toBe(
|
|
473
|
+
await HashedString.hashValue("password", SECRET, salt),
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
});
|