sliftutils 1.7.125 → 1.7.126
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/bin/derivekey.js +10 -0
- package/bin/portsecuredaemon.js +13 -0
- package/bin/unrevoke.js +9 -0
- package/package.json +8 -3
- package/security/README.md +66 -2
- package/security/authorizedKeys/authorizedKeys.ts +23 -0
- package/security/authorizedKeys/daemon/authLog.ts +117 -0
- package/security/authorizedKeys/daemon/daemon.ts +308 -0
- package/security/authorizedKeys/daemon/git.ts +119 -0
- package/security/authorizedKeys/daemon/notify.ts +25 -0
- package/security/authorizedKeys/daemon/paths.ts +26 -0
- package/security/authorizedKeys/daemon/portsecure.service +3 -2
- package/security/authorizedKeys/daemon/revocation.ts +306 -0
- package/security/authorizedKeys/daemon/rootKeys.ts +135 -0
- package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
- package/security/authorizedKeys/daemon/state.ts +108 -0
- package/security/authorizedKeys/daemon/trust.ts +291 -0
- package/security/authorizedKeys/daemon/userKeys.ts +76 -0
- package/security/authorizedKeys/dist/authorizedKeys.ts.cache +73 -0
- package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
- package/security/authorizedKeys/dist/secureSSH.ts.cache +552 -0
- package/security/authorizedKeys/dist/sources.ts.cache +24 -0
- package/security/authorizedKeys/dist/unrevoke.ts.cache +145 -0
- package/security/authorizedKeys/revokeSource.ts +40 -0
- package/security/authorizedKeys/secureSSH.ts +201 -15
- package/security/authorizedKeys/unrevoke.ts +149 -0
- package/security/helpers/dist/paths.ts.cache +28 -0
- package/security/helpers/dist/remoteSSH.ts.cache +90 -0
- package/security/helpers/dist/spawn.ts.cache +34 -0
- package/security/helpers/remoteSSH.ts +3 -2
- package/security/helpers/spawn.ts +5 -1
- package/security/keys/deriveKey.ts +72 -0
- package/security/keys/dist/deriveKey.ts.cache +72 -0
- package/security/keys/dist/sshKeyFile.ts.cache +153 -0
- package/security/keys/sshKeyFile.ts +156 -0
- package/security/notifications/dist/discord.ts.cache +180 -0
- package/security/signedFiles/dist/manifest.ts.cache +68 -0
- package/security/signedFiles/dist/signFiles.ts.cache +146 -0
- package/security/signedFiles/manifest.ts +19 -6
- package/security/signedFiles/signFiles.ts +78 -43
- package/security/authorizedKeys/daemon/portsecureDaemon.js +0 -1032
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { spawnPromise } from "../../helpers/spawn";
|
|
3
|
+
import { MAX_ERROR_BODY_LENGTH, SSHD_CONFIG_PATH, SSHD_DROPIN_DIR, SSHD_DROPIN_PATH } from "./paths";
|
|
4
|
+
|
|
5
|
+
// VERBOSE is what makes sshd name the key a refused attempt used, which is the whole basis for
|
|
6
|
+
// revoking it. Without it the log says an attempt was refused but not by whom.
|
|
7
|
+
const SSHD_DROPIN_CONTENTS = `# Managed by portsecure. Manual changes are reverted and reported.
|
|
8
|
+
# Keys come from the portsecure repo, so no other authentication method may be used.
|
|
9
|
+
PasswordAuthentication no
|
|
10
|
+
PermitEmptyPasswords no
|
|
11
|
+
KbdInteractiveAuthentication no
|
|
12
|
+
ChallengeResponseAuthentication no
|
|
13
|
+
PubkeyAuthentication yes
|
|
14
|
+
PermitRootLogin prohibit-password
|
|
15
|
+
LogLevel VERBOSE
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
async function pathExists(filePath: string) {
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(filePath);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function sshdConfigIncludesDropinDir() {
|
|
28
|
+
let contents = await fs.readFile(SSHD_CONFIG_PATH, "utf8");
|
|
29
|
+
return contents.split("\n").some(line => {
|
|
30
|
+
let trimmed = line.trim();
|
|
31
|
+
return trimmed.startsWith("Include") && trimmed.includes("sshd_config.d");
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function restartSSHD() {
|
|
36
|
+
for (let unit of ["ssh", "sshd"]) {
|
|
37
|
+
let result = await spawnPromise({ command: "systemctl", args: ["reload-or-restart", unit] });
|
|
38
|
+
if (result.status === 0) {
|
|
39
|
+
return unit;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Expected to reload ssh or sshd, neither unit could be reloaded`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Turns off every non key based way in. Validates before reloading, because a bad sshd config
|
|
46
|
+
that gets applied is exactly how a machine becomes unreachable. */
|
|
47
|
+
export async function enforceSSHDConfig() {
|
|
48
|
+
let existing = "";
|
|
49
|
+
if (await pathExists(SSHD_DROPIN_PATH)) {
|
|
50
|
+
existing = await fs.readFile(SSHD_DROPIN_PATH, "utf8");
|
|
51
|
+
}
|
|
52
|
+
let includeMissing = !await sshdConfigIncludesDropinDir();
|
|
53
|
+
if (existing === SSHD_DROPIN_CONTENTS && !includeMissing) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let originalConfig = await fs.readFile(SSHD_CONFIG_PATH, "utf8");
|
|
58
|
+
await fs.mkdir(SSHD_DROPIN_DIR, { recursive: true });
|
|
59
|
+
await fs.writeFile(SSHD_DROPIN_PATH, SSHD_DROPIN_CONTENTS, { mode: 0o644 });
|
|
60
|
+
if (includeMissing) {
|
|
61
|
+
// sshd takes the first value it sees for most keywords, so the include has to come before
|
|
62
|
+
// any setting it is meant to override.
|
|
63
|
+
await fs.writeFile(SSHD_CONFIG_PATH, `Include ${SSHD_DROPIN_DIR}/*.conf\n${originalConfig}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let validation = await spawnPromise({ command: "sshd", args: ["-t"] });
|
|
67
|
+
if (validation.error) {
|
|
68
|
+
validation = await spawnPromise({ command: "/usr/sbin/sshd", args: ["-t"] });
|
|
69
|
+
}
|
|
70
|
+
if (validation.status !== 0) {
|
|
71
|
+
// Roll back rather than leave a config that sshd would refuse on its next start.
|
|
72
|
+
await fs.rm(SSHD_DROPIN_PATH, { force: true });
|
|
73
|
+
if (includeMissing) {
|
|
74
|
+
await fs.writeFile(SSHD_CONFIG_PATH, originalConfig);
|
|
75
|
+
}
|
|
76
|
+
console.log(
|
|
77
|
+
`sshd rejected the portsecure config, rolled it back. `
|
|
78
|
+
+ `${(validation.stdout + validation.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let unit = await restartSSHD();
|
|
84
|
+
console.log(`Password authentication disabled, reloaded ${unit}`);
|
|
85
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { STATE_PATH } from "./paths";
|
|
4
|
+
|
|
5
|
+
/** What we last decided to trust for one source, kept on disk so nobody can tell us a different
|
|
6
|
+
story about what we saw last time. */
|
|
7
|
+
export type SourceState = {
|
|
8
|
+
lastSha: string;
|
|
9
|
+
branch: string;
|
|
10
|
+
accepted: boolean;
|
|
11
|
+
acceptedSigner: string;
|
|
12
|
+
acceptedKeys: string[];
|
|
13
|
+
acceptedManifestHash: string;
|
|
14
|
+
acceptedSignatureHash: string;
|
|
15
|
+
pendingSigner: string;
|
|
16
|
+
pendingSince: number;
|
|
17
|
+
reportedProblem: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A key this machine has stopped accepting. Kept even when the revoke repo no longer lists it:
|
|
21
|
+
the deploy key that writes revocations lives on every server, so an attacker who took one could
|
|
22
|
+
otherwise delete the revocation that locked them out. */
|
|
23
|
+
export type RevocationState = {
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
revocationId: string;
|
|
26
|
+
// Set once an unrevoke has been seen, so the wait can be served out across restarts.
|
|
27
|
+
unrevokeSeenAt: number;
|
|
28
|
+
unrevokeId: string;
|
|
29
|
+
unrevoked: boolean;
|
|
30
|
+
// Whether we have said that this key actually left root's authorized_keys. The removal is the
|
|
31
|
+
// thing worth reporting, and it is worth reporting exactly once.
|
|
32
|
+
reportedRemoved: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A refusal we saw but could not write down yet. Held until it is recorded, because the log is
|
|
36
|
+
read once and moves on: losing one of these to a repo that happened to be unreachable would
|
|
37
|
+
leave a key that was misused accepted forever. */
|
|
38
|
+
export type PendingRevocation = {
|
|
39
|
+
fingerprint: string;
|
|
40
|
+
keyLine: string;
|
|
41
|
+
sourceURL: string;
|
|
42
|
+
attempt: { ip: string; user: string; port: string; required: string; line: string };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type DaemonState = {
|
|
46
|
+
sources: { [repoURL: string]: SourceState };
|
|
47
|
+
userKeyHashes: { [userName: string]: string };
|
|
48
|
+
revocations: { [fingerprint: string]: RevocationState };
|
|
49
|
+
pendingRevocations: PendingRevocation[];
|
|
50
|
+
// Where we had read up to in the auth log, so a restart does not re-report old attempts.
|
|
51
|
+
authLogOffset: number;
|
|
52
|
+
authLogSignature: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let state: DaemonState = {
|
|
56
|
+
sources: {},
|
|
57
|
+
userKeyHashes: {},
|
|
58
|
+
revocations: {},
|
|
59
|
+
pendingRevocations: [],
|
|
60
|
+
authLogOffset: 0,
|
|
61
|
+
authLogSignature: "",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function getState() {
|
|
65
|
+
return state;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Per source progress, created on first use so a newly added source starts clean. */
|
|
69
|
+
export function sourceState(repoURL: string) {
|
|
70
|
+
let existing = state.sources[repoURL];
|
|
71
|
+
if (existing) {
|
|
72
|
+
return existing;
|
|
73
|
+
}
|
|
74
|
+
let created: SourceState = {
|
|
75
|
+
lastSha: "",
|
|
76
|
+
branch: "",
|
|
77
|
+
accepted: false,
|
|
78
|
+
acceptedSigner: "",
|
|
79
|
+
acceptedKeys: [],
|
|
80
|
+
acceptedManifestHash: "",
|
|
81
|
+
acceptedSignatureHash: "",
|
|
82
|
+
pendingSigner: "",
|
|
83
|
+
pendingSince: 0,
|
|
84
|
+
reportedProblem: "",
|
|
85
|
+
};
|
|
86
|
+
state.sources[repoURL] = created;
|
|
87
|
+
return created;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function loadState() {
|
|
91
|
+
let contents;
|
|
92
|
+
try {
|
|
93
|
+
contents = await fs.readFile(STATE_PATH, "utf8");
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
state = Object.assign(state, JSON.parse(contents));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Corrupt state only costs us one duplicate notification, so it is not worth failing over.
|
|
101
|
+
console.log(`Ignoring unreadable state file ${STATE_PATH}. ${e}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function saveState() {
|
|
106
|
+
await fs.mkdir(path.dirname(STATE_PATH), { recursive: true });
|
|
107
|
+
await fs.writeFile(STATE_PATH, JSON.stringify(state, undefined, 4), { mode: 0o600 });
|
|
108
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { spawnPromise } from "../../helpers/spawn";
|
|
5
|
+
import { normalizeKeys } from "../authorizedKeys";
|
|
6
|
+
import { sourceRepoPath } from "../sources";
|
|
7
|
+
import { MANIFEST_NAME, normalizeContent, SIGN_NAMESPACE, SIGNATURE_NAME } from "../../signedFiles/manifest";
|
|
8
|
+
import { MAX_ERROR_BODY_LENGTH, SIGNER_CHANGE_DELAY } from "./paths";
|
|
9
|
+
import { notify } from "./notify";
|
|
10
|
+
import { saveState, sourceState } from "./state";
|
|
11
|
+
|
|
12
|
+
// A source that has never been signed reads as this, so losing a signature counts as a change of
|
|
13
|
+
// signer rather than as something to wave through.
|
|
14
|
+
const UNSIGNED = "";
|
|
15
|
+
// What fixes both of the signature problems we report, so the message can say so rather than
|
|
16
|
+
// leaving someone to work it out.
|
|
17
|
+
const SIGN_COMMAND = "yarn signfiles git";
|
|
18
|
+
|
|
19
|
+
async function pathExists(filePath: string) {
|
|
20
|
+
try {
|
|
21
|
+
await fs.access(filePath);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readSSHString(buffer: Buffer, offset: number) {
|
|
29
|
+
let length = buffer.readUInt32BE(offset);
|
|
30
|
+
return buffer.subarray(offset + 4, offset + 4 + length);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The signature carries the public key that made it, so the key itself can be reported rather
|
|
34
|
+
than only a fingerprint. Returns it in the usual "type base64" form. */
|
|
35
|
+
export function publicKeyFromSignature(signatureText: string) {
|
|
36
|
+
let body = signatureText.split("\n").filter(line => line && !line.startsWith("-----")).join("");
|
|
37
|
+
let blob = Buffer.from(body, "base64");
|
|
38
|
+
if (blob.subarray(0, 6).toString() !== "SSHSIG") {
|
|
39
|
+
throw new Error(`Expected an SSHSIG signature, started with ${blob.subarray(0, 6).toString()}`);
|
|
40
|
+
}
|
|
41
|
+
// The magic, then a uint32 version, then the public key.
|
|
42
|
+
let publicKey = readSSHString(blob, 6 + 4);
|
|
43
|
+
let keyType = readSSHString(publicKey, 0).toString();
|
|
44
|
+
let fingerprint = "SHA256:" + crypto.createHash("sha256").update(publicKey).digest("base64").replace(/=+$/, "");
|
|
45
|
+
return { publicKey: `${keyType} ${publicKey.toString("base64")}`, fingerprint };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Reads one checkout's keys. Prefers a top level authorized_keys file and otherwise concatenates
|
|
49
|
+
every .pub at the top level. */
|
|
50
|
+
export async function readCheckoutKeys(repoPath: string) {
|
|
51
|
+
let combinedPath = path.join(repoPath, "authorized_keys");
|
|
52
|
+
if (await pathExists(combinedPath)) {
|
|
53
|
+
return normalizeKeys(await fs.readFile(combinedPath, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
let pubFiles = (await fs.readdir(repoPath)).filter(name => name.endsWith(".pub")).sort();
|
|
56
|
+
if (!pubFiles.length) {
|
|
57
|
+
throw new Error(`Expected authorized_keys or at least one .pub file in ${repoPath}, found neither`);
|
|
58
|
+
}
|
|
59
|
+
let keys: string[] = [];
|
|
60
|
+
for (let name of pubFiles) {
|
|
61
|
+
keys.push(...normalizeKeys(await fs.readFile(path.join(repoPath, name), "utf8")));
|
|
62
|
+
}
|
|
63
|
+
return keys;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every file in a checkout, other than git's own directory and the signature files, which cannot
|
|
67
|
+
describe themselves. */
|
|
68
|
+
async function listCheckoutFiles(repoPath: string, prefix?: string): Promise<string[]> {
|
|
69
|
+
let files: string[] = [];
|
|
70
|
+
for (let entry of await fs.readdir(path.join(repoPath, prefix || ""), { withFileTypes: true })) {
|
|
71
|
+
let relativePath = prefix && `${prefix}/${entry.name}` || entry.name;
|
|
72
|
+
if (entry.name === ".git") {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!prefix && (entry.name === MANIFEST_NAME || entry.name === SIGNATURE_NAME)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
files.push(...await listCheckoutFiles(repoPath, relativePath));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
files.push(relativePath);
|
|
83
|
+
}
|
|
84
|
+
return files.sort();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The signature only covers the manifest, so the manifest has to be checked against what is
|
|
88
|
+
actually on disk. Both directions matter: a missing file changes what the keys mean, and an
|
|
89
|
+
extra unlisted file could add keys nobody signed for. */
|
|
90
|
+
async function verifyManifestMatchesFiles(repoPath: string) {
|
|
91
|
+
let manifest = JSON.parse(await fs.readFile(path.join(repoPath, MANIFEST_NAME), "utf8"));
|
|
92
|
+
let listed = new Map<string, { path: string; size: number; sha256: string }>(
|
|
93
|
+
(manifest.files || []).map((file: { path: string }) => [file.path, file])
|
|
94
|
+
);
|
|
95
|
+
let actual = await listCheckoutFiles(repoPath);
|
|
96
|
+
|
|
97
|
+
let missing = [...listed.keys()].filter(filePath => !actual.includes(filePath));
|
|
98
|
+
if (missing.length) {
|
|
99
|
+
throw new Error(`Expected the signed files to be present, ${missing.length} missing, first ${missing[0]}`);
|
|
100
|
+
}
|
|
101
|
+
let extra = actual.filter(filePath => !listed.has(filePath));
|
|
102
|
+
if (extra.length) {
|
|
103
|
+
throw new Error(`Expected only signed files to be present, ${extra.length} extra, first ${extra[0]}`);
|
|
104
|
+
}
|
|
105
|
+
for (let filePath of actual) {
|
|
106
|
+
let expected = listed.get(filePath);
|
|
107
|
+
if (!expected) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
let contents = normalizeContent(await fs.readFile(path.join(repoPath, filePath)));
|
|
111
|
+
if (contents.length !== expected.size) {
|
|
112
|
+
throw new Error(`Expected ${filePath} to be ${expected.size} bytes, was ${contents.length}`);
|
|
113
|
+
}
|
|
114
|
+
let hash = crypto.createHash("sha256").update(contents).digest("hex");
|
|
115
|
+
if (hash !== expected.sha256) {
|
|
116
|
+
throw new Error(`Expected ${filePath} to hash to ${expected.sha256}, was ${hash}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The manifest and signature as they stand, whether or not they are any good. The hashes are
|
|
122
|
+
what tell a signature that was never updated apart from one that is broken. */
|
|
123
|
+
async function readSignatureFiles(repoPath: string) {
|
|
124
|
+
let manifestPath = path.join(repoPath, MANIFEST_NAME);
|
|
125
|
+
let signaturePath = path.join(repoPath, SIGNATURE_NAME);
|
|
126
|
+
let manifest = await pathExists(manifestPath) && await fs.readFile(manifestPath) || undefined;
|
|
127
|
+
let signature = await pathExists(signaturePath) && await fs.readFile(signaturePath) || undefined;
|
|
128
|
+
return {
|
|
129
|
+
manifest,
|
|
130
|
+
signature,
|
|
131
|
+
manifestHash: manifest && crypto.createHash("sha256").update(manifest).digest("hex") || "",
|
|
132
|
+
signatureHash: signature && crypto.createHash("sha256").update(signature).digest("hex") || "",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Who signed this checkout. Returns UNSIGNED when there is no signature at all, and throws when
|
|
137
|
+
there is one that does not hold up - an unverifiable signature is never treated as an identity,
|
|
138
|
+
so it can never become something we accept. */
|
|
139
|
+
async function verifyCheckoutSigner(config: {
|
|
140
|
+
repoPath: string;
|
|
141
|
+
files: { manifest?: Buffer; signature?: Buffer };
|
|
142
|
+
}) {
|
|
143
|
+
let { repoPath, files } = config;
|
|
144
|
+
if (!files.manifest && !files.signature) {
|
|
145
|
+
return UNSIGNED;
|
|
146
|
+
}
|
|
147
|
+
if (!files.manifest || !files.signature) {
|
|
148
|
+
throw new Error(`Expected both ${MANIFEST_NAME} and ${SIGNATURE_NAME}, only one is present`);
|
|
149
|
+
}
|
|
150
|
+
let result = await spawnPromise({
|
|
151
|
+
command: "ssh-keygen",
|
|
152
|
+
args: ["-Y", "check-novalidate", "-n", SIGN_NAMESPACE, "-s", path.join(repoPath, SIGNATURE_NAME)],
|
|
153
|
+
// The signature covers the normalised bytes, so a checkout that arrived with CRLF still
|
|
154
|
+
// verifies rather than looking like tampering.
|
|
155
|
+
input: normalizeContent(files.manifest).toString("utf8"),
|
|
156
|
+
});
|
|
157
|
+
if (result.status !== 0) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`the signature over ${MANIFEST_NAME} does not verify: `
|
|
160
|
+
+ `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
let reported = `${result.stdout} ${result.stderr}`.match(/(SHA256:[A-Za-z0-9+/=]+)/);
|
|
164
|
+
if (!reported) {
|
|
165
|
+
throw new Error(`ssh-keygen reported no signer fingerprint, said ${result.stdout.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
166
|
+
}
|
|
167
|
+
let { publicKey, fingerprint } = publicKeyFromSignature(files.signature.toString("utf8"));
|
|
168
|
+
// The key we read out of the signature has to be the one ssh-keygen just checked against,
|
|
169
|
+
// otherwise we would be reporting an identity that did not sign anything.
|
|
170
|
+
if (fingerprint !== reported[1]) {
|
|
171
|
+
throw new Error(`the signing key reads as ${fingerprint} but ssh-keygen verified ${reported[1]}`);
|
|
172
|
+
}
|
|
173
|
+
await verifyManifestMatchesFiles(repoPath);
|
|
174
|
+
return publicKey;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function describeSigner(signer: string) {
|
|
178
|
+
return signer === UNSIGNED && "<no public key>" || signer;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Reports a problem with a source's signature, but only when it is not the same problem we
|
|
182
|
+
already reported, so a fault that persists does not repeat every check. */
|
|
183
|
+
async function reportProblem(config: { repoURL: string; problem: string; message: string }) {
|
|
184
|
+
let { repoURL, problem, message } = config;
|
|
185
|
+
let sourceStateValue = sourceState(repoURL);
|
|
186
|
+
if (sourceStateValue.reportedProblem === problem) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
sourceStateValue.reportedProblem = problem;
|
|
190
|
+
await saveState();
|
|
191
|
+
await notify(message);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The keys a source is allowed to contribute right now. A source signed by someone we have not
|
|
195
|
+
accepted keeps contributing the keys we last accepted, until the delay has passed. */
|
|
196
|
+
export async function resolveSourceKeys(repoURL: string) {
|
|
197
|
+
let sourceStateValue = sourceState(repoURL);
|
|
198
|
+
let repoPath = sourceRepoPath(repoURL);
|
|
199
|
+
let files = await readSignatureFiles(repoPath);
|
|
200
|
+
|
|
201
|
+
let signer: string;
|
|
202
|
+
try {
|
|
203
|
+
signer = await verifyCheckoutSigner({ repoPath, files });
|
|
204
|
+
} catch (e) {
|
|
205
|
+
// Nothing here is trustworthy, so nothing here is used. Which of the two problems it is
|
|
206
|
+
// depends on whether the signature is simply the one we already accepted.
|
|
207
|
+
let unchanged = files.manifestHash === sourceStateValue.acceptedManifestHash
|
|
208
|
+
&& files.signatureHash === sourceStateValue.acceptedSignatureHash;
|
|
209
|
+
if (unchanged) {
|
|
210
|
+
await reportProblem({
|
|
211
|
+
repoURL,
|
|
212
|
+
problem: "stale",
|
|
213
|
+
message: `\`${repoURL}\` changed but its signature was not updated, so the changes are being`
|
|
214
|
+
+ ` ignored. Still using the keys signed by \`${describeSigner(sourceStateValue.acceptedSigner)}\`.`
|
|
215
|
+
+ `\nTo deploy the change, run this in that repo:\n\`\`\`\n${SIGN_COMMAND}\n\`\`\``,
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
await reportProblem({
|
|
219
|
+
repoURL,
|
|
220
|
+
problem: "corrupt",
|
|
221
|
+
message: `\`${repoURL}\` has a corrupted signature, so its contents are being ignored:`
|
|
222
|
+
+ ` ${e}.\nStill using the keys signed by \`${describeSigner(sourceStateValue.acceptedSigner)}\`.`
|
|
223
|
+
+ `\nTo replace it, run this in that repo:\n\`\`\`\n${SIGN_COMMAND}\n\`\`\``,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return sourceStateValue.acceptedKeys;
|
|
227
|
+
}
|
|
228
|
+
if (sourceStateValue.reportedProblem) {
|
|
229
|
+
sourceStateValue.reportedProblem = "";
|
|
230
|
+
await saveState();
|
|
231
|
+
}
|
|
232
|
+
let checkoutKeys = await readCheckoutKeys(repoPath);
|
|
233
|
+
|
|
234
|
+
let accept = async () => {
|
|
235
|
+
sourceStateValue.accepted = true;
|
|
236
|
+
sourceStateValue.acceptedSigner = signer;
|
|
237
|
+
sourceStateValue.acceptedKeys = checkoutKeys;
|
|
238
|
+
sourceStateValue.acceptedManifestHash = files.manifestHash;
|
|
239
|
+
sourceStateValue.acceptedSignatureHash = files.signatureHash;
|
|
240
|
+
sourceStateValue.pendingSigner = UNSIGNED;
|
|
241
|
+
sourceStateValue.pendingSince = 0;
|
|
242
|
+
await saveState();
|
|
243
|
+
return checkoutKeys;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Nothing has ever been accepted from this source, so this is what we start trusting.
|
|
247
|
+
if (!sourceStateValue.accepted) {
|
|
248
|
+
console.log(`Trusting ${repoURL} as signed by ${describeSigner(signer)}`);
|
|
249
|
+
return await accept();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (signer === sourceStateValue.acceptedSigner) {
|
|
253
|
+
// Back to the signer we already trust, so anything we were waiting on is moot.
|
|
254
|
+
if (sourceStateValue.pendingSince) {
|
|
255
|
+
console.log(`${repoURL} is signed by its accepted key again, dropping the pending change`);
|
|
256
|
+
}
|
|
257
|
+
return await accept();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Going from nothing to something signed is only ever an improvement, so it does not wait.
|
|
261
|
+
if (sourceStateValue.acceptedSigner === UNSIGNED) {
|
|
262
|
+
await notify(
|
|
263
|
+
`\`${repoURL}\` is now signed, by \`${signer}\`. It was not signed before, so its keys are`
|
|
264
|
+
+ ` being applied right away.`
|
|
265
|
+
);
|
|
266
|
+
return await accept();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A signer we have not accepted. Anything new restarts the wait, so publishing twice in a row
|
|
270
|
+
// gains an attacker nothing. pendingSince is what marks a wait as running, because an unsigned
|
|
271
|
+
// checkout is itself a signer value and cannot double as "nothing pending".
|
|
272
|
+
if (!sourceStateValue.pendingSince || signer !== sourceStateValue.pendingSigner) {
|
|
273
|
+
sourceStateValue.pendingSigner = signer;
|
|
274
|
+
sourceStateValue.pendingSince = Date.now();
|
|
275
|
+
await saveState();
|
|
276
|
+
await notify(
|
|
277
|
+
`\`${repoURL}\` is now signed by \`${describeSigner(signer)}\`, where it was signed by`
|
|
278
|
+
+ ` \`${describeSigner(sourceStateValue.acceptedSigner)}\`. Its keys are NOT being applied.`
|
|
279
|
+
+ ` If nothing changes they will be applied in 24 hours.`
|
|
280
|
+
);
|
|
281
|
+
return sourceStateValue.acceptedKeys;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (Date.now() - sourceStateValue.pendingSince < SIGNER_CHANGE_DELAY) {
|
|
285
|
+
return sourceStateValue.acceptedKeys;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Same new signer, 24 hours later, and nobody stopped it.
|
|
289
|
+
console.log(`Accepting ${describeSigner(signer)} for ${repoURL} after the ${SIGNER_CHANGE_DELAY}ms wait`);
|
|
290
|
+
return await accept();
|
|
291
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { summarizeKey } from "../authorizedKeys";
|
|
5
|
+
import { PASSWD_PATH, ROOT_AUTHORIZED_KEYS } from "./paths";
|
|
6
|
+
import { notify } from "./notify";
|
|
7
|
+
import { getState, saveState } from "./state";
|
|
8
|
+
import { readAuthorizedKeysFile } from "./rootKeys";
|
|
9
|
+
|
|
10
|
+
async function pathExists(filePath: string) {
|
|
11
|
+
try {
|
|
12
|
+
await fs.access(filePath);
|
|
13
|
+
return true;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function listUserAuthorizedKeyFiles() {
|
|
20
|
+
let entries: { name: string; filePath: string }[] = [];
|
|
21
|
+
let passwd = await fs.readFile(PASSWD_PATH, "utf8");
|
|
22
|
+
for (let line of passwd.split("\n")) {
|
|
23
|
+
let fields = line.split(":");
|
|
24
|
+
if (fields.length < 7) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let [name, , , , , home] = fields;
|
|
28
|
+
if (!home || !await pathExists(home)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let filePath = path.join(home, ".ssh", "authorized_keys");
|
|
32
|
+
if (filePath === ROOT_AUTHORIZED_KEYS) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
entries.push({ name, filePath });
|
|
36
|
+
}
|
|
37
|
+
return entries;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hashKeys(keys: string[]) {
|
|
41
|
+
return crypto.createHash("sha256").update(keys.join("\n")).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Seeds the per user hashes without reporting every existing file as a change. */
|
|
45
|
+
export async function seedUserKeys() {
|
|
46
|
+
let state = getState();
|
|
47
|
+
if (Object.keys(state.userKeyHashes).length) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (let entry of await listUserAuthorizedKeyFiles()) {
|
|
51
|
+
state.userKeyHashes[entry.name] = hashKeys(await readAuthorizedKeysFile(entry.filePath));
|
|
52
|
+
}
|
|
53
|
+
await saveState();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Root is enforced elsewhere, every other account is watched and reported on. */
|
|
57
|
+
export async function checkOtherUserKeys() {
|
|
58
|
+
let state = getState();
|
|
59
|
+
let hashes: { [name: string]: string } = {};
|
|
60
|
+
for (let entry of await listUserAuthorizedKeyFiles()) {
|
|
61
|
+
let keys = await readAuthorizedKeysFile(entry.filePath);
|
|
62
|
+
let hash = hashKeys(keys);
|
|
63
|
+
hashes[entry.name] = hash;
|
|
64
|
+
let previousHash = state.userKeyHashes[entry.name];
|
|
65
|
+
if (previousHash === undefined || previousHash === hash) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
await notify(
|
|
69
|
+
`authorized_keys for user \`${entry.name}\` changed (\`${entry.filePath}\`).`
|
|
70
|
+
+ ` portsecure does not manage this account, so the change was left in place.`
|
|
71
|
+
+ `\n\`\`\`\n${keys.map(summarizeKey).join("\n") || "(now empty)"}\n\`\`\``
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
state.userKeyHashes = hashes;
|
|
75
|
+
await saveState();
|
|
76
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule && mod.default) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true , configurable: true});
|
|
6
|
+
//exports.readRepoKeys = exports.summarizeKey = exports.keyRestriction = exports.keyFingerprint = exports.normalizeKeys = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
// PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of normalizeKeys,
|
|
11
|
+
// summarizeKey and readRepoKeys, so it can resolve the same keys with no dependencies. The two
|
|
12
|
+
// must agree on which keys a repo produces - if you change one, make the matching change in the
|
|
13
|
+
// other.
|
|
14
|
+
function normalizeKeys(contents) {
|
|
15
|
+
return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
16
|
+
}
|
|
17
|
+
exports.normalizeKeys = normalizeKeys;
|
|
18
|
+
/** The fingerprint ssh itself reports for a key, which is what the sshd log names and therefore
|
|
19
|
+
what a revocation is keyed by. Returns "" for a line that holds no key. */
|
|
20
|
+
function keyFingerprint(keyLine) {
|
|
21
|
+
let parts = keyLine.trim().split(/\s+/);
|
|
22
|
+
let typeIndex = parts.findIndex(part => /^(ssh-|ecdsa-|sk-)/.test(part));
|
|
23
|
+
let blob = typeIndex >= 0 && parts[typeIndex + 1] || "";
|
|
24
|
+
if (!blob) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
return "SHA256:" + crypto_1.default.createHash("sha256").update(Buffer.from(blob, "base64")).digest("base64").replace(/=+$/, "");
|
|
28
|
+
}
|
|
29
|
+
exports.keyFingerprint = keyFingerprint;
|
|
30
|
+
/** The addresses a key may be used from, which is the part of an authorized_keys line that
|
|
31
|
+
decides how much a stolen key is worth. A key with no restriction says so loudly. */
|
|
32
|
+
function keyRestriction(keyLine) {
|
|
33
|
+
let match = keyLine.match(/from="([^"]*)"/);
|
|
34
|
+
if (!match) {
|
|
35
|
+
return "ANY ADDRESS (no from= restriction)";
|
|
36
|
+
}
|
|
37
|
+
return match[1];
|
|
38
|
+
}
|
|
39
|
+
exports.keyRestriction = keyRestriction;
|
|
40
|
+
/** Enough to recognise whose key this is without printing the whole blob. */
|
|
41
|
+
function summarizeKey(keyLine) {
|
|
42
|
+
let parts = keyLine.trim().split(/\s+/);
|
|
43
|
+
let typeIndex = parts.findIndex(part => /^(ssh-|ecdsa-|sk-)/.test(part));
|
|
44
|
+
if (typeIndex < 0) {
|
|
45
|
+
return keyLine.slice(0, 60);
|
|
46
|
+
}
|
|
47
|
+
let type = parts[typeIndex];
|
|
48
|
+
let blob = parts[typeIndex + 1] || "";
|
|
49
|
+
let comment = parts.slice(typeIndex + 2).join(" ");
|
|
50
|
+
return `${type} ...${blob.slice(-12)}${comment && ` ${comment}` || ""}`;
|
|
51
|
+
}
|
|
52
|
+
exports.summarizeKey = summarizeKey;
|
|
53
|
+
/** Reads the authorized keys a repo checkout wants applied. Prefers a top level authorized_keys
|
|
54
|
+
file and otherwise concatenates every .pub at the top level. */
|
|
55
|
+
async function readRepoKeys(repoPath) {
|
|
56
|
+
let combinedPath = path_1.default.join(repoPath, "authorized_keys");
|
|
57
|
+
let entries = await promises_1.default.readdir(repoPath);
|
|
58
|
+
if (entries.includes("authorized_keys")) {
|
|
59
|
+
return normalizeKeys(await promises_1.default.readFile(combinedPath, "utf8"));
|
|
60
|
+
}
|
|
61
|
+
let pubFiles = entries.filter(name => name.endsWith(".pub")).sort();
|
|
62
|
+
if (!pubFiles.length) {
|
|
63
|
+
throw new Error(`Expected authorized_keys or at least one .pub file in ${repoPath}, found neither`);
|
|
64
|
+
}
|
|
65
|
+
let keys = [];
|
|
66
|
+
for (let name of pubFiles) {
|
|
67
|
+
keys.push(...normalizeKeys(await promises_1.default.readFile(path_1.default.join(repoPath, name), "utf8")));
|
|
68
|
+
}
|
|
69
|
+
return keys;
|
|
70
|
+
}
|
|
71
|
+
exports.readRepoKeys = readRepoKeys;
|
|
72
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXV0aG9yaXplZEtleXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJhdXRob3JpemVkS2V5cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7QUFBQSxvREFBNEI7QUFDNUIsMkRBQTZCO0FBQzdCLGdEQUF3QjtBQUV4Qiw2R0FBNkc7QUFDN0csK0ZBQStGO0FBQy9GLGdHQUFnRztBQUNoRyxTQUFTO0FBRVQsU0FBZ0IsYUFBYSxDQUFDLFFBQWdCO0lBQzFDLE9BQU8sUUFBUSxDQUFDLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLEVBQUUsQ0FBQyxJQUFJLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLEVBQUUsQ0FBQyxJQUFJLElBQUksQ0FBQyxJQUFJLENBQUMsVUFBVSxDQUFDLEdBQUcsQ0FBQyxDQUFDLENBQUM7QUFDdkcsQ0FBQztBQUZELHNDQUVDO0FBRUQ7OEVBQzhFO0FBQzlFLFNBQWdCLGNBQWMsQ0FBQyxPQUFlO0lBQzFDLElBQUksS0FBSyxHQUFHLE9BQU8sQ0FBQyxJQUFJLEVBQUUsQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDeEMsSUFBSSxTQUFTLEdBQUcsS0FBSyxDQUFDLFNBQVMsQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFDLG9CQUFvQixDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDO0lBQ3pFLElBQUksSUFBSSxHQUFHLFNBQVMsSUFBSSxDQUFDLElBQUksS0FBSyxDQUFDLFNBQVMsR0FBRyxDQUFDLENBQUMsSUFBSSxFQUFFLENBQUM7SUFDeEQsSUFBSSxDQUFDLElBQUksRUFBRSxDQUFDO1FBQ1IsT0FBTyxFQUFFLENBQUM7SUFDZCxDQUFDO0lBQ0QsT0FBTyxTQUFTLEdBQUcsZ0JBQU0sQ0FBQyxVQUFVLENBQUMsUUFBUSxDQUFDLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsSUFBSSxFQUFF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cHRvIGZyb20gXCJjcnlwdG9cIjtcbmltcG9ydCBmcyBmcm9tIFwiZnMvcHJvbWlzZXNcIjtcbmltcG9ydCBwYXRoIGZyb20gXCJwYXRoXCI7XG5cbi8vIFBPUlRFRCBDT0RFOiBzZWN1cml0eS9hdXRob3JpemVkS2V5cy9kYWVtb24vcG9ydHNlY3VyZURhZW1vbi5qcyBjb250YWlucyBhIHBsYWluIEpTIHBvcnQgb2Ygbm9ybWFsaXplS2V5cyxcbi8vIHN1bW1hcml6ZUtleSBhbmQgcmVhZFJlcG9LZXlzLCBzbyBpdCBjYW4gcmVzb2x2ZSB0aGUgc2FtZSBrZXlzIHdpdGggbm8gZGVwZW5kZW5jaWVzLiBUaGUgdHdvXG4vLyBtdXN0IGFncmVlIG9uIHdoaWNoIGtleXMgYSByZXBvIHByb2R1Y2VzIC0gaWYgeW91IGNoYW5nZSBvbmUsIG1ha2UgdGhlIG1hdGNoaW5nIGNoYW5nZSBpbiB0aGVcbi8vIG90aGVyLlxuXG5leHBvcnQgZnVuY3Rpb24gbm9ybWFsaXplS2V5cyhjb250ZW50czogc3RyaW5nKSB7XG4gICAgcmV0dXJuIGNvbnRlbnRzLnNwbGl0KFwiXFxuXCIpLm1hcChsaW5lID0+IGxpbmUudHJpbSgpKS5maWx0ZXIobGluZSA9PiBsaW5lICYmICFsaW5lLnN0YXJ0c1dpdGgoXCIjXCIpKTtcbn1cblxuLyoqIFRoZSBmaW5nZXJwcmludCBzc2ggaXRzZWxmIHJlcG9ydHMgZm9yIGEga2V5LCB3aGljaCBpcyB3aGF0IHRoZSBzc2hkIGxvZyBuYW1lcyBhbmQgdGhlcmVmb3JlXG4gICAgd2hhdCBhIHJldm9jYXRpb24gaXMga2V5ZWQgYnkuIFJldHVybnMgXCJcIiBmb3IgYSBsaW5lIHRoYXQgaG9sZHMgbm8ga2V5LiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGtleUZpbmdlcnByaW50KGtleUxpbmU6IHN0cmluZykge1xuICAgIGxldCBwYXJ0cyA9IGtleUxpbmUudHJpbSgpLnNwbGl0KC9cXHMrLyk7XG4gICAgbGV0IHR5cGVJbmRleCA9IHBhcnRzLmZpbmRJbmRleChwYXJ0ID0+IC9eKHNzaC18ZWNkc2EtfHNrLSkvLnRlc3QocGFydCkpO1xuICAgIGxldCBibG9iID0gdHlwZUluZGV4ID49IDAgJiYgcGFydHNbdHlwZUluZGV4ICsgMV0gfHwgXCJcIjtcbiAgICBpZiAoIWJsb2IpIHtcbiAgICAgICAgcmV0dXJuIFwiXCI7XG4gICAgfVxuICAgIHJldHVybiBcIlNIQTI1NjpcIiArIGNyeXB0by5jcmVhdGVIYXNoKFwic2hhMjU2XCIpLnVwZGF0ZShCdWZmZXIuZnJvbShibG9iLCBcImJhc2U2NFwiKSkuZGlnZXN0KFwiYmFzZTY0XCIpLnJlcGxhY2UoLz0rJC8sIFwiXCIpO1xufVxuXG4vKiogVGhlIGFkZHJlc3NlcyBhIGtleSBtYXkgYmUgdXNlZCBmcm9tLCB3aGljaCBpcyB0aGUgcGFydCBvZiBhbiBhdXRob3JpemVkX2tleXMgbGluZSB0aGF0XG4gICAgZGVjaWRlcyBob3cgbXVjaCBhIHN0b2xlbiBrZXkgaXMgd29ydGguIEEga2V5IHdpdGggbm8gcmVzdHJpY3Rpb24gc2F5cyBzbyBsb3VkbHkuICovXG5leHBvcnQgZnVuY3Rpb24ga2V5UmVzdHJpY3Rpb24oa2V5TGluZTogc3RyaW5nKSB7XG4gICAgbGV0IG1hdGNoID0ga2V5TGluZS5tYXRjaCgvZnJvbT1cIihbXlwiXSopXCIvKTtcbiAgICBpZiAoIW1hdGNoKSB7XG4gICAgICAgIHJldHVybiBcIkFOWSBBRERSRVNTIChubyBmcm9tPSByZXN0cmljdGlvbilcIjtcbiAgICB9XG4gICAgcmV0dXJuIG1hdGNoWzFdO1xufVxuXG4vKiogRW5vdWdoIHRvIHJlY29nbmlzZSB3aG9zZSBrZXkgdGhpcyBpcyB3aXRob3V0IHByaW50aW5nIHRoZSB3aG9sZSBibG9iLiAqL1xuZXhwb3J0IGZ1bmN0aW9uIHN1bW1hcml6ZUtleShrZXlMaW5lOiBzdHJpbmcpIHtcbiAgICBsZXQgcGFydHMgPSBrZXlMaW5lLnRyaW0oKS5zcGxpdCgvXFxzKy8pO1xuICAgIGxldCB0eXBlSW5kZXggPSBwYXJ0cy5maW5kSW5kZXgocGFydCA9PiAvXihzc2gtfGVjZHNhLXxzay0pLy50ZXN0KHBhcnQpKTtcbiAgICBpZiAodHlwZUluZGV4IDwgMCkge1xuICAgICAgICByZXR1cm4ga2V5TGluZS5zbGljZSgwLCA2MCk7XG4gICAgfVxuICAgIGxldCB0eXBlID0gcGFydHNbdHlwZUluZGV4XTtcbiAgICBsZXQgYmxvYiA9IHBhcnRzW3R5cGVJbmRleCArIDFdIHx8IFwiXCI7XG4gICAgbGV0IGNvbW1lbnQgPSBwYXJ0cy5zbGljZSh0eXBlSW5kZXggKyAyKS5qb2luKFwiIFwiKTtcbiAgICByZXR1cm4gYCR7dHlwZX0gLi4uJHtibG9iLnNsaWNlKC0xMil9JHtjb21tZW50ICYmIGAgJHtjb21tZW50fWAgfHwgXCJcIn1gO1xufVxuXG4vKiogUmVhZHMgdGhlIGF1dGhvcml6ZWQga2V5cyBhIHJlcG8gY2hlY2tvdXQgd2FudHMgYXBwbGllZC4gUHJlZmVycyBhIHRvcCBsZXZlbCBhdXRob3JpemVkX2tleXNcbiAgICBmaWxlIGFuZCBvdGhlcndpc2UgY29uY2F0ZW5hdGVzIGV2ZXJ5IC5wdWIgYXQgdGhlIHRvcCBsZXZlbC4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZWFkUmVwb0tleXMocmVwb1BhdGg6IHN0cmluZykge1xuICAgIGxldCBjb21iaW5lZFBhdGggPSBwYXRoLmpvaW4ocmVwb1BhdGgsIFwiYXV0aG9yaXplZF9rZXlzXCIpO1xuICAgIGxldCBlbnRyaWVzID0gYXdhaXQgZnMucmVhZGRpcihyZXBvUGF0aCk7XG4gICAgaWYgKGVudHJpZXMuaW5jbHVkZXMoXCJhdXRob3JpemVkX2tleXNcIikpIHtcbiAgICAgICAgcmV0dXJuIG5vcm1hbGl6ZUtleXMoYXdhaXQgZnMucmVhZEZpbGUoY29tYmluZWRQYXRoLCBcInV0ZjhcIikpO1xuICAgIH1cbiAgICBsZXQgcHViRmlsZXMgPSBlbnRyaWVzLmZpbHRlcihuYW1lID0+IG5hbWUuZW5kc1dpdGgoXCIucHViXCIpKS5zb3J0KCk7XG4gICAgaWYgKCFwdWJGaWxlcy5sZW5ndGgpIHtcbiAgICAgICAgdGhyb3cgbmV3IEVycm9yKGBFeHBlY3RlZCBhdXRob3JpemVkX2tleXMgb3IgYXQgbGVhc3Qgb25lIC5wdWIgZmlsZSBpbiAke3JlcG9QYXRofSwgZm91bmQgbmVpdGhlcmApO1xuICAgIH1cbiAgICBsZXQga2V5czogc3RyaW5nW10gPSBbXTtcbiAgICBmb3IgKGxldCBuYW1lIG9mIHB1YkZpbGVzKSB7XG4gICAgICAgIGtleXMucHVzaCguLi5ub3JtYWxpemVLZXlzKGF3YWl0IGZzLnJlYWRGaWxlKHBhdGguam9pbihyZXBvUGF0aCwgbmFtZSksIFwidXRmOFwiKSkpO1xuICAgIH1cbiAgICByZXR1cm4ga2V5cztcbn1cbiJdfQ==
|
|
73
|
+
/* _JS_SOURCE_HASH = "acd843c08a7ede6419089a90a105d6fbe56220931b66deb6b231678bdebe43cf"; */
|