sliftutils 1.7.124 → 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/CLAUDE.md +3 -1
- package/bin/derivekey.js +10 -0
- package/bin/portsecuredaemon.js +13 -0
- package/bin/securessh.js +10 -0
- package/bin/setupnotify.js +9 -0
- package/bin/signfiles.js +10 -0
- package/bin/unrevoke.js +9 -0
- package/package.json +14 -3
- package/security/README.md +141 -0
- package/security/authorizedKeys/authorizedKeys.ts +66 -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 +19 -0
- 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 +613 -0
- package/security/authorizedKeys/sources.ts +20 -0
- 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/paths.ts +20 -0
- package/security/helpers/remoteSSH.ts +95 -0
- package/security/helpers/spawn.ts +36 -0
- 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/discord.ts +190 -0
- package/security/notifications/dist/discord.ts.cache +180 -0
- package/security/notifications/remoteWebhook.ts +85 -0
- package/security/notifications/setupNotify.ts +29 -0
- package/security/signedFiles/dist/manifest.ts.cache +68 -0
- package/security/signedFiles/dist/signFiles.ts.cache +146 -0
- package/security/signedFiles/manifest.ts +69 -0
- package/security/signedFiles/signFiles.ts +151 -0
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +17 -20
|
@@ -0,0 +1,153 @@
|
|
|
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.formatPublicKeyLine = exports.formatOpenSSHPrivateKey = exports.parseOpenSSHPrivateKey = exports.publicKeyFromSeed = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
// OpenSSH keeps private keys in its own container rather than anything node can read, so the few
|
|
9
|
+
// bytes of it we need are handled here. Only unencrypted ed25519 keys are supported, which is what
|
|
10
|
+
// a key used by automation is.
|
|
11
|
+
const MAGIC = "openssh-key-v1\0";
|
|
12
|
+
const KEY_TYPE = "ssh-ed25519";
|
|
13
|
+
const NONE = "none";
|
|
14
|
+
const SEED_LENGTH = 32;
|
|
15
|
+
const PUBLIC_LENGTH = 32;
|
|
16
|
+
// The one block size that matters here, since nothing is encrypted.
|
|
17
|
+
const PADDING_BLOCK = 8;
|
|
18
|
+
const PEM_LINE_LENGTH = 70;
|
|
19
|
+
const PRIVATE_HEADER = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
|
20
|
+
const PRIVATE_FOOTER = "-----END OPENSSH PRIVATE KEY-----";
|
|
21
|
+
// The prefix that turns a bare 32 byte seed into a pkcs8 ed25519 key node will accept.
|
|
22
|
+
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
23
|
+
class ByteReader {
|
|
24
|
+
constructor(buffer) {
|
|
25
|
+
this.buffer = buffer;
|
|
26
|
+
this.offset = 0;
|
|
27
|
+
}
|
|
28
|
+
readBytes(length) {
|
|
29
|
+
let value = this.buffer.subarray(this.offset, this.offset + length);
|
|
30
|
+
if (value.length !== length) {
|
|
31
|
+
throw new Error(`Expected ${length} bytes at offset ${this.offset}, was ${value.length}`);
|
|
32
|
+
}
|
|
33
|
+
this.offset += length;
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
readUInt32() {
|
|
37
|
+
let value = this.buffer.readUInt32BE(this.offset);
|
|
38
|
+
this.offset += 4;
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
readString() {
|
|
42
|
+
return this.readBytes(this.readUInt32());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function writeString(value) {
|
|
46
|
+
let contents = Buffer.isBuffer(value) && value || Buffer.from(value, "utf8");
|
|
47
|
+
let length = Buffer.alloc(4);
|
|
48
|
+
length.writeUInt32BE(contents.length);
|
|
49
|
+
return Buffer.concat([length, contents]);
|
|
50
|
+
}
|
|
51
|
+
function writeUInt32(value) {
|
|
52
|
+
let bytes = Buffer.alloc(4);
|
|
53
|
+
bytes.writeUInt32BE(value);
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
/** The public key of a seed, worked out by node rather than by reimplementing the curve. */
|
|
57
|
+
function publicKeyFromSeed(seed) {
|
|
58
|
+
if (seed.length !== SEED_LENGTH) {
|
|
59
|
+
throw new Error(`Expected a ${SEED_LENGTH} byte seed, was ${seed.length}`);
|
|
60
|
+
}
|
|
61
|
+
let privateKey = crypto_1.default.createPrivateKey({
|
|
62
|
+
key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),
|
|
63
|
+
format: "der",
|
|
64
|
+
type: "pkcs8",
|
|
65
|
+
});
|
|
66
|
+
// The spki wrapper is a fixed 12 byte header followed by the raw key.
|
|
67
|
+
return crypto_1.default.createPublicKey(privateKey).export({ format: "der", type: "spki" }).subarray(12);
|
|
68
|
+
}
|
|
69
|
+
exports.publicKeyFromSeed = publicKeyFromSeed;
|
|
70
|
+
function parseOpenSSHPrivateKey(contents) {
|
|
71
|
+
let body = contents.split("\n").filter(line => line.trim() && !line.startsWith("-----")).join("");
|
|
72
|
+
if (!contents.includes(PRIVATE_HEADER)) {
|
|
73
|
+
throw new Error(`Expected an OpenSSH private key starting with ${PRIVATE_HEADER}`);
|
|
74
|
+
}
|
|
75
|
+
let reader = new ByteReader(Buffer.from(body, "base64"));
|
|
76
|
+
if (reader.readBytes(MAGIC.length).toString() !== MAGIC) {
|
|
77
|
+
throw new Error(`Expected the key to start with ${JSON.stringify(MAGIC)}`);
|
|
78
|
+
}
|
|
79
|
+
let cipherName = reader.readString().toString();
|
|
80
|
+
let kdfName = reader.readString().toString();
|
|
81
|
+
reader.readString();
|
|
82
|
+
if (cipherName !== NONE || kdfName !== NONE) {
|
|
83
|
+
throw new Error(`Expected an unencrypted key, was encrypted with ${cipherName}/${kdfName}.`
|
|
84
|
+
+ ` Remove the passphrase with "ssh-keygen -p" first.`);
|
|
85
|
+
}
|
|
86
|
+
let keyCount = reader.readUInt32();
|
|
87
|
+
if (keyCount !== 1) {
|
|
88
|
+
throw new Error(`Expected the key file to hold one key, held ${keyCount}`);
|
|
89
|
+
}
|
|
90
|
+
reader.readString();
|
|
91
|
+
let privateSection = new ByteReader(reader.readString());
|
|
92
|
+
let checkOne = privateSection.readUInt32();
|
|
93
|
+
let checkTwo = privateSection.readUInt32();
|
|
94
|
+
if (checkOne !== checkTwo) {
|
|
95
|
+
throw new Error(`Expected the key's two check values to match, were ${checkOne} and ${checkTwo}`);
|
|
96
|
+
}
|
|
97
|
+
let keyType = privateSection.readString().toString();
|
|
98
|
+
if (keyType !== KEY_TYPE) {
|
|
99
|
+
throw new Error(`Expected an ${KEY_TYPE} key, was ${keyType}`);
|
|
100
|
+
}
|
|
101
|
+
privateSection.readString();
|
|
102
|
+
// OpenSSH stores the seed and the public key together in the private field.
|
|
103
|
+
let privateValue = privateSection.readString();
|
|
104
|
+
if (privateValue.length !== SEED_LENGTH + PUBLIC_LENGTH) {
|
|
105
|
+
throw new Error(`Expected a ${SEED_LENGTH + PUBLIC_LENGTH} byte private value, was ${privateValue.length}`);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
seed: privateValue.subarray(0, SEED_LENGTH),
|
|
109
|
+
publicKey: privateValue.subarray(SEED_LENGTH),
|
|
110
|
+
comment: privateSection.readString().toString(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
exports.parseOpenSSHPrivateKey = parseOpenSSHPrivateKey;
|
|
114
|
+
function formatOpenSSHPrivateKey(config) {
|
|
115
|
+
let { seed, comment } = config;
|
|
116
|
+
let publicKey = publicKeyFromSeed(seed);
|
|
117
|
+
let publicBlob = Buffer.concat([writeString(KEY_TYPE), writeString(publicKey)]);
|
|
118
|
+
// The check value only proves a passphrase decrypted correctly, and nothing here is encrypted.
|
|
119
|
+
// Taking it from the seed keeps the same input producing the same file, so deriving a key twice
|
|
120
|
+
// is something you can compare rather than something that looks different every time.
|
|
121
|
+
let check = crypto_1.default.createHash("sha256").update(seed).digest().readUInt32BE(0);
|
|
122
|
+
let privateSection = Buffer.concat([
|
|
123
|
+
writeUInt32(check),
|
|
124
|
+
writeUInt32(check),
|
|
125
|
+
writeString(KEY_TYPE),
|
|
126
|
+
writeString(publicKey),
|
|
127
|
+
writeString(Buffer.concat([seed, publicKey])),
|
|
128
|
+
writeString(comment),
|
|
129
|
+
]);
|
|
130
|
+
let paddingLength = (PADDING_BLOCK - privateSection.length % PADDING_BLOCK) % PADDING_BLOCK;
|
|
131
|
+
let padding = Buffer.from(Array.from({ length: paddingLength }, (unused, index) => index + 1));
|
|
132
|
+
let contents = Buffer.concat([
|
|
133
|
+
Buffer.from(MAGIC),
|
|
134
|
+
writeString(NONE),
|
|
135
|
+
writeString(NONE),
|
|
136
|
+
writeString(""),
|
|
137
|
+
writeUInt32(1),
|
|
138
|
+
writeString(publicBlob),
|
|
139
|
+
writeString(Buffer.concat([privateSection, padding])),
|
|
140
|
+
]);
|
|
141
|
+
let encoded = contents.toString("base64").match(new RegExp(`.{1,${PEM_LINE_LENGTH}}`, "g")) || [];
|
|
142
|
+
return `${PRIVATE_HEADER}\n${encoded.join("\n")}\n${PRIVATE_FOOTER}\n`;
|
|
143
|
+
}
|
|
144
|
+
exports.formatOpenSSHPrivateKey = formatOpenSSHPrivateKey;
|
|
145
|
+
/** The single line form, as it appears in authorized_keys and in a .pub file. */
|
|
146
|
+
function formatPublicKeyLine(config) {
|
|
147
|
+
let { publicKey, comment } = config;
|
|
148
|
+
let blob = Buffer.concat([writeString(KEY_TYPE), writeString(publicKey)]);
|
|
149
|
+
return `${KEY_TYPE} ${blob.toString("base64")}${comment && ` ${comment}` || ""}\n`;
|
|
150
|
+
}
|
|
151
|
+
exports.formatPublicKeyLine = formatPublicKeyLine;
|
|
152
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3NoS2V5RmlsZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInNzaEtleUZpbGUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7Ozs7O0FBQUEsb0RBQTRCO0FBRTVCLGlHQUFpRztBQUNqRyxtR0FBbUc7QUFDbkcsK0JBQStCO0FBRS9CLE1BQU0sS0FBSyxHQUFHLGtCQUFrQixDQUFDO0FBQ2pDLE1BQU0sUUFBUSxHQUFHLGFBQWEsQ0FBQztBQUMvQixNQUFNLElBQUksR0FBRyxNQUFNLENBQUM7QUFDcEIsTUFBTSxXQUFXLEdBQUcsRUFBRSxDQUFDO0FBQ3ZCLE1BQU0sYUFBYSxHQUFHLEVBQUUsQ0FBQztBQUN6QixvRUFBb0U7QUFDcEUsTUFBTSxhQUFhLEdBQUcsQ0FBQyxDQUFDO0FBQ3hCLE1BQU0sZUFBZSxHQUFHLEVBQUUsQ0FBQztBQUMzQixNQUFNLGNBQWMsR0FBRyxxQ0FBcUMsQ0FBQztBQUM3RCxNQUFNLGNBQWMsR0FBRyxtQ0FBbUMsQ0FBQztBQUMzRCx1RkFBdUY7QUFDdkYsTUFBTSxvQkFBb0IsR0FBRyxNQUFNLENBQUMsSUFBSSxDQUFDLGtDQUFrQyxFQUFFLEtBQUssQ0FBQyxDQUFDO0FBRXBGLE1BQU0sVUFBVTtJQUVaLFlBQW9CLE1BQWM7UUFBZCxXQUFNLEdBQU4sTUFBTSxDQUFRO1FBRDFCLFdBQU0sR0FBRyxDQUFDLENBQUM7SUFDbUIsQ0FBQztJQUV2QyxTQUFTLENBQUMsTUFBYztRQUNwQixJQUFJLEtBQUssR0FBRyxJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxJQUFJLENBQUMsTUFBTSxFQUFFLElBQUksQ0FBQyxNQUFNLEdBQUcsTUFBTSxDQUFDLENBQUM7UUFDcEUsSUFBSSxLQUFLLENBQUMsTUFBTSxLQUFLLE1BQU0sRUFBRSxDQUFDO1lBQzFCLE1BQU0sSUFBSSxLQUFLLENBQUMsWUFBWSxNQUFNLG9CQUFvQixJQUFJLENBQUMsTUFBTSxTQUFTLEtBQUssQ0FBQyxNQUFNLEVBQUUsQ0FBQyxDQUFD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ZUFBZSxHQUFHLEVBQUUsR0FBRyxDQUFDLENBQUMsSUFBSSxFQUFFLENBQUM7SUFDbEcsT0FBTyxHQUFHLGNBQWMsS0FBSyxPQUFPLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxLQUFLLGNBQWMsSUFBSSxDQUFDO0FBQzNFLENBQUM7QUEvQkQsMERBK0JDO0FBRUQsaUZBQWlGO0FBQ2pGLFNBQWdCLG1CQUFtQixDQUFDLE1BQThDO0lBQzlFLElBQUksRUFBRSxTQUFTLEVBQUUsT0FBTyxFQUFFLEdBQUcsTUFBTSxDQUFDO0lBQ3BDLElBQUksSUFBSSxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUMsQ0FBQyxXQUFXLENBQUMsUUFBUSxDQUFDLEVBQUUsV0FBVyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsQ0FBQztJQUMxRSxPQUFPLEdBQUcsUUFBUSxJQUFJLElBQUksQ0FBQyxRQUFRLENBQUMsUUFBUSxDQUFDLEdBQUcsT0FBTyxJQUFJLElBQUksT0FBTyxFQUFFLElBQUksRUFBRSxJQUFJLENBQUM7QUFDdkYsQ0FBQztBQUpELGtEQUlDIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IGNyeXB0byBmcm9tIFwiY3J5cHRvXCI7XG5cbi8vIE9wZW5TU0gga2VlcHMgcHJpdmF0ZSBrZXlzIGluIGl0cyBvd24gY29udGFpbmVyIHJhdGhlciB0aGFuIGFueXRoaW5nIG5vZGUgY2FuIHJlYWQsIHNvIHRoZSBmZXdcbi8vIGJ5dGVzIG9mIGl0IHdlIG5lZWQgYXJlIGhhbmRsZWQgaGVyZS4gT25seSB1bmVuY3J5cHRlZCBlZDI1NTE5IGtleXMgYXJlIHN1cHBvcnRlZCwgd2hpY2ggaXMgd2hhdFxuLy8gYSBrZXkgdXNlZCBieSBhdXRvbWF0aW9uIGlzLlxuXG5jb25zdCBNQUdJQyA9IFwib3BlbnNzaC1rZXktdjFcXDBcIjtcbmNvbnN0IEtFWV9UWVBFID0gXCJzc2gtZWQyNTUxOVwiO1xuY29uc3QgTk9ORSA9IFwibm9uZVwiO1xuY29uc3QgU0VFRF9MRU5HVEggPSAzMjtcbmNvbnN0IFBVQkxJQ19MRU5HVEggPSAzMjtcbi8vIFRoZSBvbmUgYmxvY2sgc2l6ZSB0aGF0IG1hdHRlcnMgaGVyZSwgc2luY2Ugbm90aGluZyBpcyBlbmNyeXB0ZWQuXG5jb25zdCBQQURESU5HX0JMT0NLID0gODtcbmNvbnN0IFBFTV9MSU5FX0xFTkdUSCA9IDcwO1xuY29uc3QgUFJJVkFURV9IRUFERVIgPSBcIi0tLS0tQkVHSU4gT1BFTlNTSCBQUklWQVRFIEtFWS0tLS0tXCI7XG5jb25zdCBQUklWQVRFX0ZPT1RFUiA9IFwiLS0tLS1FTkQgT1BFTlNTSCBQUklWQVRFIEtFWS0tLS0tXCI7XG4vLyBUaGUgcHJlZml4IHRoYXQgdHVybnMgYSBiYXJlIDMyIGJ5dGUgc2VlZCBpbnRvIGEgcGtjczggZWQyNTUxOSBrZXkgbm9kZSB3aWxsIGFjY2VwdC5cbmNvbnN0IFBLQ1M4X0VEMjU1MTlfUFJFRklYID0gQnVmZmVyLmZyb20oXCIzMDJlMDIwMTAwMzAwNTA2MDMyYjY1NzAwNDIyMDQyMFwiLCBcImhleFwiKTtcblxuY2xhc3MgQnl0ZVJlYWRlciB7XG4gICAgcHJpdmF0ZSBvZmZzZXQgPSAwO1xuICAgIGNvbnN0cnVjdG9yKHByaXZhdGUgYnVmZmVyOiBCdWZmZXIpIHsgfVxuXG4gICAgcmVhZEJ5dGVzKGxlbmd0aDogbnVtYmVyKSB7XG4gICAgICAgIGxldCB2YWx1ZSA9IHRoaXMuYnVmZmVyLnN1YmFycmF5KHRoaXMub2Zmc2V0LCB0aGlzLm9mZnNldCArIGxlbmd0aCk7XG4gICAgICAgIGlmICh2YWx1ZS5sZW5ndGggIT09IGxlbmd0aCkge1xuICAgICAgICAgICAgdGhyb3cgbmV3IEVycm9yKGBFeHBlY3RlZCAke2xlbmd0aH0gYnl0ZXMgYXQgb2Zmc2V0ICR7dGhpcy5vZmZzZXR9LCB3YXMgJHt2YWx1ZS5sZW5ndGh9YCk7XG4gICAgICAgIH1cbiAgICAgICAgdGhpcy5vZmZzZXQgKz0gbGVuZ3RoO1xuICAgICAgICByZXR1cm4gdmFsdWU7XG4gICAgfVxuXG4gICAgcmVhZFVJbnQzMigpIHtcbiAgICAgICAgbGV0IHZhbHVlID0gdGhpcy5idWZmZXIucmVhZFVJbnQzMkJFKHRoaXMub2Zmc2V0KTtcbiAgICAgICAgdGhpcy5vZmZzZXQgKz0gNDtcbiAgICAgICAgcmV0dXJuIHZhbHVlO1xuICAgIH1cblxuICAgIHJlYWRTdHJpbmcoKSB7XG4gICAgICAgIHJldHVybiB0aGlzLnJlYWRCeXRlcyh0aGlzLnJlYWRVSW50MzIoKSk7XG4gICAgfVxufVxuXG5mdW5jdGlvbiB3cml0ZVN0cmluZyh2YWx1ZTogQnVmZmVyIHwgc3RyaW5nKSB7XG4gICAgbGV0IGNvbnRlbnRzID0gQnVmZmVyLmlzQnVmZmVyKHZhbHVlKSAmJiB2YWx1ZSB8fCBCdWZmZXIuZnJvbSh2YWx1ZSwgXCJ1dGY4XCIpO1xuICAgIGxldCBsZW5ndGggPSBCdWZmZXIuYWxsb2MoNCk7XG4gICAgbGVuZ3RoLndyaXRlVUludDMyQkUoY29udGVudHMubGVuZ3RoKTtcbiAgICByZXR1cm4gQnVmZmVyLmNvbmNhdChbbGVuZ3RoLCBjb250ZW50c10pO1xufVxuXG5mdW5jdGlvbiB3cml0ZVVJbnQzMih2YWx1ZTogbnVtYmVyKSB7XG4gICAgbGV0IGJ5dGVzID0gQnVmZmVyLmFsbG9jKDQpO1xuICAgIGJ5dGVzLndyaXRlVUludDMyQkUodmFsdWUpO1xuICAgIHJldHVybiBieXRlcztcbn1cblxuLyoqIFRoZSBwdWJsaWMga2V5IG9mIGEgc2VlZCwgd29ya2VkIG91dCBieSBub2RlIHJhdGhlciB0aGFuIGJ5IHJlaW1wbGVtZW50aW5nIHRoZSBjdXJ2ZS4gKi9cbmV4cG9ydCBmdW5jdGlvbiBwdWJsaWNLZXlGcm9tU2VlZChzZWVkOiBCdWZmZXIpIHtcbiAgICBpZiAoc2VlZC5sZW5ndGggIT09IFNFRURfTEVOR1RIKSB7XG4gICAgICAgIHRocm93IG5ldyBFcnJvcihgRXhwZWN0ZWQgYSAke1NFRURfTEVOR1RIfSBieXRlIHNlZWQsIHdhcyAke3NlZWQubGVuZ3RofWApO1xuICAgIH1cbiAgICBsZXQgcHJpdmF0ZUtleSA9IGNyeXB0by5jcmVhdGVQcml2YXRlS2V5KHtcbiAgICAgICAga2V5OiBCdWZmZXIuY29uY2F0KFtQS0NTOF9FRDI1NTE5X1BSRUZJWCwgc2VlZF0pLFxuICAgICAgICBmb3JtYXQ6IFwiZGVyXCIsXG4gICAgICAgIHR5cGU6IFwicGtjczhcIixcbiAgICB9KTtcbiAgICAvLyBUaGUgc3BraSB3cmFwcGVyIGlzIGEgZml4ZWQgMTIgYnl0ZSBoZWFkZXIgZm9sbG93ZWQgYnkgdGhlIHJhdyBrZXkuXG4gICAgcmV0dXJuIGNyeXB0by5jcmVhdGVQdWJsaWNLZXkocHJpdmF0ZUtleSkuZXhwb3J0KHsgZm9ybWF0OiBcImRlclwiLCB0eXBlOiBcInNwa2lcIiB9KS5zdWJhcnJheSgxMik7XG59XG5cbmV4cG9ydCBmdW5jdGlvbiBwYXJzZU9wZW5TU0hQcml2YXRlS2V5KGNvbnRlbnRzOiBzdHJpbmcpIHtcbiAgICBsZXQgYm9keSA9IGNvbnRlbnRzLnNwbGl0KFwiXFxuXCIpLmZpbHRlcihsaW5lID0+IGxpbmUudHJpbSgpICYmICFsaW5lLnN0YXJ0c1dpdGgoXCItLS0tLVwiKSkuam9pbihcIlwiKTtcbiAgICBpZiAoIWNvbnRlbnRzLmluY2x1ZGVzKFBSSVZBVEVfSEVBREVSKSkge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYEV4cGVjdGVkIGFuIE9wZW5TU0ggcHJpdmF0ZSBrZXkgc3RhcnRpbmcgd2l0aCAke1BSSVZBVEVfSEVBREVSfWApO1xuICAgIH1cbiAgICBsZXQgcmVhZGVyID0gbmV3IEJ5dGVSZWFkZXIoQnVmZmVyLmZyb20oYm9keSwgXCJiYXNlNjRcIikpO1xuICAgIGlmIChyZWFkZXIucmVhZEJ5dGVzKE1BR0lDLmxlbmd0aCkudG9TdHJpbmcoKSAhPT0gTUFHSUMpIHtcbiAgICAgICAgdGhyb3cgbmV3IEVycm9yKGBFeHBlY3RlZCB0aGUga2V5IHRvIHN0YXJ0IHdpdGggJHtKU09OLnN0cmluZ2lmeShNQUdJQyl9YCk7XG4gICAgfVxuICAgIGxldCBjaXBoZXJOYW1lID0gcmVhZGVyLnJlYWRTdHJpbmcoKS50b1N0cmluZygpO1xuICAgIGxldCBrZGZOYW1lID0gcmVhZGVyLnJlYWRTdHJpbmcoKS50b1N0cmluZygpO1xuICAgIHJlYWRlci5yZWFkU3RyaW5nKCk7XG4gICAgaWYgKGNpcGhlck5hbWUgIT09IE5PTkUgfHwga2RmTmFtZSAhPT0gTk9ORSkge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoXG4gICAgICAgICAgICBgRXhwZWN0ZWQgYW4gdW5lbmNyeXB0ZWQga2V5LCB3YXMgZW5jcnlwdGVkIHdpdGggJHtjaXBoZXJOYW1lfS8ke2tkZk5hbWV9LmBcbiAgICAgICAgICAgICsgYCBSZW1vdmUgdGhlIHBhc3NwaHJhc2Ugd2l0aCBcInNzaC1rZXlnZW4gLXBcIiBmaXJzdC5gXG4gICAgICAgICk7XG4gICAgfVxuICAgIGxldCBrZXlDb3VudCA9IHJlYWRlci5yZWFkVUludDMyKCk7XG4gICAgaWYgKGtleUNvdW50ICE9PSAxKSB7XG4gICAgICAgIHRocm93IG5ldyBFcnJvcihgRXhwZWN0ZWQgdGhlIGtleSBmaWxlIHRvIGhvbGQgb25lIGtleSwgaGVsZCAke2tleUNvdW50fWApO1xuICAgIH1cbiAgICByZWFkZXIucmVhZFN0cmluZygpO1xuXG4gICAgbGV0IHByaXZhdGVTZWN0aW9uID0gbmV3IEJ5dGVSZWFkZXIocmVhZGVyLnJlYWRTdHJpbmcoKSk7XG4gICAgbGV0IGNoZWNrT25lID0gcHJpdmF0ZVNlY3Rpb24ucmVhZFVJbnQzMigpO1xuICAgIGxldCBjaGVja1R3byA9IHByaXZhdGVTZWN0aW9uLnJlYWRVSW50MzIoKTtcbiAgICBpZiAoY2hlY2tPbmUgIT09IGNoZWNrVHdvKSB7XG4gICAgICAgIHRocm93IG5ldyBFcnJvcihgRXhwZWN0ZWQgdGhlIGtleSdzIHR3byBjaGVjayB2YWx1ZXMgdG8gbWF0Y2gsIHdlcmUgJHtjaGVja09uZX0gYW5kICR7Y2hlY2tUd299YCk7XG4gICAgfVxuICAgIGxldCBrZXlUeXBlID0gcHJpdmF0ZVNlY3Rpb24ucmVhZFN0cmluZygpLnRvU3RyaW5nKCk7XG4gICAgaWYgKGtleVR5cGUgIT09IEtFWV9UWVBFKSB7XG4gICAgICAgIHRocm93IG5ldyBFcnJvcihgRXhwZWN0ZWQgYW4gJHtLRVlfVFlQRX0ga2V5LCB3YXMgJHtrZXlUeXBlfWApO1xuICAgIH1cbiAgICBwcml2YXRlU2VjdGlvbi5yZWFkU3RyaW5nKCk7XG4gICAgLy8gT3BlblNTSCBzdG9yZXMgdGhlIHNlZWQgYW5kIHRoZSBwdWJsaWMga2V5IHRvZ2V0aGVyIGluIHRoZSBwcml2YXRlIGZpZWxkLlxuICAgIGxldCBwcml2YXRlVmFsdWUgPSBwcml2YXRlU2VjdGlvbi5yZWFkU3RyaW5nKCk7XG4gICAgaWYgKHByaXZhdGVWYWx1ZS5sZW5ndGggIT09IFNFRURfTEVOR1RIICsgUFVCTElDX0xFTkdUSCkge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYEV4cGVjdGVkIGEgJHtTRUVEX0xFTkdUSCArIFBVQkxJQ19MRU5HVEh9IGJ5dGUgcHJpdmF0ZSB2YWx1ZSwgd2FzICR7cHJpdmF0ZVZhbHVlLmxlbmd0aH1gKTtcbiAgICB9XG4gICAgcmV0dXJuIHtcbiAgICAgICAgc2VlZDogcHJpdmF0ZVZhbHVlLnN1YmFycmF5KDAsIFNFRURfTEVOR1RIKSxcbiAgICAgICAgcHVibGljS2V5OiBwcml2YXRlVmFsdWUuc3ViYXJyYXkoU0VFRF9MRU5HVEgpLFxuICAgICAgICBjb21tZW50OiBwcml2YXRlU2VjdGlvbi5yZWFkU3RyaW5nKCkudG9TdHJpbmcoKSxcbiAgICB9O1xufVxuXG5leHBvcnQgZnVuY3Rpb24gZm9ybWF0T3BlblNTSFByaXZhdGVLZXkoY29uZmlnOiB7IHNlZWQ6IEJ1ZmZlcjsgY29tbWVudDogc3RyaW5nIH0pIHtcbiAgICBsZXQgeyBzZWVkLCBjb21tZW50IH0gPSBjb25maWc7XG4gICAgbGV0IHB1YmxpY0tleSA9IHB1YmxpY0tleUZyb21TZWVkKHNlZWQpO1xuICAgIGxldCBwdWJsaWNCbG9iID0gQnVmZmVyLmNvbmNhdChbd3JpdGVTdHJpbmcoS0VZX1RZUEUpLCB3cml0ZVN0cmluZyhwdWJsaWNLZXkpXSk7XG5cbiAgICAvLyBUaGUgY2hlY2sgdmFsdWUgb25seSBwcm92ZXMgYSBwYXNzcGhyYXNlIGRlY3J5cHRlZCBjb3JyZWN0bHksIGFuZCBub3RoaW5nIGhlcmUgaXMgZW5jcnlwdGVkLlxuICAgIC8vIFRha2luZyBpdCBmcm9tIHRoZSBzZWVkIGtlZXBzIHRoZSBzYW1lIGlucHV0IHByb2R1Y2luZyB0aGUgc2FtZSBmaWxlLCBzbyBkZXJpdmluZyBhIGtleSB0d2ljZVxuICAgIC8vIGlzIHNvbWV0aGluZyB5b3UgY2FuIGNvbXBhcmUgcmF0aGVyIHRoYW4gc29tZXRoaW5nIHRoYXQgbG9va3MgZGlmZmVyZW50IGV2ZXJ5IHRpbWUuXG4gICAgbGV0IGNoZWNrID0gY3J5cHRvLmNyZWF0ZUhhc2goXCJzaGEyNTZcIikudXBkYXRlKHNlZWQpLmRpZ2VzdCgpLnJlYWRVSW50MzJCRSgwKTtcbiAgICBsZXQgcHJpdmF0ZVNlY3Rpb24gPSBCdWZmZXIuY29uY2F0KFtcbiAgICAgICAgd3JpdGVVSW50MzIoY2hlY2spLFxuICAgICAgICB3cml0ZVVJbnQzMihjaGVjayksXG4gICAgICAgIHdyaXRlU3RyaW5nKEtFWV9UWVBFKSxcbiAgICAgICAgd3JpdGVTdHJpbmcocHVibGljS2V5KSxcbiAgICAgICAgd3JpdGVTdHJpbmcoQnVmZmVyLmNvbmNhdChbc2VlZCwgcHVibGljS2V5XSkpLFxuICAgICAgICB3cml0ZVN0cmluZyhjb21tZW50KSxcbiAgICBdKTtcbiAgICBsZXQgcGFkZGluZ0xlbmd0aCA9IChQQURESU5HX0JMT0NLIC0gcHJpdmF0ZVNlY3Rpb24ubGVuZ3RoICUgUEFERElOR19CTE9DSykgJSBQQURESU5HX0JMT0NLO1xuICAgIGxldCBwYWRkaW5nID0gQnVmZmVyLmZyb20oQXJyYXkuZnJvbSh7IGxlbmd0aDogcGFkZGluZ0xlbmd0aCB9LCAodW51c2VkLCBpbmRleCkgPT4gaW5kZXggKyAxKSk7XG5cbiAgICBsZXQgY29udGVudHMgPSBCdWZmZXIuY29uY2F0KFtcbiAgICAgICAgQnVmZmVyLmZyb20oTUFHSUMpLFxuICAgICAgICB3cml0ZVN0cmluZyhOT05FKSxcbiAgICAgICAgd3JpdGVTdHJpbmcoTk9ORSksXG4gICAgICAgIHdyaXRlU3RyaW5nKFwiXCIpLFxuICAgICAgICB3cml0ZVVJbnQzMigxKSxcbiAgICAgICAgd3JpdGVTdHJpbmcocHVibGljQmxvYiksXG4gICAgICAgIHdyaXRlU3RyaW5nKEJ1ZmZlci5jb25jYXQoW3ByaXZhdGVTZWN0aW9uLCBwYWRkaW5nXSkpLFxuICAgIF0pO1xuICAgIGxldCBlbmNvZGVkID0gY29udGVudHMudG9TdHJpbmcoXCJiYXNlNjRcIikubWF0Y2gobmV3IFJlZ0V4cChgLnsxLCR7UEVNX0xJTkVfTEVOR1RIfX1gLCBcImdcIikpIHx8IFtdO1xuICAgIHJldHVybiBgJHtQUklWQVRFX0hFQURFUn1cXG4ke2VuY29kZWQuam9pbihcIlxcblwiKX1cXG4ke1BSSVZBVEVfRk9PVEVSfVxcbmA7XG59XG5cbi8qKiBUaGUgc2luZ2xlIGxpbmUgZm9ybSwgYXMgaXQgYXBwZWFycyBpbiBhdXRob3JpemVkX2tleXMgYW5kIGluIGEgLnB1YiBmaWxlLiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGZvcm1hdFB1YmxpY0tleUxpbmUoY29uZmlnOiB7IHB1YmxpY0tleTogQnVmZmVyOyBjb21tZW50OiBzdHJpbmcgfSkge1xuICAgIGxldCB7IHB1YmxpY0tleSwgY29tbWVudCB9ID0gY29uZmlnO1xuICAgIGxldCBibG9iID0gQnVmZmVyLmNvbmNhdChbd3JpdGVTdHJpbmcoS0VZX1RZUEUpLCB3cml0ZVN0cmluZyhwdWJsaWNLZXkpXSk7XG4gICAgcmV0dXJuIGAke0tFWV9UWVBFfSAke2Jsb2IudG9TdHJpbmcoXCJiYXNlNjRcIil9JHtjb21tZW50ICYmIGAgJHtjb21tZW50fWAgfHwgXCJcIn1cXG5gO1xufVxuIl19
|
|
153
|
+
/* _JS_SOURCE_HASH = "57e4d1fd38c49a01bb35cf57a53a29f28eb2053b81167b87b4c17bab13251fae"; */
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
// OpenSSH keeps private keys in its own container rather than anything node can read, so the few
|
|
4
|
+
// bytes of it we need are handled here. Only unencrypted ed25519 keys are supported, which is what
|
|
5
|
+
// a key used by automation is.
|
|
6
|
+
|
|
7
|
+
const MAGIC = "openssh-key-v1\0";
|
|
8
|
+
const KEY_TYPE = "ssh-ed25519";
|
|
9
|
+
const NONE = "none";
|
|
10
|
+
const SEED_LENGTH = 32;
|
|
11
|
+
const PUBLIC_LENGTH = 32;
|
|
12
|
+
// The one block size that matters here, since nothing is encrypted.
|
|
13
|
+
const PADDING_BLOCK = 8;
|
|
14
|
+
const PEM_LINE_LENGTH = 70;
|
|
15
|
+
const PRIVATE_HEADER = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
|
16
|
+
const PRIVATE_FOOTER = "-----END OPENSSH PRIVATE KEY-----";
|
|
17
|
+
// The prefix that turns a bare 32 byte seed into a pkcs8 ed25519 key node will accept.
|
|
18
|
+
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
19
|
+
|
|
20
|
+
class ByteReader {
|
|
21
|
+
private offset = 0;
|
|
22
|
+
constructor(private buffer: Buffer) { }
|
|
23
|
+
|
|
24
|
+
readBytes(length: number) {
|
|
25
|
+
let value = this.buffer.subarray(this.offset, this.offset + length);
|
|
26
|
+
if (value.length !== length) {
|
|
27
|
+
throw new Error(`Expected ${length} bytes at offset ${this.offset}, was ${value.length}`);
|
|
28
|
+
}
|
|
29
|
+
this.offset += length;
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
readUInt32() {
|
|
34
|
+
let value = this.buffer.readUInt32BE(this.offset);
|
|
35
|
+
this.offset += 4;
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
readString() {
|
|
40
|
+
return this.readBytes(this.readUInt32());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeString(value: Buffer | string) {
|
|
45
|
+
let contents = Buffer.isBuffer(value) && value || Buffer.from(value, "utf8");
|
|
46
|
+
let length = Buffer.alloc(4);
|
|
47
|
+
length.writeUInt32BE(contents.length);
|
|
48
|
+
return Buffer.concat([length, contents]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeUInt32(value: number) {
|
|
52
|
+
let bytes = Buffer.alloc(4);
|
|
53
|
+
bytes.writeUInt32BE(value);
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The public key of a seed, worked out by node rather than by reimplementing the curve. */
|
|
58
|
+
export function publicKeyFromSeed(seed: Buffer) {
|
|
59
|
+
if (seed.length !== SEED_LENGTH) {
|
|
60
|
+
throw new Error(`Expected a ${SEED_LENGTH} byte seed, was ${seed.length}`);
|
|
61
|
+
}
|
|
62
|
+
let privateKey = crypto.createPrivateKey({
|
|
63
|
+
key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),
|
|
64
|
+
format: "der",
|
|
65
|
+
type: "pkcs8",
|
|
66
|
+
});
|
|
67
|
+
// The spki wrapper is a fixed 12 byte header followed by the raw key.
|
|
68
|
+
return crypto.createPublicKey(privateKey).export({ format: "der", type: "spki" }).subarray(12);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseOpenSSHPrivateKey(contents: string) {
|
|
72
|
+
let body = contents.split("\n").filter(line => line.trim() && !line.startsWith("-----")).join("");
|
|
73
|
+
if (!contents.includes(PRIVATE_HEADER)) {
|
|
74
|
+
throw new Error(`Expected an OpenSSH private key starting with ${PRIVATE_HEADER}`);
|
|
75
|
+
}
|
|
76
|
+
let reader = new ByteReader(Buffer.from(body, "base64"));
|
|
77
|
+
if (reader.readBytes(MAGIC.length).toString() !== MAGIC) {
|
|
78
|
+
throw new Error(`Expected the key to start with ${JSON.stringify(MAGIC)}`);
|
|
79
|
+
}
|
|
80
|
+
let cipherName = reader.readString().toString();
|
|
81
|
+
let kdfName = reader.readString().toString();
|
|
82
|
+
reader.readString();
|
|
83
|
+
if (cipherName !== NONE || kdfName !== NONE) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Expected an unencrypted key, was encrypted with ${cipherName}/${kdfName}.`
|
|
86
|
+
+ ` Remove the passphrase with "ssh-keygen -p" first.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
let keyCount = reader.readUInt32();
|
|
90
|
+
if (keyCount !== 1) {
|
|
91
|
+
throw new Error(`Expected the key file to hold one key, held ${keyCount}`);
|
|
92
|
+
}
|
|
93
|
+
reader.readString();
|
|
94
|
+
|
|
95
|
+
let privateSection = new ByteReader(reader.readString());
|
|
96
|
+
let checkOne = privateSection.readUInt32();
|
|
97
|
+
let checkTwo = privateSection.readUInt32();
|
|
98
|
+
if (checkOne !== checkTwo) {
|
|
99
|
+
throw new Error(`Expected the key's two check values to match, were ${checkOne} and ${checkTwo}`);
|
|
100
|
+
}
|
|
101
|
+
let keyType = privateSection.readString().toString();
|
|
102
|
+
if (keyType !== KEY_TYPE) {
|
|
103
|
+
throw new Error(`Expected an ${KEY_TYPE} key, was ${keyType}`);
|
|
104
|
+
}
|
|
105
|
+
privateSection.readString();
|
|
106
|
+
// OpenSSH stores the seed and the public key together in the private field.
|
|
107
|
+
let privateValue = privateSection.readString();
|
|
108
|
+
if (privateValue.length !== SEED_LENGTH + PUBLIC_LENGTH) {
|
|
109
|
+
throw new Error(`Expected a ${SEED_LENGTH + PUBLIC_LENGTH} byte private value, was ${privateValue.length}`);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
seed: privateValue.subarray(0, SEED_LENGTH),
|
|
113
|
+
publicKey: privateValue.subarray(SEED_LENGTH),
|
|
114
|
+
comment: privateSection.readString().toString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatOpenSSHPrivateKey(config: { seed: Buffer; comment: string }) {
|
|
119
|
+
let { seed, comment } = config;
|
|
120
|
+
let publicKey = publicKeyFromSeed(seed);
|
|
121
|
+
let publicBlob = Buffer.concat([writeString(KEY_TYPE), writeString(publicKey)]);
|
|
122
|
+
|
|
123
|
+
// The check value only proves a passphrase decrypted correctly, and nothing here is encrypted.
|
|
124
|
+
// Taking it from the seed keeps the same input producing the same file, so deriving a key twice
|
|
125
|
+
// is something you can compare rather than something that looks different every time.
|
|
126
|
+
let check = crypto.createHash("sha256").update(seed).digest().readUInt32BE(0);
|
|
127
|
+
let privateSection = Buffer.concat([
|
|
128
|
+
writeUInt32(check),
|
|
129
|
+
writeUInt32(check),
|
|
130
|
+
writeString(KEY_TYPE),
|
|
131
|
+
writeString(publicKey),
|
|
132
|
+
writeString(Buffer.concat([seed, publicKey])),
|
|
133
|
+
writeString(comment),
|
|
134
|
+
]);
|
|
135
|
+
let paddingLength = (PADDING_BLOCK - privateSection.length % PADDING_BLOCK) % PADDING_BLOCK;
|
|
136
|
+
let padding = Buffer.from(Array.from({ length: paddingLength }, (unused, index) => index + 1));
|
|
137
|
+
|
|
138
|
+
let contents = Buffer.concat([
|
|
139
|
+
Buffer.from(MAGIC),
|
|
140
|
+
writeString(NONE),
|
|
141
|
+
writeString(NONE),
|
|
142
|
+
writeString(""),
|
|
143
|
+
writeUInt32(1),
|
|
144
|
+
writeString(publicBlob),
|
|
145
|
+
writeString(Buffer.concat([privateSection, padding])),
|
|
146
|
+
]);
|
|
147
|
+
let encoded = contents.toString("base64").match(new RegExp(`.{1,${PEM_LINE_LENGTH}}`, "g")) || [];
|
|
148
|
+
return `${PRIVATE_HEADER}\n${encoded.join("\n")}\n${PRIVATE_FOOTER}\n`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The single line form, as it appears in authorized_keys and in a .pub file. */
|
|
152
|
+
export function formatPublicKeyLine(config: { publicKey: Buffer; comment: string }) {
|
|
153
|
+
let { publicKey, comment } = config;
|
|
154
|
+
let blob = Buffer.concat([writeString(KEY_TYPE), writeString(publicKey)]);
|
|
155
|
+
return `${KEY_TYPE} ${blob.toString("base64")}${comment && ` ${comment}` || ""}\n`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
|
|
3
|
+
// PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js embeds a hand port of this file in plain JS, so the
|
|
4
|
+
// daemon can run with no dependencies. The two are expected to behave identically - if you change
|
|
5
|
+
// one, make the matching change in the other.
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_WEBHOOK_FILE_PATH = "/etc/portsecure/discord-webhook";
|
|
8
|
+
const DEFAULT_CHECK_INTERVAL = 5 * 60 * 1000;
|
|
9
|
+
const VALID_WEBHOOK_PREFIXES = [
|
|
10
|
+
"https://discord.com/api/webhooks/",
|
|
11
|
+
"https://discordapp.com/api/webhooks/",
|
|
12
|
+
"https://canary.discord.com/api/webhooks/",
|
|
13
|
+
];
|
|
14
|
+
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
15
|
+
const MAX_ERROR_BODY_LENGTH = 500;
|
|
16
|
+
const MAX_SEND_ATTEMPTS = 3;
|
|
17
|
+
const REDACTED_TOKEN_VISIBLE = 8;
|
|
18
|
+
const DEFAULT_RATE_LIMIT_WAIT = 2 * 1000;
|
|
19
|
+
|
|
20
|
+
let notificationState: {
|
|
21
|
+
filePath: string;
|
|
22
|
+
webhookURL: string;
|
|
23
|
+
checkTimer: NodeJS.Timeout;
|
|
24
|
+
} | undefined;
|
|
25
|
+
|
|
26
|
+
// Discord webhooks are rate limited (roughly 5 requests per 2 seconds), so every send
|
|
27
|
+
// goes through one chain instead of racing.
|
|
28
|
+
let sendChain: Promise<unknown> = Promise.resolve();
|
|
29
|
+
|
|
30
|
+
/** Pulls the webhook URL out of webhook file contents. `sourceName` only appears in errors, so
|
|
31
|
+
callers can name a remote path. */
|
|
32
|
+
export function parseWebhookFile(config: { contents: string; sourceName: string }) {
|
|
33
|
+
let { contents, sourceName } = config;
|
|
34
|
+
let lines = contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
35
|
+
let webhookURL = lines[0];
|
|
36
|
+
if (!webhookURL) {
|
|
37
|
+
throw new Error(`Expected a Discord webhook URL in ${sourceName}, the file has no usable lines`);
|
|
38
|
+
}
|
|
39
|
+
if (!VALID_WEBHOOK_PREFIXES.some(prefix => webhookURL.startsWith(prefix))) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Expected a Discord webhook URL starting with one of ${VALID_WEBHOOK_PREFIXES.join(", ")}, `
|
|
42
|
+
+ `was ${webhookURL.slice(0, MAX_ERROR_BODY_LENGTH)} (in ${sourceName})`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return webhookURL;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Keeps the id and both ends of the token, so a reader can confirm which webhook replaced theirs
|
|
49
|
+
without receiving one they could post to. */
|
|
50
|
+
export function redactWebhookURL(webhookURL: string) {
|
|
51
|
+
let separatorIndex = webhookURL.lastIndexOf("/");
|
|
52
|
+
let base = webhookURL.slice(0, separatorIndex + 1);
|
|
53
|
+
let token = webhookURL.slice(separatorIndex + 1);
|
|
54
|
+
if (token.length <= REDACTED_TOKEN_VISIBLE * 2) {
|
|
55
|
+
// Too short to show both ends without giving away the whole token.
|
|
56
|
+
return `${base}${token.slice(0, REDACTED_TOKEN_VISIBLE)}...`;
|
|
57
|
+
}
|
|
58
|
+
return `${base}${token.slice(0, REDACTED_TOKEN_VISIBLE)}...${token.slice(-REDACTED_TOKEN_VISIBLE)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatWebhookFile(webhookURL: string) {
|
|
62
|
+
return `# portsecure Discord webhook. First non-comment line is used.\n${webhookURL}\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readWebhookFile(filePath: string) {
|
|
66
|
+
let contents;
|
|
67
|
+
try {
|
|
68
|
+
contents = await fs.readFile(filePath, "utf8");
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new Error(`Expected a readable Discord webhook file at ${filePath}, ${e}`);
|
|
71
|
+
}
|
|
72
|
+
return parseWebhookFile({ contents, sourceName: filePath });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function postToWebhook(webhookURL: string, message: string) {
|
|
76
|
+
let content = message;
|
|
77
|
+
if (content.length > DISCORD_MESSAGE_LIMIT) {
|
|
78
|
+
content = content.slice(0, DISCORD_MESSAGE_LIMIT - 3) + "...";
|
|
79
|
+
}
|
|
80
|
+
for (let attempt = 1; attempt <= MAX_SEND_ATTEMPTS; attempt++) {
|
|
81
|
+
let response = await fetch(webhookURL, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ content }),
|
|
85
|
+
});
|
|
86
|
+
if (response.ok) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let body = (await response.text()).slice(0, MAX_ERROR_BODY_LENGTH);
|
|
90
|
+
if (response.status === 429 && attempt < MAX_SEND_ATTEMPTS) {
|
|
91
|
+
let retryAfter = Number(response.headers.get("retry-after"));
|
|
92
|
+
let waitTime = retryAfter && retryAfter * 1000 || DEFAULT_RATE_LIMIT_WAIT;
|
|
93
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Expected a 2xx response from the Discord webhook, was ${response.status} ${response.statusText}, body ${body}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function queueSend(webhookURL: string, message: string) {
|
|
103
|
+
let send = () => postToWebhook(webhookURL, message);
|
|
104
|
+
let result = sendChain.then(send, send);
|
|
105
|
+
sendChain = result.catch(() => undefined);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Sends to an explicit webhook URL, for tooling that acts on a webhook before (or instead of)
|
|
110
|
+
configureDiscordNotifications - setup, migrations, connectivity checks. */
|
|
111
|
+
export function sendToWebhookURL(config: { webhookURL: string; message: string }) {
|
|
112
|
+
return queueSend(config.webhookURL, config.message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function checkWebhookFileChanged() {
|
|
116
|
+
let state = notificationState;
|
|
117
|
+
if (!state) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let newWebhookURL;
|
|
121
|
+
try {
|
|
122
|
+
newWebhookURL = await readWebhookFile(state.filePath);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error(`portsecure: Discord webhook file is no longer readable, still using the loaded webhook. ${e}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (newWebhookURL === state.webhookURL) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Warn the old channel first, with the new webhook redacted, so a stolen old webhook does not
|
|
131
|
+
// hand the attacker a usable new one.
|
|
132
|
+
try {
|
|
133
|
+
await queueSend(
|
|
134
|
+
state.webhookURL,
|
|
135
|
+
`**portsecure**: the Discord webhook in \`${state.filePath}\` changed to`
|
|
136
|
+
+ ` \`${redactWebhookURL(newWebhookURL)}\`.`
|
|
137
|
+
+ ` Notifications are moving to the new webhook and this channel will stop receiving them.`
|
|
138
|
+
);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error(`portsecure: failed to warn the old Discord webhook about the change. ${e}`);
|
|
141
|
+
}
|
|
142
|
+
state.webhookURL = newWebhookURL;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Must be called once on startup, before any notification is sent. Aborts the process if the
|
|
146
|
+
webhook file is missing or invalid, then re-checks the file on an interval and warns the old
|
|
147
|
+
webhook whenever it changes. */
|
|
148
|
+
export async function configureDiscordNotifications(config?: {
|
|
149
|
+
filePath?: string;
|
|
150
|
+
checkInterval?: number;
|
|
151
|
+
}) {
|
|
152
|
+
if (notificationState) {
|
|
153
|
+
throw new Error(`Expected configureDiscordNotifications to be called once, was called again (already using ${notificationState.filePath})`);
|
|
154
|
+
}
|
|
155
|
+
let filePath = config?.filePath || DEFAULT_WEBHOOK_FILE_PATH;
|
|
156
|
+
let checkInterval = config?.checkInterval || DEFAULT_CHECK_INTERVAL;
|
|
157
|
+
let webhookURL;
|
|
158
|
+
try {
|
|
159
|
+
webhookURL = await readWebhookFile(filePath);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error(`portsecure: refusing to start without a valid Discord webhook file.\n${e}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
let checkTimer = setInterval(() => {
|
|
165
|
+
checkWebhookFileChanged().catch(e => console.error(`portsecure: Discord webhook file check failed. ${e}`));
|
|
166
|
+
}, checkInterval);
|
|
167
|
+
// The check should never be the reason the process stays alive.
|
|
168
|
+
checkTimer.unref();
|
|
169
|
+
notificationState = { filePath, webhookURL, checkTimer };
|
|
170
|
+
return { filePath, checkInterval };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// DO NOT add new calls to this. Every message goes to a real Discord server someone reads, so a
|
|
174
|
+
// notification is only ever added when the user explicitly asks for that specific case. Startup,
|
|
175
|
+
// success, errors, retries and recoveries all belong in a log instead.
|
|
176
|
+
export async function sendDiscordNotification(message: string) {
|
|
177
|
+
let state = notificationState;
|
|
178
|
+
if (!state) {
|
|
179
|
+
throw new Error(`Expected configureDiscordNotifications to be called before sending, was called with message ${message.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
180
|
+
}
|
|
181
|
+
await queueSend(state.webhookURL, message);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function stopDiscordNotifications() {
|
|
185
|
+
if (!notificationState) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
clearInterval(notificationState.checkTimer);
|
|
189
|
+
notificationState = undefined;
|
|
190
|
+
}
|