Skip to content

Commit 7a7e016

Browse files
committed
fix: guard CAP-71 delegate signing against expiration mismatch
1 parent 160dd8f commit 7a7e016

4 files changed

Lines changed: 615 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ Release History
33

44
### Pending
55

6+
#### Fixes
7+
- `auth.authorize_entry` no longer re-stamps an authorization entry's expiration ledger out from under a signature the entry already carries. A CAP-71-01 (`SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES`) entry has one `signature_expiration_ledger`, shared by the top-level address and every (nested) delegate, and each of their signatures commits to it — so signing one node with a `valid_until_ledger_sequence` different from the one already stored left the earlier signatures in place but no longer verifiable, and nothing was raised until the host rejected the entry. ([#1215](https://github.com/StellarCN/py-stellar-base/issues/1215))
8+
- `authorize_entry` now resolves the target node(s) before touching the entry and raises `ValueError`, naming the stored expiration ledger, when the requested one would invalidate a signature on any node outside them. Signature values are opaque to the SDK, so every value other than `scvVoid` counts, `scvVoid` being the marker for a node still awaiting one — the convention `build_with_delegates_entry` and `needs_non_invoker_signing_by` already follow. An account contract that accepts `scvVoid` itself as a valid signature is therefore not covered. Re-signing the targeted node(s) with a new expiration is still allowed, and `ADDRESS` / `ADDRESS_V2` entries — which carry a single node — are unaffected.
9+
- `AssembledTransaction[Async].authorize` / `sign_auth_entries` reach the same guard. They can only sign a delegates entry's top-level node, but the expiration they stamp (`latest_ledger + 100` by default) is the shared one, so signing the top level of an entry whose delegates have already signed now raises instead of invalidating them; pass those delegates' `valid_until_ledger_sequence` explicitly.
10+
- `build_with_delegates_entry` documents that a signature already present on the wrapped entry is not carried over, which was true but undocumented.
11+
612
### Version 16.1.0
713

814
Released on September 02, 2026

stellar_sdk/auth.py

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,35 @@ def walk(delegates: list[stellar_xdr.SorobanDelegateSignature]) -> None:
107107
return nodes
108108

109109

110+
def _has_signed_node_outside(
111+
credentials: stellar_xdr.SorobanCredentials,
112+
targets: Sequence[
113+
stellar_xdr.SorobanAddressCredentials | stellar_xdr.SorobanDelegateSignature
114+
],
115+
) -> bool:
116+
"""Whether a signature-carrying node other than ``targets`` is already signed.
117+
118+
The entry has a single, shared ``signature_expiration_ledger``, and a
119+
signature over the payload it belongs to stops verifying if that field
120+
changes. Nodes are matched by identity, so the exclusion covers exactly the
121+
nodes about to be replaced.
122+
123+
A signature ``SCVal`` is opaque to the SDK, so this conservatively treats
124+
every non-``scvVoid`` value as payload-bound. ``scvVoid`` marks a node still
125+
awaiting a signature — the convention the rest of the SDK already follows,
126+
in :func:`build_with_delegates_entry` and in
127+
``AssembledTransaction.needs_non_invoker_signing_by`` — which means an
128+
account contract that accepts ``scvVoid`` itself as a valid signature is not
129+
covered here.
130+
"""
131+
target_ids = {id(node) for node in targets}
132+
return any(
133+
id(node) not in target_ids
134+
and node.signature.type != stellar_xdr.SCValType.SCV_VOID
135+
for node in _collect_signature_nodes(credentials)
136+
)
137+
138+
110139
def build_authorization_preimage(
111140
entry: stellar_xdr.SorobanAuthorizationEntry,
112141
valid_until_ledger_sequence: int,
@@ -254,8 +283,14 @@ def authorize_entry(
254283
The signed payload commits to ``valid_until_ledger_sequence``, and for a
255284
delegates entry the top-level account and every (nested) delegate sign the
256285
same payload — so every signer of one entry must use the same
257-
``valid_until_ledger_sequence``, otherwise earlier signatures are
258-
invalidated.
286+
``valid_until_ledger_sequence``. Passing a different one once another node
287+
of the entry carries a signature raises :exc:`ValueError` rather than
288+
returning an entry whose earlier signatures may no longer verify. Every
289+
signature value other than ``scvVoid`` counts, ``scvVoid`` being the marker
290+
for a node still awaiting one — so a custom account contract that accepts
291+
``scvVoid`` itself as a valid signature falls outside that check; fill such
292+
a node by signing the payload from :func:`build_authorization_preimage` and
293+
assigning it yourself.
259294
260295
Default account example::
261296
@@ -292,8 +327,11 @@ def bls_signer(preimage: xdr.HashIDPreimage) -> xdr.SCVal:
292327
:return: A signed Soroban authorization entry.
293328
:raises:
294329
:exc:`ValueError`: if the entry's credential address is not a classic
295-
account (``G...``) or contract (``C...``) address, or if ``for_address``
296-
matches no credential node in the entry.
330+
account (``G...``) or contract (``C...``) address, if ``for_address``
331+
matches no credential node in the entry, or if
332+
``valid_until_ledger_sequence`` differs from the expiration ledger
333+
already stored on an entry whose other nodes carry a signature other
334+
than ``scvVoid``.
297335
"""
298336
if isinstance(entry, str):
299337
entry = stellar_xdr.SorobanAuthorizationEntry.from_xdr(entry)
@@ -312,20 +350,10 @@ def bls_signer(preimage: xdr.HashIDPreimage) -> xdr.SCVal:
312350
f"Unsupported SorobanCredentialsType: {entry.credentials.type}."
313351
)
314352

315-
# Set the expiration before building the preimage, so the signed payload
316-
# commits to the same expiration ledger stored in the credentials.
317-
addr_auth.signature_expiration_ledger = stellar_xdr.Uint32(
318-
valid_until_ledger_sequence
319-
)
320-
321-
preimage = build_authorization_preimage(
322-
entry, valid_until_ledger_sequence, network_passphrase
323-
)
324-
signature = _sign_authorization(signer, preimage)
325-
326353
# CAP-71-01: the payload is shared across the top-level address and every
327354
# (possibly nested) delegate, so the signature can be written to whichever
328-
# credential node(s) carry `for_address`.
355+
# credential node(s) carry `for_address`. Resolve them before touching the
356+
# entry, so the guard below knows which signatures are about to be replaced.
329357
if for_address is None:
330358
targets: list[
331359
stellar_xdr.SorobanAddressCredentials | stellar_xdr.SorobanDelegateSignature
@@ -342,6 +370,32 @@ def bls_signer(preimage: xdr.HashIDPreimage) -> xdr.SCVal:
342370
"The authorization entry has no credential node for address "
343371
f"{resolved.address}."
344372
)
373+
374+
# The whole entry shares one expiration ledger, so re-stamping it can
375+
# silently invalidate a signature already collected from another node
376+
# (CAP-71-01). Signature values are opaque, so err on the side of refusing.
377+
stored_expiration = addr_auth.signature_expiration_ledger.uint32
378+
if stored_expiration != valid_until_ledger_sequence and _has_signed_node_outside(
379+
entry.credentials, targets
380+
):
381+
raise ValueError(
382+
"This authorization entry already carries a signature committed to "
383+
f"signature_expiration_ledger={stored_expiration}; pass that same "
384+
f"`valid_until_ledger_sequence` to add another signature (got "
385+
f"{valid_until_ledger_sequence}), or start from an entry whose "
386+
"other nodes are unsigned."
387+
)
388+
389+
# Set the expiration before building the preimage, so the signed payload
390+
# commits to the same expiration ledger stored in the credentials.
391+
addr_auth.signature_expiration_ledger = stellar_xdr.Uint32(
392+
valid_until_ledger_sequence
393+
)
394+
395+
preimage = build_authorization_preimage(
396+
entry, valid_until_ledger_sequence, network_passphrase
397+
)
398+
signature = _sign_authorization(signer, preimage)
345399
for node in targets:
346400
node.signature = signature
347401
return entry
@@ -525,7 +579,9 @@ def build_with_delegates_entry(
525579
:param delegates: The delegate signers to attach.
526580
:param signature: The top-level account's signature. Defaults to
527581
``scvVoid``, which is valid for accounts that authorize purely via
528-
delegated signers.
582+
delegated signers. A signature already present on ``entry`` is not
583+
carried over; sign the returned entry with :func:`authorize_entry`, or
584+
pass the signature here.
529585
:return: A new ``SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES`` authorization
530586
entry; the input entry is not modified.
531587
:raises:

tests/contract/test_assembled_transaction.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
scval,
1414
)
1515
from stellar_sdk import xdr as stellar_xdr
16-
from stellar_sdk.auth import DelegateSignature, build_with_delegates_entry
16+
from stellar_sdk.auth import (
17+
DelegateSignature,
18+
authorize_entry,
19+
build_with_delegates_entry,
20+
)
1721
from stellar_sdk.contract import (
1822
AssembledTransaction,
1923
AssembledTransactionAsync,
@@ -665,3 +669,71 @@ async def test_async_needs_non_invoker_signing_by_counts_all_address_credential_
665669
legacy_signer.public_key,
666670
delegating_signer.public_key,
667671
}
672+
673+
674+
def _pre_signed_delegates_entry(top: Keypair, delegate: Keypair, valid_until: int):
675+
"""A delegates entry whose delegate has already signed at ``valid_until``."""
676+
entry = build_with_delegates_entry(
677+
_sample_auth_entry(
678+
top.public_key,
679+
stellar_xdr.SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2,
680+
),
681+
0,
682+
[DelegateSignature(delegate.public_key)],
683+
)
684+
return authorize_entry(
685+
entry,
686+
delegate,
687+
valid_until,
688+
Network.TESTNET_NETWORK_PASSPHRASE,
689+
for_address=delegate.public_key,
690+
)
691+
692+
693+
def test_authorize_rejects_expiration_change_on_pre_signed_delegates_entry():
694+
# authorize() can only sign the top-level node of a delegates entry, but the
695+
# expiration ledger it stamps is shared with the delegates. Re-stamping it
696+
# with the library default used to silently invalidate their signatures.
697+
top = Keypair.random()
698+
delegate = Keypair.random()
699+
entry = _pre_signed_delegates_entry(top, delegate, 1000)
700+
assembled, _, _ = _assembled_with_auth(top.public_key, entries=[entry])
701+
702+
with pytest.raises(ValueError, match=r"signature_expiration_ledger=1000"):
703+
assembled.authorize(top)
704+
705+
# passing the delegate's expiration through signs the top level for real
706+
assembled.authorize(top, valid_until_ledger_sequence=1000)
707+
assert assembled.built_transaction is not None
708+
op = assembled.built_transaction.transaction.operations[0]
709+
assert isinstance(op, InvokeHostFunction)
710+
with_delegates = op.auth[0].credentials.address_with_delegates
711+
assert with_delegates is not None
712+
assert (
713+
with_delegates.address_credentials.signature.type
714+
== stellar_xdr.SCValType.SCV_VEC
715+
)
716+
assert with_delegates.address_credentials.signature_expiration_ledger == (
717+
stellar_xdr.Uint32(1000)
718+
)
719+
720+
721+
async def test_async_authorize_rejects_expiration_change_on_pre_signed_delegates_entry():
722+
top = Keypair.random()
723+
delegate = Keypair.random()
724+
entry = _pre_signed_delegates_entry(top, delegate, 1000)
725+
assembled, _, _ = _assembled_async_with_auth(top.public_key, entries=[entry])
726+
727+
with pytest.raises(ValueError, match=r"signature_expiration_ledger=1000"):
728+
await assembled.authorize(top)
729+
730+
await assembled.authorize(top, valid_until_ledger_sequence=1000)
731+
assert assembled.built_transaction is not None
732+
op = assembled.built_transaction.transaction.operations[0]
733+
assert isinstance(op, InvokeHostFunction)
734+
with_delegates = op.auth[0].credentials.address_with_delegates
735+
assert with_delegates is not None
736+
assert (
737+
with_delegates.address_credentials.signature.type
738+
== stellar_xdr.SCValType.SCV_VEC
739+
)

0 commit comments

Comments
 (0)