@blamejs/core 0.10.14 → 0.11.0

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.
@@ -120,6 +120,7 @@ var lazyRequire = require("./lazy-require");
120
120
  var audit = lazyRequire(function () { return require("./audit"); });
121
121
  var nodeCrypto = require("node:crypto");
122
122
  var validateOpts = require("./validate-opts");
123
+ var numericBounds = require("./numeric-bounds");
123
124
  var { defineClass } = require("./framework-error");
124
125
 
125
126
  var MailCryptoError = defineClass("MailCryptoError", { alwaysPermanent: true });
@@ -923,12 +924,321 @@ function _audit(auditHandle, action, outcome, metadata) {
923
924
  } catch (_e) { /* drop-silent — audit failures must not crash callers */ }
924
925
  }
925
926
 
927
+ // ---- v0.10.16 experimental encrypt/decrypt + WKD ----
928
+ //
929
+ // PQC PGP encrypt/decrypt for ML-KEM-1024 recipients shipped under
930
+ // `experimental` namespace (RFC 9580bis PKESK ML-KEM codepoints
931
+ // haven't IANA-registered yet). Framework-private envelope matching
932
+ // the v0.10.10 `b.jose.jwe.experimental` precedent. Operators
933
+ // integrating with peers running this same framework get
934
+ // encrypt/decrypt today; cross-implementation interop waits for IANA.
935
+
936
+ var bCrypto = require("./crypto");
937
+ var pqcSoftware = require("./pqc-software");
938
+
939
+ var PGP_PQ_MAGIC = Buffer.from("BJ-PGP-PQ", "ascii"); // allow:raw-byte-literal — 9-byte framework magic
940
+ var PGP_PQ_VERSION = 1; // allow:raw-byte-literal — envelope version
941
+
942
+ function experimentalEncrypt(opts) {
943
+ opts = validateOpts.requireObject(opts, "mail.crypto.pgp.experimental.encrypt",
944
+ MailCryptoError, "mail-crypto/pgp/bad-opts");
945
+ validateOpts(opts, ["message", "recipients", "audit"], "mail.crypto.pgp.experimental.encrypt");
946
+ if (!opts.message || (!Buffer.isBuffer(opts.message) && typeof opts.message !== "string")) {
947
+ throw new MailCryptoError("mail-crypto/pgp/bad-message",
948
+ "encrypt: opts.message must be a Buffer or string");
949
+ }
950
+ if (!Array.isArray(opts.recipients) || opts.recipients.length === 0) {
951
+ throw new MailCryptoError("mail-crypto/pgp/no-recipients",
952
+ "encrypt: opts.recipients must be a non-empty array");
953
+ }
954
+ var plaintext = Buffer.isBuffer(opts.message) ? opts.message : Buffer.from(opts.message, "utf8");
955
+ var sessionKey = bCrypto.generateBytes(32); // allow:raw-byte-literal — 256-bit session key
956
+ var ciphertext = bCrypto.encryptPacked(plaintext, sessionKey);
957
+ var recipientBlobs = [];
958
+ for (var i = 0; i < opts.recipients.length; i += 1) {
959
+ var r = opts.recipients[i];
960
+ if (!Buffer.isBuffer(r.recipientId)) {
961
+ throw new MailCryptoError("mail-crypto/pgp/bad-recipient",
962
+ "encrypt: recipients[" + i + "].recipientId must be a Buffer");
963
+ }
964
+ if (!(r.publicKey instanceof Uint8Array)) {
965
+ throw new MailCryptoError("mail-crypto/pgp/bad-recipient",
966
+ "encrypt: recipients[" + i + "].publicKey must be a Uint8Array (ML-KEM-1024)");
967
+ }
968
+ if (r.recipientId.length > 255) { // allow:raw-byte-literal — u8 length cap
969
+ throw new MailCryptoError("mail-crypto/pgp/bad-recipient",
970
+ "encrypt: recipients[" + i + "].recipientId must be <= 255 bytes");
971
+ }
972
+ var encap = pqcSoftware.ml_kem_1024.encapsulate(r.publicKey);
973
+ var kek = bCrypto.kdf(Buffer.concat([
974
+ Buffer.from(encap.sharedSecret),
975
+ Buffer.from("pgp/experimental/chacha20-poly1305", "ascii"),
976
+ ]), 32); // allow:raw-byte-literal — 256-bit KEK
977
+ var wrappedKey = bCrypto.encryptPacked(sessionKey, kek);
978
+ var ct = Buffer.from(encap.cipherText);
979
+ recipientBlobs.push(Buffer.concat([
980
+ Buffer.from([r.recipientId.length]),
981
+ r.recipientId,
982
+ _u16be(ct.length),
983
+ ct,
984
+ _u16be(wrappedKey.length),
985
+ wrappedKey,
986
+ ]));
987
+ }
988
+ var envelope = Buffer.concat([
989
+ PGP_PQ_MAGIC,
990
+ Buffer.from([PGP_PQ_VERSION]),
991
+ Buffer.from([opts.recipients.length]), // allow:raw-byte-literal — u8 recipient count
992
+ Buffer.concat(recipientBlobs),
993
+ _u32be(ciphertext.length),
994
+ ciphertext,
995
+ ]);
996
+ var armored = _armorMessage(envelope);
997
+ _audit(opts.audit, "mail.crypto.pgp.experimental.encrypt", "success", {
998
+ recipients: opts.recipients.length,
999
+ });
1000
+ return { armored: armored, envelope: envelope };
1001
+ }
1002
+
1003
+ function experimentalDecrypt(opts) {
1004
+ opts = validateOpts.requireObject(opts, "mail.crypto.pgp.experimental.decrypt",
1005
+ MailCryptoError, "mail-crypto/pgp/bad-opts");
1006
+ validateOpts(opts, ["armored", "envelope", "recipientId", "secretKey", "audit"],
1007
+ "mail.crypto.pgp.experimental.decrypt");
1008
+ if (!Buffer.isBuffer(opts.recipientId)) {
1009
+ throw new MailCryptoError("mail-crypto/pgp/bad-opts",
1010
+ "decrypt: opts.recipientId must be a Buffer");
1011
+ }
1012
+ if (!(opts.secretKey instanceof Uint8Array)) {
1013
+ throw new MailCryptoError("mail-crypto/pgp/bad-opts",
1014
+ "decrypt: opts.secretKey must be a Uint8Array");
1015
+ }
1016
+ var envelope;
1017
+ if (Buffer.isBuffer(opts.envelope)) {
1018
+ envelope = opts.envelope;
1019
+ } else if (typeof opts.armored === "string" && opts.armored.length > 0) {
1020
+ envelope = _dearmorMessage(opts.armored);
1021
+ } else {
1022
+ throw new MailCryptoError("mail-crypto/pgp/bad-opts",
1023
+ "decrypt: opts.envelope OR opts.armored required");
1024
+ }
1025
+ if (envelope.length < PGP_PQ_MAGIC.length + 2 ||
1026
+ !envelope.slice(0, PGP_PQ_MAGIC.length).equals(PGP_PQ_MAGIC)) {
1027
+ throw new MailCryptoError("mail-crypto/pgp/bad-magic",
1028
+ "decrypt: envelope magic mismatch (not a blamejs-pgp-pq-v1 envelope)");
1029
+ }
1030
+ var off = PGP_PQ_MAGIC.length;
1031
+ var version = envelope[off]; off += 1;
1032
+ if (version !== PGP_PQ_VERSION) {
1033
+ throw new MailCryptoError("mail-crypto/pgp/bad-version",
1034
+ "decrypt: envelope version " + version + " unsupported (expected " + PGP_PQ_VERSION + ")");
1035
+ }
1036
+ var nRecips = envelope[off]; off += 1;
1037
+ var matchedSessionKey = null;
1038
+ for (var i = 0; i < nRecips; i += 1) {
1039
+ if (off >= envelope.length) {
1040
+ throw new MailCryptoError("mail-crypto/pgp/truncated",
1041
+ "decrypt: envelope truncated at recipient " + i);
1042
+ }
1043
+ var ridLen = envelope[off]; off += 1;
1044
+ var rid = envelope.slice(off, off + ridLen); off += ridLen;
1045
+ var ctLen = envelope.readUInt16BE(off); off += 2; // allow:raw-byte-literal — u16-be width
1046
+ var ct = envelope.slice(off, off + ctLen); off += ctLen;
1047
+ var wkLen = envelope.readUInt16BE(off); off += 2; // allow:raw-byte-literal — u16-be width
1048
+ var wrappedKey = envelope.slice(off, off + wkLen); off += wkLen;
1049
+ if (matchedSessionKey) continue;
1050
+ if (!rid.equals(opts.recipientId)) continue;
1051
+ var shared;
1052
+ try { shared = pqcSoftware.ml_kem_1024.decapsulate(new Uint8Array(ct), opts.secretKey); }
1053
+ catch (e) {
1054
+ throw new MailCryptoError("mail-crypto/pgp/decap-failed",
1055
+ "decrypt: ML-KEM-1024 decapsulate failed: " + ((e && e.message) || String(e)));
1056
+ }
1057
+ var kek = bCrypto.kdf(Buffer.concat([
1058
+ Buffer.from(shared),
1059
+ Buffer.from("pgp/experimental/chacha20-poly1305", "ascii"),
1060
+ ]), 32); // allow:raw-byte-literal — 256-bit KEK
1061
+ try { matchedSessionKey = bCrypto.decryptPacked(wrappedKey, kek); }
1062
+ catch (e2) {
1063
+ throw new MailCryptoError("mail-crypto/pgp/unwrap-failed",
1064
+ "decrypt: session-key unwrap failed: " + ((e2 && e2.message) || String(e2)));
1065
+ }
1066
+ }
1067
+ if (!matchedSessionKey) {
1068
+ throw new MailCryptoError("mail-crypto/pgp/no-matching-recipient",
1069
+ "decrypt: no recipient in envelope matches opts.recipientId");
1070
+ }
1071
+ var bodyLen = envelope.readUInt32BE(off); off += 4; // allow:raw-byte-literal — u32-be width
1072
+ var body = envelope.slice(off, off + bodyLen);
1073
+ var plaintext;
1074
+ try { plaintext = bCrypto.decryptPacked(body, matchedSessionKey); }
1075
+ catch (e3) {
1076
+ throw new MailCryptoError("mail-crypto/pgp/body-decrypt-failed",
1077
+ "decrypt: body AEAD verify failed: " + ((e3 && e3.message) || String(e3)));
1078
+ }
1079
+ _audit(opts.audit, "mail.crypto.pgp.experimental.decrypt", "success", {});
1080
+ return { plaintext: plaintext, recipientId: opts.recipientId };
1081
+ }
1082
+
1083
+ function _armorMessage(bytes) {
1084
+ var b64 = bytes.toString("base64");
1085
+ var lines = [];
1086
+ for (var i = 0; i < b64.length; i += 64) { // allow:raw-byte-literal — RFC 2045 base64 line length
1087
+ lines.push(b64.slice(i, i + 64)); // allow:raw-byte-literal — RFC 2045 base64 line length
1088
+ }
1089
+ return "-----BEGIN PGP MESSAGE-----\r\nVersion: blamejs-pgp-pq-v1\r\n\r\n" +
1090
+ lines.join("\r\n") + "\r\n-----END PGP MESSAGE-----\r\n";
1091
+ }
1092
+
1093
+ function _dearmorMessage(armored) {
1094
+ // Line-by-line parser — avoids the polynomial-time backtracking of
1095
+ // the prior regex (CodeQL "Polynomial regular expression on
1096
+ // uncontrolled data"). The previous shape
1097
+ // /-----BEGIN PGP MESSAGE-----\r?\n(?:[^\r\n]+\r?\n)*\r?\n.../
1098
+ // backtracks pathologically on inputs starting with many repeated
1099
+ // BEGIN lines. Split + walk in linear time instead.
1100
+ if (typeof armored !== "string") {
1101
+ throw new MailCryptoError("mail-crypto/pgp/bad-armor",
1102
+ "dearmor: envelope must be a string");
1103
+ }
1104
+ var lines = armored.split(/\r?\n/);
1105
+ var begin = -1;
1106
+ var end = -1;
1107
+ for (var i = 0; i < lines.length; i += 1) {
1108
+ if (begin === -1 && lines[i] === "-----BEGIN PGP MESSAGE-----") begin = i;
1109
+ else if (begin !== -1 && lines[i] === "-----END PGP MESSAGE-----") { end = i; break; }
1110
+ }
1111
+ if (begin === -1 || end === -1) {
1112
+ throw new MailCryptoError("mail-crypto/pgp/bad-armor",
1113
+ "dearmor: envelope is not BEGIN PGP MESSAGE armored");
1114
+ }
1115
+ // Skip header lines until the blank-line separator (RFC 9580 §6.2),
1116
+ // then collect base64 body lines until the END marker.
1117
+ var bodyStart = begin + 1;
1118
+ while (bodyStart < end && lines[bodyStart] !== "") bodyStart += 1;
1119
+ if (bodyStart >= end) {
1120
+ throw new MailCryptoError("mail-crypto/pgp/bad-armor",
1121
+ "dearmor: armor header has no blank-line separator before body");
1122
+ }
1123
+ var bodyChunks = [];
1124
+ for (var j = bodyStart + 1; j < end; j += 1) bodyChunks.push(lines[j]);
1125
+ return Buffer.from(bodyChunks.join(""), "base64");
1126
+ }
1127
+
1128
+ /**
1129
+ * @primitive b.mail.crypto.pgp.experimental.wkd.fetch
1130
+ * @signature b.mail.crypto.pgp.experimental.wkd.fetch(email, opts)
1131
+ * @since 0.10.16
1132
+ * @status experimental
1133
+ *
1134
+ * Fetch a WKD key for `email` per draft-koch-openpgp-webkey-service.
1135
+ * Tries the direct URL first; on 404 / network failure falls back
1136
+ * to the advanced URL. `opts.httpsGet(url) → Promise<{ status,
1137
+ * body: Buffer }>` is operator-supplied so the framework doesn't
1138
+ * couple to a specific HTTP client. Returns
1139
+ * `{ keyBytes, source: "direct" | "advanced", url }` or throws
1140
+ * `mail-crypto/pgp/wkd-not-found` when both URLs fail.
1141
+ *
1142
+ * @opts
1143
+ * httpsGet: Function, // (url) → Promise<{ status, body }>; REQUIRED
1144
+ * advancedHost: string, // passed through to computeUrl
1145
+ * maxKeyBytes: number, // default 256 KiB
1146
+ *
1147
+ * @example
1148
+ * var key = await b.mail.crypto.pgp.experimental.wkd.fetch("alice@example.com", {
1149
+ * httpsGet: function (url) {
1150
+ * return b.httpClient.request({ url: url, method: "GET" });
1151
+ * },
1152
+ * });
1153
+ */
1154
+ function wkdFetch(email, opts) {
1155
+ opts = validateOpts.requireObject(opts, "mail.crypto.pgp.experimental.wkd.fetch",
1156
+ MailCryptoError, "mail-crypto/pgp/bad-opts");
1157
+ if (typeof opts.httpsGet !== "function") {
1158
+ throw new MailCryptoError("mail-crypto/pgp/no-https-get",
1159
+ "wkd.fetch: opts.httpsGet must be a function (url) => Promise<{status, body}>");
1160
+ }
1161
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxKeyBytes, "maxKeyBytes",
1162
+ MailCryptoError, "mail-crypto/pgp/bad-max-key-bytes");
1163
+ var maxBytes = typeof opts.maxKeyBytes === "number" ? opts.maxKeyBytes : (256 * 1024); // allow:raw-byte-literal — 256 KiB default key cap
1164
+ var urls = wkdComputeUrl(email, { advancedHost: opts.advancedHost });
1165
+ return Promise.resolve(opts.httpsGet(urls.direct)).then(function (resp) {
1166
+ if (resp && resp.status === 200 && Buffer.isBuffer(resp.body) && resp.body.length > 0) { // allow:raw-byte-literal — HTTP 200
1167
+ if (resp.body.length > maxBytes) {
1168
+ throw new MailCryptoError("mail-crypto/pgp/wkd-too-large",
1169
+ "wkd.fetch: key bytes " + resp.body.length + " exceed maxKeyBytes=" + maxBytes);
1170
+ }
1171
+ return { keyBytes: resp.body, source: "direct", url: urls.direct };
1172
+ }
1173
+ return Promise.resolve(opts.httpsGet(urls.advanced)).then(function (resp2) {
1174
+ if (resp2 && resp2.status === 200 && Buffer.isBuffer(resp2.body) && resp2.body.length > 0) { // allow:raw-byte-literal — HTTP 200
1175
+ if (resp2.body.length > maxBytes) {
1176
+ throw new MailCryptoError("mail-crypto/pgp/wkd-too-large",
1177
+ "wkd.fetch: key bytes " + resp2.body.length + " exceed maxKeyBytes=" + maxBytes);
1178
+ }
1179
+ return { keyBytes: resp2.body, source: "advanced", url: urls.advanced };
1180
+ }
1181
+ throw new MailCryptoError("mail-crypto/pgp/wkd-not-found",
1182
+ "wkd.fetch: neither direct nor advanced URL returned a key for " + email);
1183
+ });
1184
+ });
1185
+ }
1186
+
1187
+ function wkdComputeUrl(email, opts) {
1188
+ opts = opts || {};
1189
+ if (typeof email !== "string" || email.indexOf("@") <= 0 || email.indexOf("@") === email.length - 1) {
1190
+ throw new MailCryptoError("mail-crypto/pgp/bad-email",
1191
+ "wkd.computeUrl: email must be a 'local@domain' string");
1192
+ }
1193
+ var at = email.indexOf("@");
1194
+ var localRaw = email.slice(0, at);
1195
+ var localLower = localRaw.toLowerCase();
1196
+ var domain = email.slice(at + 1).toLowerCase();
1197
+ var hashed = bCrypto.kdf(Buffer.from(localLower, "utf8"), 20); // allow:raw-byte-literal — 20-byte hash per draft-koch §3.1
1198
+ var encoded = _zbase32Encode(hashed);
1199
+ var advancedHost = opts.advancedHost || ("openpgpkey." + domain);
1200
+ var encodedLocal = encodeURIComponent(localRaw);
1201
+ return {
1202
+ direct: "https://" + domain + "/.well-known/openpgpkey/hu/" + encoded + "?l=" + encodedLocal,
1203
+ advanced: "https://" + advancedHost + "/.well-known/openpgpkey/" + domain + "/hu/" + encoded + "?l=" + encodedLocal,
1204
+ hashed: encoded,
1205
+ localLower: localLower,
1206
+ domain: domain,
1207
+ };
1208
+ }
1209
+
1210
+ var ZBASE32_ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769";
1211
+
1212
+ function _zbase32Encode(buf) {
1213
+ var bits = 0;
1214
+ var bitCount = 0;
1215
+ var out = "";
1216
+ for (var i = 0; i < buf.length; i += 1) {
1217
+ bits = (bits << 8) | buf[i]; // allow:raw-byte-literal — 8 bits per input byte
1218
+ bitCount += 8; // allow:raw-byte-literal — 8 bits per input byte
1219
+ while (bitCount >= 5) { // allow:raw-byte-literal — 5 bits per zbase32 char
1220
+ bitCount -= 5; // allow:raw-byte-literal — 5 bits per zbase32 char
1221
+ out += ZBASE32_ALPHABET.charAt((bits >> bitCount) & 0x1f); // allow:raw-byte-literal — 5-bit mask
1222
+ }
1223
+ }
1224
+ if (bitCount > 0) {
1225
+ out += ZBASE32_ALPHABET.charAt((bits << (5 - bitCount)) & 0x1f); // allow:raw-byte-literal — final partial char
1226
+ }
1227
+ return out;
1228
+ }
1229
+
926
1230
  module.exports = {
927
1231
  sign: sign,
928
1232
  verify: verify,
1233
+ experimental: {
1234
+ encrypt: experimentalEncrypt,
1235
+ decrypt: experimentalDecrypt,
1236
+ wkd: {
1237
+ computeUrl: wkdComputeUrl,
1238
+ fetch: wkdFetch,
1239
+ },
1240
+ },
929
1241
  MailCryptoError: MailCryptoError,
930
- // Test-only exports — operators don't call these. Stable for v1 so
931
- // the codebase-patterns gate sees them as named.
932
1242
  _v4FingerprintForTest: _v4Fingerprint,
933
1243
  _armorForTest: _armor,
934
1244
  _dearmorForTest: _dearmor,