diff --git a/AGENTS.md b/AGENTS.md index b59378e64e..8ec1c2d01d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,13 @@ Before implementing any task: 3. Log blockers/decisions under `## Notes & Decisions` 4. Mark plan as `✅ COMPLETE` when finished +### Documentation Rule +Before marking a task complete, update or create the relevant documentation under `docs/`: +- If the task changes a public API, protocol, or user-facing behaviour, update the corresponding `docs/` page (or create one if it does not exist). +- Keep docs consistent with code: function names, message fields, flow diagrams, and examples must match the implementation. +- New `docs/` pages must follow the existing style (Markdown, same heading hierarchy as neighbouring files). +- If no existing doc page covers the changed area, create `docs//.md` and add a link from the nearest index or README. + ## 🔍 Debugging & Advanced Testing ### Log Locations @@ -197,38 +204,8 @@ Before implementing any task: - `token/`: Core SDK logic - `integration/`: Integration tests and Network Orchestrator -## 🔄 CI Workflow Overview - -To ensure your commits pass CI automatically, understand what runs: - -### 🔧 Pre-Merge Checks (GitHub Actions) -All PRs and pushes to `main` trigger these workflows: - -1. **Checks Job** (Prerequisite): - - License verification - - Code formatting (`gofmt`, `goimports`) - - Static analysis (`govet`, `staticcheck`, `ineffassign`, `misspell`) - - *Run locally with:* `make checks` - -2. **Unit Testing**: - - Race detector enabled tests - - Regression tests - - Coverage reporting to Coveralls - -3. **Integration Testing** (Extensive Matrix): - - Fabtoken (cleartext tokens): t1-t5 - - ZKATDLog (privacy tokens): t1-t13 - - Fabric-X, Interop, NFT, DVP, Update tests - - Stress tests - - All with coverage reporting - -4. **Separate Workflows**: - - **golangci-lint**: Comprehensive linting (30 min timeout) - - **Markdown links**: Validates all doc links - - **CodeQL**: Security analysis (weekly + on push/PR) - -### 💡 Best Practices for CI Success +## 💡 Best Practices for CI Success +Before marking a task complete: - **Always run** `make checks` and `make lint-auto-fix` before committing -- **Verify** `FAB_BINS` is set for integration test compatibility - **Address** all linting and static check warnings promptly - **Keep** dependencies updated with `make tidy` \ No newline at end of file diff --git a/docs/services/ttx.md b/docs/services/ttx.md index 4a7a9dd21e..09e23c4278 100644 --- a/docs/services/ttx.md +++ b/docs/services/ttx.md @@ -11,28 +11,24 @@ The lifecycle of a token transaction typically involves the following stages, co ```mermaid sequenceDiagram autonumber - participant Initiator - participant Recipient - participant Auditor - participant Network as Network Service - participant Ledger as DLT / Ledger box darkgreen Panurus Stack participant Initiator participant Recipient participant Auditor - participant Network + participant Network as Network Service end + participant Ledger as DLT / Ledger Note over Initiator: 1. Request Identities (with nonce/signature attestation) Initiator->>+Recipient: RequestRecipientIdentityView (includes Nonce) Recipient-->>-Initiator: RecipientResponse (Identity + Audit Info + Signature) Note over Initiator: 2. Assemble Request - Initiator->>+Initiator: Issue / Transfer / Redeem operations + Initiator->>Initiator: Issue / Transfer / Redeem operations Note over Initiator: 3. Collect Endorsements - Initiator->>+Initiator: Sign locally + Initiator->>Initiator: Sign locally Initiator->>+Recipient: Request Signatures (for spent tokens) Recipient-->>-Initiator: Signature Initiator->>+Auditor: AuditApproveView @@ -41,13 +37,13 @@ sequenceDiagram Network-->>-Initiator: Endorsed Envelope Note over Initiator: 4. Distribution & Ordering - Initiator->>+Recipient: Distribute Transaction Metadata + Initiator->>Recipient: Distribute Transaction Metadata Initiator->>+Network: Broadcast Transaction Network->>+Ledger: Submit to Orderer + Ledger-->>-Network: Transaction Committed Note over Initiator: 5. Finality Tracking Initiator->>+Network: Listen for Finality - Ledger-->>-Network: Transaction Committed Network-->>-Initiator: Notify Finality ``` @@ -290,7 +286,7 @@ The message-type discriminators live with the service that uses them — the ttx | `TypeRecipientRequest` / `TypeRecipientResponse` | `recipient_req` / `recipient_resp` | `recipients.go` request flow | | `TypeExchangeRecipientRequest` / `TypeExchangeRecipientResp` | `exchange_req` / `exchange_resp` | `recipients.go` exchange flow | | `TypeMultisigRecipientData` / `TypePolicyRecipientData` | `multisig_data` / `policy_data` | recipient follow-ups for multisig / policy identities | -| `TypeWithdrawalRequest` | `withdrawal_req` | `withdrawal.go` | +| `TypeWithdrawalRequest` / `TypeWithdrawalChallenge` / `TypeWithdrawalResponse` | `withdrawal_req` / `withdrawal_challenge` / `withdrawal_resp` | `withdrawal.go` | | `TypeUpgradeAgreement` / `TypeUpgradeRequest` | `upgrade_agree` / `upgrade_req` | `upgrade.go` | | `TypeSpendRequest` / `TypeSpendResponse` | `spend_req` / `spend_resp` | `multisig/spend.go`, `boolpolicy/spend.go` | | `TypeSignatureRequest` / `TypeSignature` | `sig_req` / `signature` | `collectendorsements.go`, `endorse.go`, `accept.go`, `auditor.go` | @@ -317,7 +313,7 @@ All satisfy `errors.Is`. `VersionCompatibility` / `IsCompatible(local, remote)` ## Withdrawal Flow -The withdrawal protocol (`withdrawal.go`) lets a wallet ask an issuer to mint tokens to a freshly generated recipient identity. Single-shot: the initiator sends a `WithdrawalRequest`; the issuer registers the recipient identity and returns the session for the subsequent issuance flow. +The withdrawal protocol (`withdrawal.go`) lets a wallet ask an issuer to mint tokens to a freshly generated recipient identity. It uses a **three-message challenge-response** so that freshness is controlled by the issuer — the issuer samples the nonce, which prevents an attacker from pre-computing a valid `(nonce, signature)` pair and replaying it across sessions. ```mermaid sequenceDiagram @@ -325,13 +321,37 @@ sequenceDiagram participant I as Initiator (RequestWithdrawalView) participant Iss as Issuer (ReceiveWithdrawalRequestView) - I->>I: Resolve recipient identity (caller-supplied or wallet-generated) - I->>Iss: Envelope{t:"withdrawal_req", b:WithdrawalRequest{TMSID, RecipientData, TokenType, Amount, NotAnonymous}} - Iss->>Iss: RegisterRecipientIdentity(request.RecipientData) - Iss->>Iss: endpoint.Bind(caller -> RecipientData.Identity) - Note over I,Iss: Session returned to caller for the issuance/endorsement flow + rect rgba(230, 230, 250, 0.35) + Note over I,Iss: Phase 1 - Request + I->>I: Resolve recipient identity (caller-supplied or wallet-generated) + I->>Iss: Envelope{t:"withdrawal_req", b:WithdrawalRequest{TMSID, RecipientData, TokenType, Amount, NotAnonymous}} + end + + rect rgba(255, 245, 238, 0.5) + Note over Iss: Phase 2 - Challenge + Iss->>Iss: nonce = GetRandomNonce() + Iss-->>I: Envelope{t:"withdrawal_challenge", b:WithdrawalChallenge{Nonce}} + end + + rect rgba(240, 255, 240, 0.45) + Note over I: Phase 3 - Response + I->>I: msg = asn1(TMSID + nil walletID + RecipientData.Identity + nonce + session id + context id) + I->>I: sig = Sign(msg) + I->>Iss: Envelope{t:"withdrawal_resp", b:WithdrawalResponse{Signature: sig}} + end + + rect rgba(245, 245, 245, 0.55) + Note over Iss: Phase 4 - Verification and registration + Iss->>Iss: msg = asn1(same fields, using issuer-held nonce) + Iss->>Iss: verifier.Verify(msg, resp.Signature) + Iss->>Iss: RegisterRecipientIdentity(request.RecipientData) + Iss->>Iss: endpoint.Bind(caller -> RecipientData.Identity) + Note over I,Iss: Session returned to caller for the issuance/endorsement flow + end ``` +The attestation message is the same DER-encoded (`encoding/asn1`) structure used by the recipient-identity protocols, with `walletID` fixed to `nil` — the issuer does not need to know the requester's wallet identifier. + ## Token Upgrade Flow The upgrade protocol (`upgrade.go`) exchanges old-format tokens for new-format ones over two round-trips: an agreement that establishes a fresh challenge, then a request carrying the proof. @@ -409,7 +429,7 @@ sequenceDiagram I->>R: Envelope{t:"transaction", b:TransactionPayload{Raw}} I->>R: Envelope{t:"actions", b:Actions} I->>R: Envelope{t:"action_transfer", b:ActionTransfer} - R->>R: Receive transaction, actions, action; assemble + R->>R: Receive transaction, actions, action — assemble R-->>I: Envelope{t:"tx_resp", b:TransactionPayload{Raw}} ``` diff --git a/token/services/ttx/envelope_protocol_test.go b/token/services/ttx/envelope_protocol_test.go index 2a60273b75..1d0d1ea13e 100644 --- a/token/services/ttx/envelope_protocol_test.go +++ b/token/services/ttx/envelope_protocol_test.go @@ -93,6 +93,20 @@ func TestVersionedWithdrawalRequestRoundTrip(t *testing.T) { roundTripTTXMessage(t, TypeWithdrawalRequest, original, &WithdrawalRequest{}) } +func TestVersionedWithdrawalChallengeRoundTrip(t *testing.T) { + original := &WithdrawalChallenge{ + Nonce: []byte("withdrawal-nonce-32bytes-pad-xxxx"), + } + roundTripTTXMessage(t, TypeWithdrawalChallenge, original, &WithdrawalChallenge{}) +} + +func TestVersionedWithdrawalResponseRoundTrip(t *testing.T) { + original := &WithdrawalResponse{ + Signature: []byte("attestation-sig"), + } + roundTripTTXMessage(t, TypeWithdrawalResponse, original, &WithdrawalResponse{}) +} + func TestVersionedUpgradeAgreementRoundTrip(t *testing.T) { original := &UpgradeTokensAgreement{ Challenge: []byte("challenge"), diff --git a/token/services/ttx/protocol_messages.go b/token/services/ttx/protocol_messages.go index fc24fb2ba2..c1de4261e4 100644 --- a/token/services/ttx/protocol_messages.go +++ b/token/services/ttx/protocol_messages.go @@ -19,7 +19,9 @@ const ( TypePolicyRecipientData = "policy_data" // withdrawal.go - TypeWithdrawalRequest = "withdrawal_req" + TypeWithdrawalRequest = "withdrawal_req" + TypeWithdrawalChallenge = "withdrawal_challenge" + TypeWithdrawalResponse = "withdrawal_resp" // upgrade.go TypeUpgradeAgreement = "upgrade_agree" diff --git a/token/services/ttx/withdrawal.go b/token/services/ttx/withdrawal.go index 3518019f1d..19cf2824da 100644 --- a/token/services/ttx/withdrawal.go +++ b/token/services/ttx/withdrawal.go @@ -17,6 +17,10 @@ import ( "github.com/hyperledger-labs/fabric-smart-client/platform/view/view" ) +// WithdrawalRequest is the first message of the withdrawal protocol. The +// requester sends it to the issuer to declare which identity should receive +// the issued tokens. It carries no nonce or signature; those are exchanged in +// the subsequent challenge/response round-trip initiated by the issuer. type WithdrawalRequest struct { TMSID token.TMSID RecipientData RecipientData @@ -25,6 +29,20 @@ type WithdrawalRequest struct { NotAnonymous bool } +// WithdrawalChallenge is sent by the issuer after receiving a WithdrawalRequest. +// The issuer samples a fresh nonce so that it — not the requester — controls +// freshness, preventing replay attacks. +type WithdrawalChallenge struct { + Nonce []byte +} + +// WithdrawalResponse is sent by the requester in reply to a WithdrawalChallenge. +// Signature is a key-ownership attestation over the challenge nonce and the +// fields of the original WithdrawalRequest (built via buildAttestationMessage). +type WithdrawalResponse struct { + Signature []byte +} + // RequestWithdrawalView is the initiator view to request an issuer the issuance of tokens. // The view prepares an instance of WithdrawalRequest and send it to the issuer. type RequestWithdrawalView struct { @@ -66,12 +84,21 @@ func RequestWithdrawalForRecipient(context view.Context, issuer view.Identity, w } func (r *RequestWithdrawalView) Call(context view.Context) (any, error) { - logger.DebugfContext(context.Context(), "Respond request recipient identity using wallet [%s]", r.Wallet) + logger.DebugfContext(context.Context(), "Request withdrawal using wallet [%s]", r.Wallet) - tmsID, recipientData, err := r.getRecipientIdentity(context) + tmsID, recipientData, w, err := r.getRecipientIdentity(context) if err != nil { return nil, errors.Wrapf(err, "failed to get recipient data") } + + logger.DebugfContext(context.Context(), "Start session") + s, err := jsession.NewTypedSessionForCaller(context, context.Initiator(), r.Issuer) + if err != nil { + logger.Errorf("failed to get session to [%s]: [%s]", r.Issuer, err) + + return nil, errors.Wrapf(err, "failed to get session to [%s]", r.Issuer) + } + wr := &WithdrawalRequest{ TMSID: *tmsID, RecipientData: *recipientData, @@ -80,20 +107,36 @@ func (r *RequestWithdrawalView) Call(context view.Context) (any, error) { NotAnonymous: r.NotAnonymous, } - logger.DebugfContext(context.Context(), "Start session") - s, err := jsession.NewTypedSessionForCaller(context, context.Initiator(), r.Issuer) - if err != nil { - logger.Errorf("failed to get session to [%s]: [%s]", r.Issuer, err) + logger.DebugfContext(context.Context(), "Send withdrawal request") + if err = s.SendTyped(context.Context(), wr, TypeWithdrawalRequest); err != nil { + logger.Errorf("failed to send withdrawal request: [%s]", err) - return nil, errors.Wrapf(err, "failed to get session to [%s]", r.Issuer) + return nil, errors.Wrapf(err, "failed to send withdrawal request") } - logger.DebugfContext(context.Context(), "Send withdrawal request") - err = s.SendTyped(context.Context(), wr, TypeWithdrawalRequest) + // Receive the issuer-sampled challenge nonce. + logger.DebugfContext(context.Context(), "Receive withdrawal challenge") + challenge := &WithdrawalChallenge{} + if err = s.ReceiveTypedWithTimeout(TypeWithdrawalChallenge, challenge, 1*time.Minute); err != nil { + return nil, errors.Wrapf(err, "failed to receive withdrawal challenge") + } + if len(challenge.Nonce) == 0 { + return nil, errors.New("withdrawal challenge missing nonce") + } + + // Sign the issuer-controlled nonce to prove key ownership. + message, err := buildAttestationMessage(*tmsID, nil, recipientData.Identity, false, "", challenge.Nonce, s.Info().ID, context.ID()) + if err != nil { + return nil, errors.Wrapf(err, "failed to build attestation message") + } + sig, err := signRecipientAttestation(context.Context(), w, message, recipientData.Identity, true) if err != nil { - logger.Errorf("failed to send recipient data: [%s]", err) + return nil, err + } - return nil, errors.Wrapf(err, "failed to send recipient data") + logger.DebugfContext(context.Context(), "Send withdrawal response") + if err = s.SendTyped(context.Context(), &WithdrawalResponse{Signature: sig}, TypeWithdrawalResponse); err != nil { + return nil, errors.Wrapf(err, "failed to send withdrawal response") } return []any{wr, s.Session()}, nil @@ -120,16 +163,10 @@ func (r *RequestWithdrawalView) WithRecipientData(data *RecipientData) *RequestW return r } -func (r *RequestWithdrawalView) getRecipientIdentity(context view.Context) (*token.TMSID, *RecipientData, error) { - if r.RecipientData != nil { - tms, err := token.GetManagementService(context, token.WithTMSID(r.TMSID)) - if err != nil { - return nil, nil, errors.Wrapf(err, "tms not found for [%s]", r.TMSID) - } - - return new(tms.ID()), r.RecipientData, nil - } - +// getRecipientIdentity resolves the TMS ID, recipient data, and the owner +// wallet for the requester. The wallet is returned so the caller can sign the +// key-ownership attestation. +func (r *RequestWithdrawalView) getRecipientIdentity(context view.Context) (*token.TMSID, *RecipientData, *token.OwnerWallet, error) { w := GetWallet( context, r.Wallet, @@ -138,16 +175,25 @@ func (r *RequestWithdrawalView) getRecipientIdentity(context view.Context) (*tok if w == nil { logger.Errorf("failed to get wallet [%s]", r.Wallet) - return nil, nil, errors.Errorf("wallet [%s:%s] not found", r.Wallet, r.TMSID) + return nil, nil, nil, errors.Errorf("wallet [%s:%s] not found", r.Wallet, r.TMSID) + } + + if r.RecipientData != nil { + tmsID := w.TMS().ID() + + return &tmsID, r.RecipientData, w, nil } + recipientData, err := w.GetRecipientData(context.Context()) if err != nil { logger.Errorf("failed to get recipient data: [%s]", err) - return nil, nil, errors.Wrapf(err, "failed to get recipient data") + return nil, nil, nil, errors.Wrapf(err, "failed to get recipient data") } - return new(w.TMS().ID()), recipientData, nil + tmsID := w.TMS().ID() + + return &tmsID, recipientData, w, nil } // ReceiveWithdrawalRequestView this is the view used by the issuer to receive a withdrawal request @@ -180,7 +226,34 @@ func (r *ReceiveWithdrawalRequestView) Call(context view.Context) (any, error) { return nil, errors.Wrapf(err, "tms not found for [%s]", request.TMSID) } - if err := tms.WalletManager().RegisterRecipientIdentity(context.Context(), &request.RecipientData); err != nil { + // Sample a fresh nonce and send it as the challenge. The requester must + // sign this nonce, proving it — not any replayed message — is live. + nonce, err := GetRandomNonce() + if err != nil { + return nil, errors.Wrap(err, "failed to generate withdrawal challenge nonce") + } + logger.DebugfContext(context.Context(), "Send withdrawal challenge") + if err = s.SendTyped(context.Context(), &WithdrawalChallenge{Nonce: nonce}, TypeWithdrawalChallenge); err != nil { + return nil, errors.Wrapf(err, "failed to send withdrawal challenge") + } + + // Receive the requester's signed response. + logger.DebugfContext(context.Context(), "Receive withdrawal response") + resp := &WithdrawalResponse{} + if err = s.ReceiveTypedWithTimeout(TypeWithdrawalResponse, resp, 1*time.Minute); err != nil { + return nil, errors.Wrapf(err, "failed to receive withdrawal response") + } + + // Verify the key-ownership attestation using the issuer-controlled nonce. + message, err := buildAttestationMessage(request.TMSID, nil, request.RecipientData.Identity, false, "", nonce, s.Info().ID, context.ID()) + if err != nil { + return nil, errors.Wrapf(err, "failed to build attestation message") + } + if err = verifyRecipientAttestation(context.Context(), tms, message, &request.RecipientData, resp.Signature, false); err != nil { + return nil, err + } + + if err = tms.WalletManager().RegisterRecipientIdentity(context.Context(), &request.RecipientData); err != nil { logger.Errorf("failed to register recipient identity: [%s]", err) return nil, errors.Wrapf(err, "failed to register recipient identity") @@ -189,7 +262,7 @@ func (r *ReceiveWithdrawalRequestView) Call(context view.Context) (any, error) { // Update the Endpoint Resolver caller := context.Session().Info().Caller logger.DebugfContext(context.Context(), "update endpoint resolver for [%s], bind to [%s]", request.RecipientData.Identity, caller) - if err := endpoint.GetService(context).Bind(context.Context(), caller, request.RecipientData.Identity); err != nil { + if err = endpoint.GetService(context).Bind(context.Context(), caller, request.RecipientData.Identity); err != nil { logger.DebugfContext(context.Context(), "failed binding [%s] to [%s]", request.RecipientData.Identity, caller) return nil, errors.Wrapf(err, "failed binding [%s] to [%s]", request.RecipientData.Identity, caller)