crypto-policies: granular allowlist parser for Fedora system policies#10541
crypto-policies: granular allowlist parser for Fedora system policies#10541max-qlab wants to merge 6 commits into
Conversation
Introduces src/crypto_policy_granular.{c,h}, a self-contained parser
for the sectioned crypto-policies allowlist format. The vocabulary is
the one Fedora crypto-policies emits (the same shape the GnuTLS
back-end already uses, endorsed by @asosedkin in
fedora-crypto-policies wolfSSL#60 as the granular alternative to the
single-line @SECLEVEL=N:... cipher string the wolfSSL back-end was
rejected for in wolfSSL wolfSSL#8205).
The module is split in three responsibilities:
1. Header sniff (wolfSSL_crypto_policy_is_granular):
looks at the first non-blank, non-comment line. `version = `,
`override-mode = `, or a `[section]` header => granular file.
Anything else => legacy.
2. Parser (wolfSSL_crypto_policy_parse_granular):
fills a WolfGranularPolicy struct from a NUL-terminated buffer.
Bounded: 64 tokens per category, 48 bytes per token, 256-byte
line length, 1 MiB file ceiling. Strict on file format version
(only version=1 accepted); forward-tolerant on unknown vocabulary
tokens.
3. Apply (wolfSSL_crypto_policy_apply_granular):
drives the wolfSSL public API on a WOLFSSL_CTX from the parsed
struct. Five sites, in order:
- wolfSSL_CTX_SetMinVersion
- wolfSSL_CTX_set_cipher_list (cipher x kx x mac x version
cross-product against the known TLS suite table)
- wolfSSL_CTX_UseSupportedCurve (per enabled-group)
- wolfSSL_CTX_set1_sigalgs_list
- wolfSSL_CTX_SetMin{Rsa,Dh,Ecc}Key_Sz
SetMinVersion and set1_sigalgs_list are best-effort: a build
that lacks TLS 1.0 support or rejects an rsa_pss sigalg the
policy lists logs a warning and the remaining steps still
enforce the policy. Cipher list and key-size floors are
authoritative.
Standalone helpers (derive_cipher_list, derive_sigalgs_list,
min_version) are exposed WOLFSSL_LOCAL so the existing ssl.c parser
can be wired to them in the next commit without source-level
copy-paste.
src/include.am is updated to compile the new file under the existing
!BUILD_CRYPTONLY guard; the file's own contents are gated by
WOLFSSL_SYS_CRYPTO_POLICY so the build is a no-op when that flag is
not defined.
Mapping tables (versions, groups, sig schemes, suite table) follow
the OpenSSL/GnuTLS back-end naming for primitives; the cross-product
is restricted to the Fedora-relevant TLS suites and can be extended
incrementally as further policies enable additional primitives.
Co-Authored-By: Dominik Blain <dominik@qreativelab.io>
src/ssl.c gains four mechanical changes plus one safety guard so the
granular allowlist module from the previous commit installs itself
on every WOLFSSL_CTX. The legacy `@SECLEVEL=N:...` single-line code
path is preserved unchanged for existing deployments.
1. State extension
Two file-scope globals next to the existing
`static struct SystemCryptoPolicy crypto_policy;`:
static WolfGranularPolicy crypto_policy_gran;
static int crypto_policy_gran_enabled = 0;
static int crypto_policy_applying = 0;
`_gran` stores the parsed allowlist; `_gran_enabled` marks which
parser owns the active policy; `_applying` is the temporary
bypass used during the apply step (see point 5).
2. wolfSSL_crypto_policy_enable(file)
The file is read into a heap buffer (legacy 1024-byte ceiling
replaced by a 1 MiB ceiling; the legacy size check is
re-asserted only on the legacy code path). The header sniff
decides: granular header => parse with
wolfSSL_crypto_policy_parse_granular() into crypto_policy_gran,
mirror security_level into crypto_policy.secLevel (so existing
getters keep returning the right number), set enabled = 1 on
both, return. Legacy header => existing crypto_policy_parse()
path, unchanged.
3. wolfSSL_crypto_policy_enable_buffer(buf)
Same dispatch. The legacy MAX_WOLFSSL_CRYPTO_POLICY_SIZE check
now only applies to legacy-format buffers.
4. wolfSSL_crypto_policy_disable()
Zeroes both legacy and granular state.
5. wolfSSL_CTX_new_ex()
The block that loads the crypto-policy cipher list on a freshly
allocated CTX now branches on crypto_policy_gran_enabled:
- granular: call wolfSSL_crypto_policy_apply_granular(ctx, &gran),
wrapped in `crypto_policy_applying = 1; ... ; = 0;` so the
per-setter policy guards (point 6) let the applier through.
- legacy: unchanged AllocateCtxSuites + wolfSSL_parse_cipher_list.
6. wolfSSL_CTX_SetMinVersion() guard
The existing `if (crypto_policy.enabled) return CRYPTO_POLICY_FORBIDDEN`
guard at the top of wolfSSL_CTX_SetMinVersion() prevented the
granular applier from installing the policy's protocol floor.
The guard is widened to `crypto_policy.enabled && !crypto_policy_applying`
so application code is still blocked, but our apply step is not.
No other setter needs this treatment: the key-size guards
already let larger requested floors through (they only reject
weakening), and set_cipher_list / UseSupportedCurve /
set1_sigalgs_list have no policy guard.
After this change the build is still cipher-list compatible with
the legacy format and `make check` is green (see the test commit).
Co-Authored-By: Dominik Blain <dominik@qreativelab.io>
Adds end-to-end coverage of the granular allowlist code path
introduced in the previous two commits.
Test (tests/api.c, test_wolfSSL_crypto_policy_granular):
For LEGACY / DEFAULT / FUTURE the test:
- enables the policy via wolfSSL_crypto_policy_enable(file),
- asserts wolfSSL_crypto_policy_is_enabled() == 1,
- asserts wolfSSL_crypto_policy_get_level() returns the
policy's security-level (1 / 2 / 3),
- creates a WOLFSSL_CTX (must not be NULL, which would mean
the applier tore it down),
- counts the resolved cipher suites (must be > 0),
- asserts presence of TLS 1.3 / AES-256 family suites,
- asserts AES-128 is absent from FUTURE and present elsewhere.
Then two cross-policy invariants:
- suite-count monotonicity: LEGACY > FUTURE (FUTURE excludes
AES-128 so it must derive strictly fewer suites),
- a DTLS-only fixture must yield a usable DTLS CTX (guarded by
WOLFSSL_DTLS).
Then two format-validation cases via _enable_buffer:
- `version = 2` must be rejected outright,
- `override-mode = blocklist` must be rejected.
Fixtures (examples/crypto_policies/{legacy,default,future}/
wolfssl-allowlist.txt):
These are unmodified outputs of the Fedora crypto-policies
generator (the WolfSSLGenerator class). Checked in so the test
exercises the parser against the same bytes a Fedora install
would produce, not a hand-rolled approximation.
default/wolfssl-allowlist-dtls.txt is hand-crafted (the
DTLS-only slice of DEFAULT) since the upstream generator does
not currently emit a pure-DTLS file.
Format spec (examples/crypto_policies/README.md):
Documents the allowlist syntax, the relationship with the legacy
format, the five API sites the apply step drives, the
forward-compat rules (version=1 strict, vocabulary tokens
tolerant), and the relevant upstream tracking issues
(wolfSSL wolfSSL#9802, fedora-crypto-policies wolfSSL#60).
`make check` is green on three build configurations: default
(--enable-opensslextra --enable-tls13 --enable-arc4), production
no-debug, and --disable-dtls (the DTLS section of the test
auto-skips via #ifdef WOLFSSL_DTLS).
Co-Authored-By: Dominik Blain <dominik@qreativelab.io>
|
Can one of the admins verify this patch? |
|
Thank you @max-qlab, this is very cool! I'm reviewing now. |
There was a problem hiding this comment.
Pull request overview
Adds a new “granular allowlist” parser/applier for Fedora/RHEL system crypto-policies and routes wolfSSL_crypto_policy_enable*() between the legacy single-line @SECLEVEL=... format and the new sectioned allowlist format. This integrates policy application into WOLFSSL_CTX creation and adds API tests plus real Fedora-generated fixtures and a format README.
Changes:
- Introduces
src/crypto_policy_granular.{c,h}implementing allowlist parsing, mapping, and CTX application. - Updates
src/ssl.cto sniff/route formats and apply granular policy on each newWOLFSSL_CTX. - Adds
tests/api.ccoverage and ships example allowlist fixtures + format documentation.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
tests/api.c |
Adds granular allowlist API test and helper for counting enabled suites. |
src/ssl.c |
Routes enable() via header sniff; applies granular policy during wolfSSL_CTX_new_ex; expands SetMinVersion guard logic. |
src/include.am |
Builds the new granular module and distributes its internal header. |
src/crypto_policy_granular.h |
Declares internal granular policy structs and helper APIs. |
src/crypto_policy_granular.c |
Implements granular allowlist parsing, mapping tables, derivation, and CTX application. |
examples/crypto_policies/README.md |
Documents both legacy and granular formats and the apply semantics. |
examples/crypto_policies/*/wolfssl-allowlist*.txt |
Adds Fedora-generator outputs and a DTLS-only test fixture used by tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Six fixes for the granular allowlist back-end, in response to the GitHub Copilot pull-request review and the os-check.yml CI matrix failure on the initial PR (wolfSSL#10541). 1. src/crypto_policy_granular.c top includes. The file was using the standalone `#include <config.h>` + `#include <wolfssl/wolfcrypt/settings.h>` pattern, which trips the include-order assertion in settings.h whenever the build defines TEST_LIBWOLFSSL_SOURCES_INCLUSION_SEQUENCE — that is, on the upstream `make check linux` matrix (75 jobs failed, first observed on CPPFLAGS=-DNO_VERIFY_OID). When the module's content is gated behind WOLFSSL_SYS_CRYPTO_POLICY and the macro is undefined, the same configuration also hit ISO C's empty-translation-unit rule with -Werror=pedantic. Switch to `#include <wolfssl/wolfcrypt/libwolfssl_sources.h>` (the canonical pattern used by src/ssl.c, src/internal.c, src/tls.c) which both establishes the correct include order and pulls in types.h / error-crypt.h / logging.h so the TU is never empty. The downstream includes of logging.h and types.h become redundant and are removed. 2. DTLS-only allowlist no longer derives an empty cipher list (Copilot review, src/crypto_policy_granular.c:432). The previous derivation matched a suite's `version` field (e.g. "TLS1.2") byte-for-byte against the policy's enabled protocol tokens. A DTLS-only file (enabled-version = DTLS1.2) therefore produced zero cipher rows even though the IANA suite identifiers used by TLS 1.x and DTLS 1.x at the same minor version are identical. The new helper `wcp_protocol_family_enabled()` treats "TLS1.x" and "DTLS1.x" as suite-equivalent at the same minor version, in both directions. 3. min_version is now scoped to the CTX's protocol family (Copilot review, src/crypto_policy_granular.c:545). `wolfSSL_crypto_policy_min_version()` used to pick the numerically smallest enabled version across both families. On a DTLS CTX, that would feed a TLS constant (e.g. WOLFSSL_TLSV1_2 = 3) into `wolfSSL_CTX_SetMinVersion()`, which rejects it for the DTLS major version and the floor was silently dropped. The function now takes an `is_dtls` argument and only considers tokens of that family; apply_granular() derives `is_dtls` from `ctx->method->version.major == DTLS_MAJOR`. 4. Empty derived cipher list is now a hard failure (Copilot review, src/crypto_policy_granular.c:559). apply_granular() used to skip `wolfSSL_CTX_set_cipher_list()` when the derived list was empty and still return success, leaving the CTX's default cipher list in place — i.e. an allowlist that didn't constrain anything. It now returns WOLFSSL_FAILURE in that case, which tears the CTX down in the WOLFSSL_CTX_new_ex granular-apply branch of src/ssl.c. 5. wolfSSL_crypto_policy_get_ciphers() reflects the granular derivation (Copilot review, src/ssl.c:5473 and :5535). Both enable paths mirror the derived cipher list into `crypto_policy.str` after a successful granular parse, so the public getter no longer returns an empty string while `crypto_policy.enabled == 1`. The cipher set is identical for the TLS and DTLS families at a given minor version, so one derivation feeds both CTX families. Truncation is safe: the string is informational; the authoritative apply happens per CTX. 6. Cipher-count test helper uses the public stack accessor (Copilot review, tests/api.c:30068). `crypto_policy_cipher_count()` is replaced by a single call to `wolfSSL_sk_SSL_CIPHER_num()`. The manual loop relied on out-of-range behavior of `wolfSSL_sk_SSL_CIPHER_value()`. The DTLS section of test_wolfSSL_crypto_policy_granular() is extended to assert (a) the global policy cipher list is non-empty for a DTLS-only fixture (regression guard for fix wolfSSL#2), (b) the DTLS CTX survives apply_granular (regression guard for fixes wolfSSL#3 and wolfSSL#4), (c) the derived list round-trips through `wolfSSL_CTX_set_cipher_list()` on a fresh DTLS CTX. The test explicitly does not assert on `wolfSSL_get_ciphers_compat()` for a DTLS SSL: its `sslCipherMinMaxCheck()` compares TLS minor numbers with DTLS minor numbers, a pre-existing TLS-vs-DTLS quirk unrelated to the granular back-end. Verified against five local build configurations on Ubuntu 24.04 + gcc 13: (1) `CPPFLAGS=-DNO_VERIFY_OID` (the original failing CI config) — clean; (2) `--enable-all --enable-dtls --enable-dtls13 --with-sys-crypto-policy=...` — make check 17/17 pass; (3) default `./configure` — make check 5/5 pass; (4) `--enable-all` + `-pedantic -Werror -fsanitize=undefined -O1` — build clean (a pre-existing UBSan finding in src/ssl_crypto.c DES code is unrelated); (5) `--enable-dtls --enable-dtls13` + DTLS-only policy — our test passes. Co-Authored-By: Dominik Blain <dominik@qreativelab.io>
|
Hi @philljj, @Copilot — sorry about the noisy initial submission. New commit What changed
Test extension The DTLS block of Local verification Built and tested on Ubuntu 24.04 + gcc 13 with five configurations:
On the contributor agreement — Max and Dom (Dominik Blain, co-author on every commit) will email Thanks again for the careful read — the DTLS issues in particular are the kind of thing you only catch when someone who's read the code points at the right line. |
Address the check-source-text failure on PR wolfSSL#10541, commit 0b95179: the 8-bit scan in pre-commit/check-source-text.sh flagged ten non-ASCII bytes (em-dash U+2014 and intersection U+2229) that had crept into commentary in src/crypto_policy_granular.c and tests/api.c. Replace each em-dash with " -- " and the lone "intersect" glyph with the word "intersect". Comments-only change; no code, no fixtures, no test logic touched. Diff is 10 insertions / 10 deletions across the two files. Verified post-fix: grep -rP '[^\x00-\x7F]' src/crypto_policy_granular.c tests/api.c -> no matches. Co-Authored-By: Dominik Blain <dominik@qreativelab.io>
Follow-up to 7d2b407: the previous pass only scrubbed src/crypto_policy_granular.c and tests/api.c. The 8-bit scan in pre-commit/check-source-text.sh still flags nine lines in files I touched but didn't re-scan -- two in src/ssl.c (around the granular-applier guards and the cipher-list mirror comment) and seven in examples/crypto_policies/README.md. Replacements applied: - ellipsis U+2026 -> "..." - em-dash U+2014 -> " -- " - multiply U+00D7 -> "x" (cross-product description) - intersect U+2229 -> "intersection of A and B" Comments and prose only; no code, no fixtures, no test logic touched. Diff is 9 insertions / 9 deletions across the two files. Verified post-fix on the full PR file set: $ python3 -c "..." # byte-by-byte scan, all 10 PR files NON-ASCII REMAINING: 0 $ grep -rnP '[^\x00-\x7F]' \ src/ssl.c src/crypto_policy_granular.c \ src/crypto_policy_granular.h src/include.am \ examples/crypto_policies/ tests/api.c -> no matches. Co-Authored-By: Dominik Blain <dominik@qreativelab.io>
|
Hi @philljj — apologies, you were right, my previous ASCII pass missed the comments I touched in
Comments and prose only — no code, fixtures, or test logic touched (9 insertions / 9 deletions). Verified locally on the full PR file set: Sorry for the back-and-forth on the check-source-text gate. |
|
There was a problem hiding this comment.
This is my first pass reviewing.
I really like the allow-list style config this is shooting for, e.g. similar to /etc/crypto-policies/back-ends/gnutls.config. I also like the parsing logic isolated to crypto_policy_granular.c, and think this is a good foundation to build on.
I appreciate this is trying to preserve the legacy implementation (and this companion PR is simultaneously trying to expand it: #10535), however I think some of this will need to be simplified and consolidated. E.g. 2 redundant structs for legacy and granular config seems too much complexity. Please feel free to change the legacy system, because it was insufficient.
If we are going to support both config styles, I think the only thing that should be different is the initial config loading, and beyond that it should be common code applying a common crypto policy struct to new CTX's.
|
|
||
| ## The five fixtures shipped here | ||
|
|
||
| `legacy/`, `default/`, `future/`, `fips/`, `bsi/` are unmodified |
There was a problem hiding this comment.
I see only legacy, default, future. Where are fips/, bsi/?
Probably legacy, default, future are sufficient.
| with: | ||
|
|
||
| ```sh | ||
| python3 build-crypto-policies.py --flat --policy DEFAULT policies out |
There was a problem hiding this comment.
Imo we should include build-crypto-policies.py in this PR as well.
| * The per-setter policy guards are temporarily disarmed so | ||
| * our own apply step can install the policy values. */ | ||
| int ret; | ||
| crypto_policy_applying = 1; |
There was a problem hiding this comment.
This crypto_policy_applying flag feels complicated and dangerous. I recommend remove this mechanism, and just allow calling wolfSSL_CTX_SetMinVersion even if the crypto policy is enabled (remove the if (crypto_policy.enabled check).
| * is empty, the CTX would silently keep its default cipher list | ||
| * and the policy would not actually constrain anything. Refuse | ||
| * outright in that case. */ | ||
| rc = wolfSSL_crypto_policy_derive_cipher_list(p, buf, sizeof(buf)); |
There was a problem hiding this comment.
If previously wolfSSL_crypto_policy_derive_cipher_list() was already called to fill out crypto_policy.str, then couldn't we reuse that crypto_policy.str, and avoid calling it again here?
| * (the legacy single-line cap). Allocate a heap buffer for the sniff | ||
| * pass; we fall back to the legacy in-place buffer when the file | ||
| * fits and turns out to be legacy format. */ | ||
| if (sz <= 0 || sz > (long)(1L << 20)) { |
There was a problem hiding this comment.
1L << 20 seems really big for an allow list file. Could we not set a smaller upper limit based on these?
#define WOLF_CP_MAX_TOKENS 64
#define WOLF_CP_MAX_TOKEN_LEN 48
#define WOLF_CP_MAX_LINE 256
| #include "crypto_policy_granular.h" | ||
| /* The system wide crypto-policy. Configured by wolfSSL_crypto_policy_enable. | ||
| * */ | ||
| static struct SystemCryptoPolicy crypto_policy; |
There was a problem hiding this comment.
I appreciate you are trying to preserve the legacy crypto_policy, but I think these two structs (SystemCryptoPolicy, and WolfGranularPolicy) should be consolidated into one common struct.
| @@ -0,0 +1,697 @@ | |||
| /* crypto_policy_granular.c | |||
There was a problem hiding this comment.
These two new files need a wolfssl copyright header.
| size_t n = 0; | ||
| const char *cursor; | ||
|
|
||
| while (*p != '\0' && *p != '\n') { |
There was a problem hiding this comment.
Can this be replaced with `strchr*p, '\n'); like below?
const char *nl = strchr(p, '\n');
|
Hello, crypto-policies maintainer here.
I'm afraid there might be a misunderstanding here =) I appreciate the willingness to accommodate my requests, but I really didn't mean to force any specific naming or config file structure onto you. Doing so would save me maybe tens of minutes of my time and require disproportional effort on your side. E.g., whatever mapping tables you end up with, that wasn't my intention, and it's perfectly fine for any kind of Similarly, I do not insist on the config file to be superficially or totally similar to GnuTLS allowlisting format. What I requested is configurability. I want to be able to express what's enabled, fine-grained. Maintaining some name mapping on c-p sige --- that's totally expected; if your config requires specifying ciphersuites by their names --- perfectly fine, if the form most convenient to you is happens to be an XML file --- I can live with that. In general, it'd be wonderful if you just designed a granular config format most comfortable to you and your users, as free from weird unorthogonal legacy cruft like Make it simple. I'll adapt. |
|
@t184256 thank you for the feedback. I think it's in our interest to follow an accepted standard here, rather than create yet another. It seems like |
|
I can use any format, I can use the proposed one as well. =) It's just... this MR feels like it catered a bit too much to my preferences, resulting in a ton of work and maintenance burden, so I'd like to stress that I'm perfectly willing to host some of the complexity in the crypto-policies generator if it makes the wolfssl side of things any simpler. |
Summary
Adds a granular allowlist back-end for the system-wide crypto-policy file consumed by
wolfSSL_crypto_policy_enable*(), in parallel with the existing single-line@SECLEVEL=N:...parser. Routing is done by header sniff (version =/override-mode =/[section]) so existing legacy deployments keep working unchanged.This is the wolfSSL-side counterpart of the format that
fedora-crypto-policiesemits via theWolfSSLGeneratorPython class (drop-in next to the existing GnuTLS back-end). The companion Python generator is being submitted in parallel against fedora-crypto-policies.Closes the file-format objection from @asosedkin on fedora-crypto-policies #60 (PR #8205's single-line OpenSSL cipher string judged insufficient) and unblocks wolfSSL #9802.
What changed
src/crypto_policy_granular.{c,h}src/ssl.cwolfSSL_crypto_policy_enable*()sniffs the header and routes;wolfSSL_CTX_new_ex()calls the applier when granular;wolfSSL_CTX_SetMinVersion()guard widened to allow the applier throughsrc/include.am!BUILD_CRYPTONLYguardtests/api.ctest_wolfSSL_crypto_policy_granular: enable / suite-count / membership / monotonicity / DTLS / format-version / override-mode invariantsexamples/crypto_policies/{legacy,default,future}/wolfssl-allowlist.txtexamples/crypto_policies/default/wolfssl-allowlist-dtls.txtexamples/crypto_policies/README.mdThe granular applier drives, on every fresh
WOLFSSL_CTX:wolfSSL_CTX_SetMinVersion(from the lowestenabled-version)wolfSSL_CTX_set_cipher_list(from thecipher × kx × mac × versioncross-product against the known TLS suite table)wolfSSL_CTX_UseSupportedCurve(perenabled-group)wolfSSL_CTX_set1_sigalgs_list(from the mappedenabled-sigset)wolfSSL_CTX_SetMin{Rsa,Dh,Ecc}Key_Sz(frommin-rsa-bits/min-dh-bits)Steps 1 and 4 are best-effort: a build that lacks TLS 1.0 support or rejects an
rsa_pss_*sigalg the policy lists logs and continues. Cipher list and key-size floors are authoritative.Test plan
make checkon three configurations, all green:--enable-opensslextra --enable-tls13 --enable-arc4 --enable-debug --enable-supportedcurves --enable-ed25519 --enable-ed448 --enable-curve25519 --enable-curve448: 743 passed / 0 failed / 1507 total--enable-debug(production build): 738 passed / 0 failed / 1507 total--disable-dtlsvariant: 688 passed / 0 failed / 1507 total (the DTLS section of our test auto-skips via#ifdef WOLFSSL_DTLS)Zero regression vs
masterbaseline. The newtest_wolfSSL_crypto_policy_granularexercises:WOLFSSL_CTXwith the expected security level and suite contents.version = 2is rejected outright.override-mode = blocklistis rejected.Scope
What this PR does not do (deferred):
MLDSA*, additional cipher suites,KEM-ECDH,HMAC-SHA1, etc. are parsed and silently ignored when the build does not implement them — explicit support can land per primitive).wolfSSL_crypto_policy_init_ctx()to derive its min-key floors from the granular struct rather than fromsecLevel. Today the granular path mirrorssecurity-levelinto the legacycrypto_policy.secLevelso the existing min-key-size scaffolding inwolfSSL_crypto_policy_init_ctx()continues to behave; an explicit pass-through can land in a follow-up.The exact section/key names (
enabled-version,enabled-cipher, …) are deliberately close to the GnuTLS back-end so the crypto-policies maintainers can adjust them cheaply. That naming call is theirs, on fedora-crypto-policies work item #60.Authors
🤖 Generated with Claude Code