@blamejs/core 0.18.55 → 0.18.57

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/CHANGELOG.md CHANGED
@@ -8,6 +8,64 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.57 (2026-08-28) — **A mail listener refused the key-agreement group TLS 1.3 makes mandatory, and negotiated a weaker one than it would have with no policy at all.** `b.mail.server.tls.context` applied the outbound key-agreement preference to a server context. A flat colon-separated list is an offer order to a client and an ACCEPT SET to a server, so the listener refused every group the list did not name — including `secp256r1`, which RFC 8446 §9.1 makes mandatory to implement. On an SMTP listener a refused handshake is not a downgrade to cleartext: the peer meets `530 5.7.0 Must issue a STARTTLS command first` on any retry and the message stays queued until it bounces.
12
+
13
+ The same flatness collapsed HelloRetryRequest ordering, so a peer offering `X25519:X25519MLKEM768` was served X25519 — while the same peer against a context with no policy at all was pulled up to the hybrid. Applying the list made the negotiated group worse than applying nothing.
14
+
15
+ Also: a TLS-RPT report is now sent to a receiver that publishes its `rua` address in the DNS absolute form, where it was silently never sent. **Fixed:** *A mail listener accepts every registered ECDHE group, and still prefers a post-quantum hybrid (#677)* — `b.network.tls.keyAgreementGroups` returns a flat colon-separated list. That is the right shape for a client, where node sends a key share for the first entry and takes the first mutually-supported group. OpenSSL 3.5 reads the same string on a server as the set of groups it will accept AT ALL, and only tuples separated by `/` as an order between them.
16
+
17
+ So the listener refused `secp256r1`, `secp384r1`, `secp521r1` and `X448` outright. RFC 8446 §9.1 makes `secp256r1` mandatory to implement and only recommends X25519, so a fully conforming peer with a NIST-only group policy could not complete a handshake at all — and on an SMTP listener that is lost mail rather than a downgrade, because the peer meets `530 5.7.0 Must issue a STARTTLS command first` on any cleartext retry.
18
+
19
+ It also lost post-quantum coverage, which is the reverse of the intent. A peer offering `X25519:X25519MLKEM768` was served X25519, where the same peer against a context with no list was pulled up to `X25519MLKEM768` by a HelloRetryRequest.
20
+
21
+ `b.network.tls.serverKeyAgreementGroups` is the server form, and `b.mail.server.tls.context` uses it. It emits tuples — every ML-KEM hybrid first, then every registered ECDHE curve — so the listener still prefers a hybrid and will spend a retry to reach one, while accepting any group RFC 8446 §4.2.7 registers. `SecP256r1MLKEM768` stays accepted, which the runtime's own server default refuses, so applying a policy still buys something. The finite-field `ffdhe*` groups remain absent deliberately: refusing those on a listener is a policy worth keeping, refusing registered ECDHE curves is not.
22
+
23
+ The interoperability curves widen the SHIPPED DEFAULT only. An operator who called `b.network.tls.pqc.setKeyShares` narrowed the policy deliberately — the FIPS example in that module drops the X25519-based groups — and their list is taken as written on a listener too. Adding the curves back there would have let a peer negotiate exactly what was excluded, with the restriction holding outbound and silently not holding inbound. A narrowed listener may refuse a conforming peer; that is a choice the operator made and can see, where the reverse would not be.
24
+
25
+ Every configured rank becomes its own tuple, because ordering exists only between tuples: two groups sharing one are a set the server takes in whatever order the handshake reaches them, so `["secp384r1", "X25519"]` packed together would negotiate X25519 despite secp384r1 ranking first.
26
+
27
+ An operator-supplied `ecdhCurve` is still honoured verbatim, and a malformed one still refused rather than replaced. · *A TLS-RPT report reaches a receiver that publishes its address absolutely (#676)* — `b.network.smtp.tlsRpt.submit` refused a `rua=mailto:` target written in the DNS absolute form, with `mailto: target is not a valid RFC 5322 addr-spec`.
28
+
29
+ That address is published by the receiving domain in its own `_smtp._tls` record. It is not input from the operator running the sender, and a peer spelling its hostname absolutely is using the form RFC 1035 §3.1 defines — a zone file writes it that way routinely. The effect was that a receiver asking to be told about TLS failures was never told, the report owed under RFC 8460 was not sent, and nothing signalled that it had been dropped.
30
+
31
+ One trailing dot is now folded off before the address is validated. That cannot turn a valid address into a different valid one, because a trailing dot is not legal in an addr-spec domain, so a name carrying one has exactly one reading. The result records `published` beside `to`, so an operator can see what the peer wrote and what was used. A doubled trailing dot is still refused. · *The mail TLS test file exits when it finishes* — It printed its OK line and then hung, because the handshake-measuring helper closed its two listeners but never destroyed the sockets they had already accepted — `server.close()` stops a listener accepting and leaves established connections alone. Four sockets survived each run and held the event loop open, so the file could not be used in a batch without stalling whatever invoked it.
32
+
33
+ - v0.18.56 (2026-08-27) — **A CSRF exemption turned on a header the attacker writes, and a certificate renewal reached four mail listeners but not the fifth.** `csrfProtect`'s `skipStateless` waived the token check for any request carrying an `Authorization` header. Presence is not authenticity: an attacker composing a cross-site request writes their own headers. Worse, the header says nothing about which credential authenticated the request, and `attachUser` with `tokenFrom: "both"` reads the cookie first — so a request carrying both was authenticated by exactly the ambient credential the gate protects, and skipped the gate on a header nobody read. The exemption now turns on the absence of a cookie, and it no longer waives the origin check.
34
+
35
+ The mail listeners had two cases of the same shape: a value read once at construction that the operator changes while the server runs. ManageSieve captured `opts.tlsContext`, so a certificate renewal reached its four siblings and not it — and nothing reported that, because the watcher fired and the context rebuilt exactly as expected. `mail.server.mx` captured `localDomains`, so a withdrawn domain kept drawing 250 at RCPT until a restart while every management surface agreed it was gone.
36
+
37
+ Also: an audit checkpoint read the chain tip outside the lock that makes appends atomic, so it could sign a counter and a hash that were never the tip together. **Changed:** *Vendored `@noble` cryptography refreshed* — `@noble/ciphers` and `@noble/curves` to 2.4.0, `@noble/post-quantum` to 0.7.1, `@noble/hashes` to 2.4.0. The four move together because 0.7.1 pins its siblings to exactly 2.4.0, which keeps the browser hash bundle and the copy inlined in the server's PQC bundle the same code.
38
+
39
+ No output bytes change on anything blamejs computes or has stored. ML-KEM, ML-DSA and SLH-DSA known-answer vectors are byte-identical across the bump, in both directions, and so are XChaCha20-Poly1305 ciphertexts; no constant, domain-separation string, nonce construction or serialization order moved. Nothing already encrypted, wrapped or signed needs re-doing.
40
+
41
+ Two behaviour changes are reachable only through the raw primitives `b.pqcSoftware` exposes, and are worth knowing if you call them directly. `prehash(shake256)` on the 128-bit parameter sets now throws rather than producing a non-conformant signature. And an options object with an unknown or misspelled key — or one that is a class instance rather than a plain object — is now refused instead of silently ignored. **Fixed:** *A checkpoint reads the chain tip under the lock that makes appends atomic (#673)* — A checkpoint is a signed statement about a specific tip, and the value of that signature is that the pair it names was real. The tip read that feeds one ran outside the mutex the chain writer holds across its own read-tip → insert, so it could land in the middle of an append and pair a counter with a hash that were never the tip together. The signature over that pair is valid, the arithmetic is self-consistent, and it describes a state the chain was never in.
42
+
43
+ The read now takes the same lock, through `withChainLock` on the writer handle. It is held for the read only: signing is post-quantum and slow, every concurrent append queues behind that lock, and holding it across the signature would charge that cost to unrelated writers without adding a guarantee — a checkpoint claims a prefix, not that nothing has been appended since. A lock is per chain key, so an append to a different partition is never blocked by one.
44
+
45
+ A second, unreachable insert path into `audit_log` is removed. It took no lock and computed no row hash, and a future reader finding two insert paths into the chain table would reasonably have assumed both were live. · *A certificate renewal reaches the ManageSieve listener (#671)* — `b.mail.server.mx`, `.submission`, `.imap` and `.pop3` read `opts.tlsContext` at the point they need a context, so a consumer supplying an accessor gets the renewed certificate on the next connection. `.managesieve` captured it at construction, calling that accessor exactly once and serving the boot certificate for the life of the process.
46
+
47
+ Certificates renew every 60 to 90 days on any automated CA, so the frozen one expires while every piece of surrounding evidence says rotation is working: the watcher fired, the context rebuilt, and it simply never reached this one listener. That is worse than a listener that never supported rotation, because nothing reports it.
48
+
49
+ It now reads the option per connection like its siblings.
50
+
51
+ The rotation example in `b.mail.server.tls.context` was also wrong. It showed `mx.replaceTlsContext(newCtx)`, a method no listener implements and none needs: pass `get tlsContext() { return tls.secureContext; }` and a reload reaches the next connection with nothing to swap. Passing `tlsContext: tls.secureContext` instead copies the context current at boot, which is the frozen form. · *The hosted-domain set can change while the server runs (#661)* — `b.mail.server.mx.create` captured `localDomains` at construction, so the answer to "do we host this domain" was frozen at boot. Hosting a domain is administrative state, not configuration — operators add and withdraw them while the process runs.
52
+
53
+ Withdrawing is the case that bit: the operator disables a domain, every management surface agrees it is gone, and the listener keeps answering 250 at RCPT for it until a restart, with nothing signalling that mail is still arriving. Adding is milder but still wrong — a new domain draws 550 5.7.1, which a sending queue reads as permanent and drops rather than retrying.
54
+
55
+ `localDomains` now also accepts a function, answered per recipient. The neighbouring `recipientPolicy` already was, which is what made the frozen half odd: two parts of one question answered a line apart with different currency. The array form is unchanged and still validated once at boot. A set held in one array and mutated in place — pushed on add, spliced on withdraw — is read correctly, because the result is cached on the set's contents rather than on the array's identity.
56
+
57
+ An entry a live set returns that `b.guardDomain` refuses is dropped, with an audit event naming it, rather than thrown on — this is the request path, and a throw there turns a typo in an admin form into a mail outage. Mail for that entry is refused; the rest of the set keeps serving. The same holds for a set that cannot be read at all, an entry that cannot be coerced to a string, and anything else the resolver may raise: the recipients are refused and the connection survives.
58
+
59
+ `authservId` follows the same set rather than pinning to whichever domain was first at boot, and is resolved once per message so the header written and the forged headers stripped always name the same identity. **Security:** *`csrfProtect` `skipStateless` turns on the absence of a cookie, not on a header (#663)* — The exemption exists because a request with no ambient credential cannot be forged on a victim's behalf: CSRF spends a cookie the browser attaches by itself. It tested for an `Authorization` header as well.
60
+
61
+ Presence is not authenticity. An attacker composing a cross-site request writes their own headers, so `Authorization: Bearer nonsense` met the condition by being typed. And the header says nothing about which credential authenticated the request: `b.middleware.attachUser` with `tokenFrom: "both"` reads the cookie FIRST, so a request carrying a session cookie and a junk bearer header was authenticated by exactly the ambient credential the gate protects, and skipped the gate because of a header nothing had read. The two middlewares disagreed about which credential was in play, and the disagreement is what opened it.
62
+
63
+ The test is now the absence of a Cookie header, and nothing else. A bearer client that also sends an unrelated cookie is validated like any other request; deciding otherwise needs the auth layer's verdict about which credential authenticated it, which header presence cannot supply.
64
+
65
+ The exemption also sits below the origin cross-check now instead of above it, so `checkOrigin` is no longer waived by it. A consumer that asked for an origin check asked for something the token compare does not provide, and there is no reading of "stateless" under which a cross-origin state change becomes acceptable.
66
+
67
+ **Upgrading.** A caller relying on the header to skip the token check now needs to send the token, or to send no cookie. This is the shape the option was documented to cover; the header was never evidence of anything.
68
+
11
69
  - v0.18.55 (2026-08-25) — **Opening an encrypted database twice destroyed the first process's data, and four mail-listener limits were documented but never applied.** `b.db.init` in encrypted mode reclaimed temporary working copies by filename alone, so a second process opening the same volume unlinked a running process's live database. On Linux the unlink succeeds against an open file, so the first process kept reading and writing normally while every flush from then on wrote nothing and reported success — losing everything back to its last flush, not merely the overlap. It does not reproduce on Windows, where unlinking an open file fails, so a deployment developed on a desktop and shipped to a container met it in production first.
12
70
 
13
71
  The mail listeners had a matching shape in smaller form: `minBytesPerSecond` was validated, defaulted and exposed as a getter no listener called, so a peer trickling a few bytes at a time held a connection and its slot in the per-address cap for as long as it liked. `mail.server.imap` released a peer's rate-limit slot on disconnect but never removed it from the live-connection set, so an unauthenticated peer could grow that set without bound by connecting and dropping. No listener set a ceiling of its own, leaving the per-address cap as the only limit and the process-wide total at that cap times however many addresses a caller could speak from.
package/NOTICE CHANGED
@@ -17,7 +17,7 @@ lib/vendor/MANIFEST.json.
17
17
 
18
18
  --------------------------------------------------------------------------------
19
19
  Component: @noble/ciphers
20
- Version: 2.3.0
20
+ Version: 2.4.0
21
21
  Source: https://github.com/paulmillr/noble-ciphers
22
22
  License: MIT
23
23
  Copyright: Copyright (c) 2023 Paul Miller (https://paulmillr.com)
@@ -26,7 +26,7 @@ Used for: XChaCha20-Poly1305 authenticated encryption (lib/crypto.js,
26
26
  Thank you to Paul Miller for the audited noble-ciphers suite.
27
27
  --------------------------------------------------------------------------------
28
28
  Component: @noble/curves
29
- Version: 2.3.0
29
+ Version: 2.4.0
30
30
  Source: https://github.com/paulmillr/noble-curves
31
31
  License: MIT
32
32
  Copyright: Copyright (c) 2022 Paul Miller (https://paulmillr.com)
@@ -36,7 +36,7 @@ Used for: RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF)
36
36
  audited noble-curves suite.
37
37
  --------------------------------------------------------------------------------
38
38
  Component: @noble/hashes
39
- Version: 2.3.0
39
+ Version: 2.4.0
40
40
  Source: https://github.com/paulmillr/noble-hashes
41
41
  License: MIT
42
42
  Copyright: Copyright (c) 2022 Paul Miller (https://paulmillr.com)
@@ -53,7 +53,7 @@ Used for: The client half of a hybrid exchange, as a browser build
53
53
  Miller for the audited noble-hashes suite.
54
54
  --------------------------------------------------------------------------------
55
55
  Component: @noble/post-quantum
56
- Version: 0.7.0
56
+ Version: 0.7.1
57
57
  Source: https://github.com/paulmillr/noble-post-quantum
58
58
  License: MIT
59
59
  Copyright: Copyright (c) 2024 Paul Miller (https://paulmillr.com)
package/README.md CHANGED
@@ -132,7 +132,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
132
132
  - CSP nonce — generated per request, merged into the CSP (`b.middleware.cspNonce`)
133
133
  - Fetch-metadata resource-isolation guard (`b.middleware.fetchMetadata`)
134
134
  - Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
135
- - CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips Authorization-header / cookieless requests, which are not CSRF-able (`b.middleware.csrfProtect`)
135
+ - CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips cookieless requests, which carry no ambient credential to abuse and so are not CSRF-able. A request that sends a cookie is validated whatever else it carries, and the Origin check is never skipped (`b.middleware.csrfProtect`)
136
136
  - CORS (W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt) and rate-limit are wired when configured via `middleware.cors` / `middleware.rateLimit`
137
137
  - `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
138
138
  - Every access-refusal layer takes a uniform `problemDetails: true` for an RFC 9457 `application/problem+json` body or `onDeny(req, res, info)` to render the refusal itself — so a service can standardize one error envelope across its API without working around hardcoded bodies (`b.problemDetails`)
@@ -314,15 +314,15 @@ All runtime dependencies are committed to the repo — no transitive npm install
314
314
  ```bash
315
315
  ./scripts/vendor-update.sh --check # see what's outdated
316
316
  ./scripts/vendor-update.sh --diff @noble/ciphers # see changelog before bumping
317
- ./scripts/vendor-update.sh @noble/ciphers 2.3.0 # bundle + commit a new version
317
+ ./scripts/vendor-update.sh @noble/ciphers 2.4.0 # bundle + commit a new version
318
318
  ```
319
319
 
320
320
  | Package | Version | Author | Purpose |
321
321
  |---|---|---|---|
322
- | [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD. Ships a browser (ESM) build beside the server one, built from the same install |
323
- | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
324
- | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.3.0 (bundles @noble/hashes 2.3.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
325
- | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.0 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.3.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
322
+ | [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.4.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD. Ships a browser (ESM) build beside the server one, built from the same install |
323
+ | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.4.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
324
+ | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.4.0 (bundles @noble/hashes 2.4.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
325
+ | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.1 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.4.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
326
326
  | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.31 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
327
327
  | [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
328
328
  | [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
package/lib/audit.js CHANGED
@@ -190,23 +190,12 @@ async function _readAuditRowHashAtCounter(counter) {
190
190
  );
191
191
  }
192
192
 
193
- async function _insertAuditRow(allCols, values) {
194
- // No retry non-idempotent. Timeout only. Map each column to its
195
- // positional value and bind as a row object (the unambiguous b.sql form;
196
- // a flat value array whose first element is a Buffer would be misread as
197
- // an array-of-rows). BARE logical table name clusterStorage rewrites.
198
- var rowObj = {};
199
- for (var i = 0; i < allCols.length; i++) rowObj[allCols[i]] = values[i];
200
- var built = sql.insert("audit_log", _sqlOpts())
201
- .columns(allCols)
202
- .values(rowObj)
203
- .toSql();
204
- return await safeAsync.withTimeout(
205
- clusterStorage.execute(built.sql, built.params),
206
- FRAMEWORK_SQL_TIMEOUT_MS,
207
- { name: "audit.insertRow" }
208
- );
209
- }
193
+ // A second insert path into audit_log used to live here, unreachable. It held
194
+ // this file's only sql.insert("audit_log") and nothing called it: every append
195
+ // goes through chain-writer, which is what serializes read-tip hash → insert.
196
+ // Removed rather than left, because a future reader finding two insert paths
197
+ // into the chain table would reasonably assume both were live, and the dead one
198
+ // took no lock and computed no row hash.
210
199
 
211
200
  var _CHECKPOINT_COLS = [
212
201
  "_id", "createdAt", "atMonotonicCounter", "atRowHash",
@@ -1090,13 +1079,26 @@ async function _checkpointOnDatabase(opts, dbGenAtEntry) {
1090
1079
  .orderBy("monotonicCounter", "desc")
1091
1080
  .limit(1)
1092
1081
  .toSql();
1093
- var tip = await safeAsync.withTimeout(
1094
- safeAsync.asyncRetry(function () {
1095
- return clusterStorage.executeOne(tipReadBuilt.sql, tipReadBuilt.params);
1096
- }),
1097
- FRAMEWORK_SQL_TIMEOUT_MS,
1098
- { name: "audit.checkpoint.readTip" }
1099
- );
1082
+ // Read the tip under the SAME lock append() holds across its own
1083
+ // read-tip → insert. A checkpoint is a signed statement about a tip, so it
1084
+ // has to observe one that existed: read unlocked, it can land mid-append and
1085
+ // pair a counter with a hash that were never the tip together, and the
1086
+ // signature over that pair is valid, self-consistent, and describes a state
1087
+ // the chain was never in.
1088
+ //
1089
+ // The lock covers the READ only. Signing is post-quantum and slow, and every
1090
+ // concurrent append queues behind this lock; holding it across the signature
1091
+ // would charge that to unrelated writers without adding anything, because a
1092
+ // checkpoint claims a prefix rather than claiming nothing was appended after.
1093
+ var tip = await _chainWriter.withChainLock(null, function () {
1094
+ return safeAsync.withTimeout(
1095
+ safeAsync.asyncRetry(function () {
1096
+ return clusterStorage.executeOne(tipReadBuilt.sql, tipReadBuilt.params);
1097
+ }),
1098
+ FRAMEWORK_SQL_TIMEOUT_MS,
1099
+ { name: "audit.checkpoint.readTip" }
1100
+ );
1101
+ });
1100
1102
 
1101
1103
  if (!tip) return null; // empty audit log; nothing to anchor
1102
1104
 
@@ -481,6 +481,23 @@ function create(opts) {
481
481
  table: table,
482
482
  chainKey: chainKey,
483
483
  append: append,
484
+ // Run `fn` under the same lock append() holds across read-tip → insert.
485
+ //
486
+ // A checkpoint signs a statement about the tip, so it has to observe a tip
487
+ // that actually existed. Reading it outside this lock can land in the
488
+ // middle of an append — between the row being written and the counter
489
+ // advancing — and pair a counter with a hash that were never the tip
490
+ // together. The signature over that pair is valid and self-consistent and
491
+ // describes a state the chain was never in.
492
+ //
493
+ // Callers hold it for the READ only and sign afterwards. The signature is
494
+ // post-quantum and slow, and every concurrent append waits on this lock;
495
+ // signing under it would charge that cost to unrelated writers for no
496
+ // added guarantee, since a checkpoint claims a prefix rather than claiming
497
+ // nothing has been appended since.
498
+ withChainLock: function (keyValue, fn) {
499
+ return _mutexFor(keyValue).runExclusive(fn);
500
+ },
484
501
  _resetForTest: _resetForTest,
485
502
  // Expose for diagnostic introspection — the lock for a given key (or the
486
503
  // single-chain lock when no chainKey is configured).
package/lib/constants.js CHANGED
@@ -279,6 +279,38 @@ var TLS_GROUP_PREFERENCE = Object.freeze([
279
279
 
280
280
  var TLS_GROUP_CURVE_STR = TLS_GROUP_PREFERENCE.join(":");
281
281
 
282
+ // ---- Inbound (server) accept-groups ----
283
+ //
284
+ // The list above is an OFFER ORDER, which is what a client needs. A server
285
+ // reads the same flat string as its ACCEPT SET: OpenSSL 3.5 treats a
286
+ // colon-separated list as the groups it will accept AT ALL, and only tuples
287
+ // separated by `/` as an order between them. Applying the outbound list to a
288
+ // listener therefore refused every group it did not name — including
289
+ // secp256r1, which RFC 8446 §9.1 makes mandatory to implement, so a fully
290
+ // conforming peer with a NIST-only policy could not complete a handshake. On
291
+ // an SMTP listener that is not a downgrade to classical: the peer meets
292
+ // `530 5.7.0 Must issue a STARTTLS command first` on any cleartext retry and
293
+ // the message stays queued until it bounces.
294
+ //
295
+ // It also lost post-quantum coverage, which is the reverse of the intent. A
296
+ // flat list collapses HelloRetryRequest ordering, so a peer offering
297
+ // `X25519:X25519MLKEM768` was served X25519, where the same peer against a
298
+ // context with NO list was pulled up to the hybrid by a retry.
299
+ //
300
+ // So the server form is tuples: the ACTIVE key-share preference first, then
301
+ // these — the ECDHE curves RFC 8446 §4.2.7 registers, as a second tuple the
302
+ // server will still accept but will spend a HelloRetryRequest to climb out of.
303
+ // The preference stays whatever the operator configured, including a
304
+ // `pqc.setKeyShares` that opts out of hybrids; what this adds is that a
305
+ // listener does not refuse a conforming peer outright.
306
+ //
307
+ // The finite-field ffdhe* groups are absent on purpose. Refusing those on a
308
+ // listener is a policy the framework keeps; refusing registered ECDHE curves
309
+ // on a listener whose whole job is accepting strangers is not.
310
+ var TLS_SERVER_FALLBACK_CURVES = Object.freeze([
311
+ "X25519", "X448", "secp256r1", "secp384r1", "secp521r1",
312
+ ]);
313
+
282
314
  // ---- RFC 8879 certificate compression ----
283
315
  // Every compression algorithm this runtime can decompress, in the order the
284
316
  // runtime reports them. Both halves of a TLS connection use the same list:
@@ -356,6 +388,7 @@ module.exports = {
356
388
  FORMAT: FORMAT,
357
389
  PQC_GROUPS: PQC_GROUPS,
358
390
  TLS_GROUP_PREFERENCE: TLS_GROUP_PREFERENCE,
391
+ TLS_SERVER_FALLBACK_CURVES: TLS_SERVER_FALLBACK_CURVES,
359
392
  TLS_GROUP_CURVE_STR: TLS_GROUP_CURVE_STR,
360
393
  TLS_CERT_COMPRESSION: TLS_CERT_COMPRESSION,
361
394
  VAULT_PREFIX: VAULT_PREFIX,
@@ -222,7 +222,16 @@ function create(opts) {
222
222
  var authConfig = opts.auth || null;
223
223
  var mailStore = opts.mailStore;
224
224
  var allowPlaintext = opts.allowPlaintext === true;
225
- var tlsContext = opts.tlsContext || null;
225
+
226
+ // Read at the point of use, never captured. A consumer wiring the watching
227
+ // loader from mail.server.tls.context installs an accessor on this option, and
228
+ // capturing it here would call that accessor exactly once and freeze whatever
229
+ // certificate was current at boot — for a listener whose siblings all rotate.
230
+ // Certificates renew every 60 to 90 days on any automated CA, so the frozen
231
+ // one expires while the operator's evidence says rotation is working: the
232
+ // watcher fired, the context rebuilt, and it simply never reached here. The
233
+ // four sibling listeners already read the option per connection.
234
+ function _tlsContext() { return opts.tlsContext || null; }
226
235
 
227
236
  // safeSieve cap matches the guard's per-profile script cap (the
228
237
  // guard caps the literal-byte announcement; safeSieve.parse caps
@@ -280,7 +289,7 @@ function create(opts) {
280
289
  _emitCapabilityBanner(state, socket);
281
290
  _writeOk(socket, greeting + " ready");
282
291
 
283
- if (allowPlaintext && !tlsContext) {
292
+ if (allowPlaintext && !_tlsContext()) {
284
293
  _emit("mail.server.managesieve.plaintext_warning",
285
294
  { connectionId: connectionId,
286
295
  remark: "allowPlaintext=true; no STARTTLS available — operators MUST gate at network layer" },
@@ -511,7 +520,7 @@ function create(opts) {
511
520
  } else {
512
521
  socket.write('"SASL" ""\r\n');
513
522
  }
514
- if (!state.tls && tlsContext) {
523
+ if (!state.tls && _tlsContext()) {
515
524
  socket.write('"STARTTLS"\r\n');
516
525
  }
517
526
  }
@@ -539,7 +548,8 @@ function create(opts) {
539
548
  _writeNo(socket, "STARTTLS only valid pre-AUTH (RFC 5804 §2.2)");
540
549
  return;
541
550
  }
542
- if (!tlsContext) {
551
+ var startTlsContext = _tlsContext();
552
+ if (!startTlsContext) {
543
553
  _writeNo(socket, "STARTTLS unavailable (listener configured with allowPlaintext=true and no tlsContext)");
544
554
  return;
545
555
  }
@@ -552,7 +562,7 @@ function create(opts) {
552
562
  mailServerTls.upgradeLineProtocol({
553
563
  state: state,
554
564
  socket: socket,
555
- secureContext: tlsContext,
565
+ secureContext: startTlsContext,
556
566
  idleTimeoutMs: idleTimeoutMs,
557
567
  clearFields: ["pendingLiteral", "pendingAuth"],
558
568
  drain: _drainBuffer,
@@ -157,6 +157,7 @@
157
157
 
158
158
  var net = require("node:net");
159
159
  var lazyRequire = require("./lazy-require");
160
+ var boundedMap = require("./bounded-map");
160
161
  var C = require("./constants");
161
162
  var numericBounds = require("./numeric-bounds");
162
163
  var safeAsync = require("./safe-async");
@@ -321,7 +322,7 @@ function _stripForgedAuthResults(messageBuf, authservId) {
321
322
  * greylist: b.mail.greylist.create(…), // optional gate — defer first-seen (ip, from, rcpt)
322
323
  * agent: b.mail.agent, // optional delivery handoff
323
324
  * relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
324
- * localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
325
+ * localDomains: [string]|fn, // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1). A function returning the array is answered per RCPT, for a hosted set the operator changes while the server runs; an entry it returns that b.guardDomain refuses is dropped with an audit event rather than throwing on the connection that happened to arrive.
325
326
  * recipientPolicy: function (ctx) → { ok } | { ok: false, reason }, // optional RCPT-time mailbox check; refuses 550 5.1.1, defers 451 when it throws
326
327
  * maxLineBytes: number, // default 1 KiB — per-command line cap
327
328
  * maxMessageBytes: number, // default 50 MiB — DATA body cap
@@ -332,7 +333,7 @@ function _stripForgedAuthResults(messageBuf, authservId) {
332
333
  * guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
333
334
  * mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
334
335
  * onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
335
- * authservId?: string, // RFC 8601 authserv-id; default localDomains[0]
336
+ * authservId?: string, // RFC 8601 authserv-id; defaults to the first local domain, re-read per message when localDomains is a function
336
337
  * dnsLookup?: function, // async (qname, type) override for SPF/DKIM/DMARC lookups
337
338
  * maxSignatures?: number, // DKIM verify cap (1-16)
338
339
  * clockSkewMs?: number, // DKIM timestamp skew tolerance
@@ -375,9 +376,11 @@ function create(opts) {
375
376
  // entirely, so the only way to get a listener was to get one that accepted
376
377
  // everything. An allowlist that disappears when it is empty is a firewall
377
378
  // rule set that opens when the last rule is deleted.
378
- if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains)) {
379
+ if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains) &&
380
+ typeof opts.localDomains !== "function") {
379
381
  throw new MailServerMxError("mail-server-mx/bad-opts",
380
- "mail.server.mx.create: localDomains must be an array of domain strings " +
382
+ "mail.server.mx.create: localDomains must be an array of domain strings, or a " +
383
+ "function returning one when the hosted set changes while the server runs " +
381
384
  "(an empty array means this server hosts no domains and refuses every recipient)");
382
385
  }
383
386
  if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
@@ -409,7 +412,22 @@ function create(opts) {
409
412
  var maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
410
413
  var maxRcptsPerMsg = opts.maxRcptsPerMessage || DEFAULT_MAX_RCPTS_PER_MESSAGE;
411
414
  var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
412
- var localDomains = (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
415
+ // The hosted-domain set is administrative state, not configuration: operators
416
+ // add and withdraw domains while the process runs, and that is an ordinary
417
+ // act rather than a reconfiguration. Captured once, a withdrawn domain kept
418
+ // drawing 250 at RCPT until a restart — with every management surface
419
+ // agreeing it was gone and nothing telling the operator mail was still
420
+ // arriving for it — while a newly added one drew 550 5.7.1, which a sending
421
+ // queue reads as "never retry" rather than "not yet".
422
+ //
423
+ // So it may be a function, answered per RCPT. The neighbouring
424
+ // recipientPolicy already is, which is what made the frozen half odd: the two
425
+ // parts of one question were answered a line apart with different currency.
426
+ // The array form is unchanged and still validated once at boot.
427
+ var localDomainsFn = typeof opts.localDomains === "function" ? opts.localDomains : null;
428
+ var localDomains = localDomainsFn
429
+ ? []
430
+ : (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
413
431
  var relayAllowedFor = (opts.relayAllowedFor || []).map(function (__e) {
414
432
  return (__e && typeof __e === "object" && !Array.isArray(__e))
415
433
  ? Object.assign({}, __e, { cidr: _normalizeRelayCidr(__e.cidr) })
@@ -501,7 +519,10 @@ function create(opts) {
501
519
  // Authentication-Results header. Defaults to the first local
502
520
  // domain; with neither, the header is skipped (the verdict
503
521
  // still reaches the agent handoff).
504
- authservId: ge.authservId || localDomains[0] || null,
522
+ // A getter, so a listener whose hosted set is a function does not pin the
523
+ // authserv-id to whichever domain happened to be first at boot. Freezing
524
+ // the object prevents redefining this, not invoking it.
525
+ get authservId() { return ge.authservId || _resolveLocalDomains()[0] || null; },
505
526
  dnsLookup: ge.dnsLookup || undefined,
506
527
  maxSignatures: ge.maxSignatures,
507
528
  clockSkewMs: ge.clockSkewMs,
@@ -559,6 +580,165 @@ function create(opts) {
559
580
  }
560
581
  }
561
582
 
583
+ // Resolve the hosted set at the point it is needed.
584
+ //
585
+ // The array form was checked at boot and cannot change, so it is returned as
586
+ // is. The function form is checked when it is read, because a set that can
587
+ // change can acquire a bad entry after boot — an IDN homograph typed into an
588
+ // admin form is exactly the case the boot check exists for, and it would
589
+ // otherwise reach RCPT unexamined.
590
+ //
591
+ // A bad entry is DROPPED rather than thrown on. This is the request path: a
592
+ // throw here would take down the connection that happened to arrive, and the
593
+ // operator would see a mail outage rather than a typo. Dropping refuses mail
594
+ // for the entry that failed and keeps serving the domains that passed, which
595
+ // is what an allowlist with one bad line should do. Each distinct bad value
596
+ // is reported once so it does not become a per-recipient log flood.
597
+ //
598
+ // The cache key is the CONTENT of the returned set, never its identity. An
599
+ // operator holding one array and mutating it — push on add, splice on
600
+ // withdraw — hands back the same object every time, which is the most
601
+ // ordinary way to keep this state. Keyed on identity, that array would be
602
+ // normalized once and then frozen: added domains refused forever and
603
+ // withdrawn ones accepted forever, which is this very bug wearing a hat.
604
+ var _ldSeenKey = null;
605
+ var _ldSeenOut = [];
606
+ // Bounded, because its keys come from the operator's live set and a control
607
+ // plane that churns tenants — or an admin form collecting typos — would
608
+ // otherwise grow this without limit for the life of the process, one entry
609
+ // per distinct bad value ever seen. It exists only to stop a per-recipient
610
+ // log flood, so evicting the oldest costs at most a repeated warning about a
611
+ // domain nobody has mentioned in a long time.
612
+ var _ldWarned = boundedMap.boundedMap({ maxEntries: 256, policy: "evict-oldest" });
613
+
614
+ // Warn once per distinct reason. getOrInsert runs the factory only when the
615
+ // key is absent, which is the whole "first time only" rule — a hand-rolled
616
+ // has-then-set says the same thing in two statements that can drift apart.
617
+ function _warnOnce(key, emitFn) {
618
+ boundedMap.getOrInsert(_ldWarned, key, function () { emitFn(); return true; });
619
+ }
620
+
621
+ // Backstop for the whole resolution, not any one operation inside it.
622
+ //
623
+ // Three separate throws were found here one at a time — the callback itself,
624
+ // serializing its result, coercing an entry — and each was guarded where it
625
+ // stood. The rule is what matters rather than the list: NOTHING in resolving
626
+ // an operator-supplied set may reach the RCPT handler as an exception, since
627
+ // there it ends the connection of whoever happened to be delivering. An
628
+ // unreadable set means no domains are known, and no domains known means every
629
+ // recipient is refused, which is what an empty hosted set already means.
630
+ function _resolveLocalDomains() {
631
+ try { return _resolveLocalDomainsInner(); }
632
+ catch (err) {
633
+ _warnOnce("__resolver", function () {
634
+ _emit("mail.server.mx.local_domains_unavailable",
635
+ { reason: "resolver-threw",
636
+ remark: String((err && err.message) || err).slice(0, 200) },
637
+ "warning");
638
+ });
639
+ return [];
640
+ }
641
+ }
642
+
643
+ function _resolveLocalDomainsInner() {
644
+ if (!localDomainsFn) return localDomains;
645
+ var raw;
646
+ try { raw = localDomainsFn(); }
647
+ catch (err) {
648
+ _warnOnce("__threw", function () {
649
+ _emit("mail.server.mx.local_domains_unavailable",
650
+ { reason: "threw", remark: String((err && err.message) || err).slice(0, 200) },
651
+ "warning");
652
+ });
653
+ return [];
654
+ }
655
+ if (!Array.isArray(raw)) {
656
+ _warnOnce("__shape", function () {
657
+ _emit("mail.server.mx.local_domains_unavailable",
658
+ { reason: "not-an-array", remark: "localDomains() returned " + (typeof raw) },
659
+ "warning");
660
+ });
661
+ return [];
662
+ }
663
+ // Coerce first, and key the cache on the COERCED strings.
664
+ //
665
+ // Keying on the raw array does not work: JSON.stringify is not injective
666
+ // with respect to the String() that follows it. Two objects with different
667
+ // toString() results both serialize as {}, and undefined, null and
668
+ // functions are indistinguishable inside an array — so two genuinely
669
+ // different hosted sets could share a key and the second one would be
670
+ // answered from the first one's cache. A withdrawn domain would stay
671
+ // accepted, which is the bug the callback exists to fix.
672
+ //
673
+ // Coercion is per ENTRY and guarded, because it runs with operator-supplied
674
+ // runtime data inside RCPT handling: an object whose toString throws must
675
+ // drop that entry, not end the connection that happened to arrive.
676
+ var coerced = [];
677
+ for (var i = 0; i < raw.length; i += 1) {
678
+ try {
679
+ coerced.push(String(raw[i]).toLowerCase());
680
+ } catch (entryErr) {
681
+ var entryIndex = i;
682
+ var entryReason = String((entryErr && entryErr.message) || entryErr).slice(0, 160);
683
+ _warnOnce("__entry" + entryIndex, function () {
684
+ _emit("mail.server.mx.local_domain_refused",
685
+ { domain: null, kind: "unreadable",
686
+ remark: "localDomains() entry " + entryIndex + " could not be read (" +
687
+ entryReason +
688
+ "); it is refused and the rest of the set still serves" },
689
+ "warning");
690
+ });
691
+ }
692
+ }
693
+
694
+ // Every element is a string now, so this key is injective over exactly the
695
+ // values the decision below is made from. Coercion is cheap and runs every
696
+ // time; what the cache buys is skipping a b.guardDomain call per entry per
697
+ // recipient, which is the expensive half.
698
+ var contentKey = JSON.stringify(coerced);
699
+ if (contentKey === _ldSeenKey) return _ldSeenOut;
700
+
701
+ // One bad entry drops and the rest of the set still serves, which is the
702
+ // same answer the guard gives for one it rejects on its merits.
703
+ var out = [];
704
+ for (var ci = 0; ci < coerced.length; ci += 1) {
705
+ var d = coerced[ci];
706
+ if (guardDomainProfile) {
707
+ var verdict;
708
+ try { verdict = guardDomain.validate(d, guardDomainProfile); }
709
+ catch (guardErr) {
710
+ var guardReason = String((guardErr && guardErr.message) || guardErr).slice(0, 160);
711
+ _warnOnce("__guard:" + d, function () {
712
+ _emit("mail.server.mx.local_domain_refused",
713
+ { domain: d, kind: "guard-threw",
714
+ remark: "b.guardDomain threw on this entry (" + guardReason +
715
+ "); it is refused and the rest of the set still serves" },
716
+ "warning");
717
+ });
718
+ continue;
719
+ }
720
+ if (!verdict.ok) {
721
+ var refusedDomain = d;
722
+ var refusedKind =
723
+ (verdict.issues && verdict.issues[0] && verdict.issues[0].kind) || null;
724
+ _warnOnce(refusedDomain, function () {
725
+ _emit("mail.server.mx.local_domain_refused",
726
+ { domain: refusedDomain,
727
+ kind: refusedKind,
728
+ remark: "rejected by b.guardDomain; mail for it is refused and the rest " +
729
+ "of the set still serves" },
730
+ "warning");
731
+ });
732
+ continue;
733
+ }
734
+ }
735
+ out.push(d);
736
+ }
737
+ _ldSeenKey = contentKey;
738
+ _ldSeenOut = out;
739
+ return out;
740
+ }
741
+
562
742
  var connections = new Set();
563
743
 
564
744
  var _emit = auditEmit.emit;
@@ -1068,7 +1248,7 @@ function create(opts) {
1068
1248
  // every recipient. An empty hosted set now refuses everything naturally,
1069
1249
  // which is what an empty allowlist has to mean; `relayAllowedFor` is
1070
1250
  // still the way to permit a scope deliberately.
1071
- if (localDomains.indexOf(rcptDomain) === -1 &&
1251
+ if (_resolveLocalDomains().indexOf(rcptDomain) === -1 &&
1072
1252
  !_isRelayAllowed(state.remoteAddress, rcpt)) {
1073
1253
  rateLimit.noteRcptFailure(state.remoteAddress);
1074
1254
  _trackRefusedRcpt(state, rcpt, "relay-denied");
@@ -1230,6 +1410,18 @@ function create(opts) {
1230
1410
  var inboundAuth = null;
1231
1411
  if (envelopeGate) {
1232
1412
  var inboundVerdict = null;
1413
+ // Resolve the authserv-id ONCE for this message and use that value for
1414
+ // both the header written and the forged headers stripped.
1415
+ //
1416
+ // It can now come from a hosted-domain callback, so two reads either
1417
+ // side of an await can disagree — and these two reads are exactly the
1418
+ // pair that must not. The strip removes sender-attached
1419
+ // Authentication-Results claiming this receiver's identity (RFC 8601
1420
+ // §5) before the computed one is prepended. Strip under the old id and
1421
+ // write under the new one, and a forged header claiming the id the
1422
+ // trusted verdict was written under survives next to it, which is the
1423
+ // shadowing this defense exists to prevent.
1424
+ var messageAuthservId = envelopeGate.authservId;
1233
1425
  try {
1234
1426
  // Wall-clock ceiling around the whole pipeline — a message
1235
1427
  // stuffed with signatures pointing at slow resolvers must
@@ -1245,7 +1437,7 @@ function create(opts) {
1245
1437
  maxSignatures: envelopeGate.maxSignatures,
1246
1438
  clockSkewMs: envelopeGate.clockSkewMs,
1247
1439
  minRsaBits: envelopeGate.minRsaBits,
1248
- authservId: envelopeGate.authservId || undefined,
1440
+ authservId: messageAuthservId || undefined,
1249
1441
  }),
1250
1442
  envelopeGate.timeoutMs,
1251
1443
  { name: "mail.server.mx.guardEnvelope" });
@@ -1317,7 +1509,7 @@ function create(opts) {
1317
1509
  // RFC 8601 §5 — strip any sender-attached A-R header
1318
1510
  // claiming this receiver's authserv-id before prepending
1319
1511
  // the computed one (forged-verdict shadowing defense).
1320
- dedotted = _stripForgedAuthResults(dedotted, envelopeGate.authservId);
1512
+ dedotted = _stripForgedAuthResults(dedotted, messageAuthservId);
1321
1513
  dedotted = Buffer.concat([
1322
1514
  Buffer.from(inboundVerdict.authResults + "\r\n", "utf8"),
1323
1515
  dedotted,