Skip to content

fix(identity): derive the enrollment ID of a boolpolicy identity from its members - #2146

Open
EvanYan1024 wants to merge 10 commits into
LFDT-Panurus:mainfrom
Built-by-Sign:fix/boolpolicy-enrollment-id
Open

fix(identity): derive the enrollment ID of a boolpolicy identity from its members#2146
EvanYan1024 wants to merge 10 commits into
LFDT-Panurus:mainfrom
Built-by-Sign:fix/boolpolicy-enrollment-id

Conversation

@EvanYan1024

@EvanYan1024 EvanYan1024 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #2145

What

Derive the enrollment ID of a boolpolicy identity from its member identities, mirroring the recursive htlc.NewAuditDeserializer pattern:

  • boolpolicy.NewAuditInfoDeserializer now takes the parent multiplex deserializer and resolves each component's audit info through it; the policy identity reports the enrollment ID shared by all components. The fabtoken and zkatdlog drivers pass the multiplexer at registration.
  • Components with no enrollment ID of their own (e.g. a nested composite spanning enrollments) or disagreeing components yield the legacy empty value — composites resolve recursively, so a member may itself be a policy or multisig identity.
  • A component whose audit info is missing is legal: GetAuditInfo returns nil for an identity that is not registered locally and WrapAuditInfo preserves the empty entry, so such a component contributes no enrollment ID rather than failing the audit path. This exemption is applied before subtype resolution, so a component of an unknown identity type carrying no audit info also yields no enrollment ID rather than an error.
  • Malformed audit info — a non-empty component audit info that cannot be resolved, or a component count mismatch — is an error instead of a silent empty value, and every component is resolved before the result is declared so corruption in a later component is never masked by an earlier "no common EID" outcome.
  • The wire-supplied component identities are validated at this deserialization boundary, as GetAuditInfoMatcher and DeserializeVerifier already do. Nothing else on the audit path reaches that validation: Request.IsValid only checks action structure and metadata consistency, and owner verifiers are deserialized solely by the driver validator at commit.
  • A component whose inner deserializer returns neither audit info nor an error contributes no enrollment ID instead of panicking.

Why

AuditInfo.EnrollmentID() returned "" for every policy identity, and the two sides of a transaction are affected differently.

The input side carries the composite identity itself — both drivers put a single sender in TransferInputMetadata — so a policy-owned input reached the auditor with no enrollment attribution at all, and completeInputsWithEmptyEID then reassigned it to whichever enrollment ID the output stream reports first. That is the gap this PR closes.

The output side is separate: output rows are expanded per component and already resolve each member's own enrollment ID, so they are attributed correctly but counted once per member. That duplicate amount accounting is fixed by #2148. Only the two together bring a policy wallet's audited movements back to the true net; see #2145 for a worked example.

In our test environments the auditor's audit balances for policy wallets were corrupted by this pair of defects.

Testing

  • Unit tests cover: common-enrollment derivation, single member, legal cross-enrollment components, nested policies (both the common-EID and cross-enrollment inner case), empty member enrollment IDs, missing component audit info (alone, alongside a resolvable component, followed by a malformed component, and on an unknown identity type), invalid component identities (duplicate and empty), a nil audit info returned by the inner deserializer, the malformed cases (unresolvable component, count mismatch including an empty audit-info blob against a non-empty policy, a policy identity with no components, garbage audit info), the masked-later-corruption case, and the zero-value deserializer.
  • FuzzDeserializeAuditInfoNoPanic fuzzes the recursive deserialization path (seeds: valid single/two-member policies in both identity encodings, empty, truncated, empty audit-info blob, count mismatch, unknown member type, 5-deep nesting; inputs capped at 64 KiB like the neighbouring identity targets), wired into .github/workflows/nightly-fuzz.yml; 20s local run clean at ~50k execs/sec.

@EvanYan1024
EvanYan1024 force-pushed the fix/boolpolicy-enrollment-id branch 2 times, most recently from c5d62dd to a52cc93 Compare August 5, 2026 03:25
… its members

A policy identity's AuditInfo always reported an empty enrollment ID, so
policy-owned inputs and outputs reach the auditor with no enrollment
attribution and downstream eid-keyed accounting misbooks them.

Resolve each component's audit info through the parent multiplex
deserializer (same recursive pattern as htlc.NewAuditDeserializer) and
report the enrollment ID shared by all components. A component with no
enrollment ID of its own (e.g. a nested composite spanning enrollments)
or disagreeing components yield the legacy empty value. Malformed audit
info -- an unresolvable component or a component count mismatch -- is an
error rather than a silent empty value, and every component is resolved
before the result is declared so later corruption is never masked.

Add a fuzz target for the new recursive deserialization path and wire it
into the nightly fuzz workflow.

Signed-off-by: Evan <evanyan@sign.global>
@EvanYan1024
EvanYan1024 force-pushed the fix/boolpolicy-enrollment-id branch from a52cc93 to 5db7643 Compare August 5, 2026 03:39
@AkramBitar AkramBitar added the enhancement New feature or request label Aug 5, 2026
@AkramBitar AkramBitar added this to the Q3/26 milestone Aug 5, 2026
@AkramBitar

Copy link
Copy Markdown
Contributor

@EvanYan1024

Thanks a lot for finding and solving this issues. Great work. Thanks a million.

Policy identities (boolpolicy) always returned an empty enrollment ID, so policy-owned inputs and outputs lost their ownership attribution and eid-based accounting either dropped or misbooked those amounts.

The fix resolves each component's audit info recursively through the multiplexer and returns the enrollment ID that all components share. If a component has none, or they disagree, the result is "". If a component can't be resolved or its audit info is malformed, it's an error.

My concern is the missing case, not the malformed one.

An empty component audit info is legal today: WrapAuditInfo accepts empty entries, and SignatureService.GetAuditInfo returns nil for an identity that isn't registered locally. With this change that empty entry fails resolution, and token/request.go turns the failure into a rejected audit record — so a transaction that used to audit fine with eid="" now fails.

@adecaro
Could you please let us know what your opinion in this one?

Thanks a lot,
Akram

… EID

An empty component audit info is legal: WrapAuditInfo accepts empty
entries and audit info lookup returns nil for identities not registered
locally. Resolve such components to "no enrollment ID" instead of
failing, so transactions that audited fine with an empty enrollment ID
keep doing so. Errors remain for malformed data: a non-empty component
that cannot be resolved or a component count mismatch, and a missing
component does not mask corruption in a later one.

Signed-off-by: Evan <evanyan@sign.global>
@EvanYan1024

Copy link
Copy Markdown
Contributor Author

Thanks Akram, this is a valid compatibility concern. You are right that the current implementation conflates two different cases: a malformed outer audit-info with the wrong number of component entries, and a correctly shaped policy audit-info containing an empty component entry.

The latter is legal in the existing flow: GetAuditInfo may return nil for an identity that is not registered locally, and WrapAuditInfo preserves that empty entry. Before this PR, such a policy resolved to an empty enrollment ID without failing the audit path.

I will preserve that behavior by treating an empty component audit-info as “no enrollment ID available”, while continuing to resolve all remaining non-empty components so that a later malformed entry is not masked. Count mismatches and non-empty but unresolvable audit infos will remain errors. I’ll add regression tests for the empty-entry case and for an empty entry followed by malformed data.

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EvanYan1024

Thanks for the fixes that you added the empty-component case reads correctly now, and the three new regression tests cover it.

I left three comments while reading the rest. The first is the only one I'd like to discuss with you to see if we need to resolve it before merging; the other two are minor.

Regards,
Akram

}
}

return eids[0], nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this fixes less of the auditor balance corruption than the PR description claims, and I'd like a second opinion on my reading.

As far as I can tell the output side still expands a policy owner into one row per component, each at the full quantity: token/request.go:1435 calls inputsAndOutputs(ctx, true, false, false), so noOutputForRecipient is false and the output loop iterates recipients, which for a policy owner comes from DeobfuscateTypedIdentityDeserializer.Recipients → the component list.

So for a $0 AND $1 policy whose two members share enrollment wallet-42 receiving 100, I believe OutputStream.ByEnrollmentID("wallet-42").Sum() is 200. Spending it back produces a single Input row (both drivers set Senders to a 1-element slice — fabtoken/v1/transfer.go:97, zkatdlog/nogh/v1/transfer.go:202) contributing 100, so ttxdb.Movements records +100 where the true net is 0.

If that's right, the drift goes from +2q to +q per round trip rather than to 0, and either the output-side expansion needs deduplicating by EID too, or the PR body should be softened. An integration assertion on HoldingsFilter.ByEnrollmentId for a multi-member same-enrollment policy would settle it either way — I haven't run one, so I could be wrong about the output loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and thanks for working through the arithmetic — I traced both sides and your reading of the output loop holds.

One detail worth adding, because it explains why the drift is +2q rather than +q before this PR: the two sides resolve enrollment IDs from different identities. Output rows are per component, so each already gets a real EID from GetEIDAndRH(recipient, ...). The input row carries the composite identity itself (sender.Identity is the token's owner, and both drivers set Senders to a single element), so its EID comes from the policy identity — which is precisely what returned "" before this change. For a two-member same-enrollment policy round-tripping q, where the correct movement is 0:

So this PR fixes the attribution half, and the amount half is #2148, which is already open: ttxdb/store.go applies UniquePerOutput() to the received sums and UniquePerInput() to the sent sum, collapsing member rows only where amounts aggregate while leaving the identity and revocation consumers seeing every row. Since HoldingsFilter sums MovementRecords, the ByEnrollmentId assertion you suggest is settled by the pair rather than by either PR alone.

That cross-reference is missing from the PR body here, which is what makes the claim read as broader than it is — I'll scope the body to the attribution gap and point at #2148 for the amount correction. Happy to add the integration assertion on HoldingsFilter.ByEnrollmentId for a multi-member same-enrollment policy in #2148 if you'd like it landed with that half.

if err := pi.Deserialize(id); err != nil {
return "", errors.Wrapf(err, "failed to deserialize policy identity")
}
if len(pi.Identities) != len(ei.IdentityAuditInfos) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether this should call validateComponentIdentities(pi.Identities) here. It's a new deserialization boundary over wire-supplied pi.Identities, and both siblings in this file do validate — GetAuditInfoMatcher (line 99) and DeserializeVerifier (line 124). The doc comment on the function in identity.go:170-178 also says validation "must also happen at the deserialization boundary to actually close the gap."

Concretely: an AND policy with the same identity in both component slots is rejected by DeserializeVerifier but I think accepted here, yielding a derived enrollment ID. That seems to matter most on the auditor path, since the auditor never runs the verifier as far as I can see (integration/token/fungible/views/auditor.go goes Audit → business checks → NewAuditApproveViewauditdb.Append, no IsValid), so Movements/TransactionRecords rows would get attributed for an identity that only gets rejected later at commit.

Low severity, but it looks like a one-liner.

@EvanYan1024 EvanYan1024 Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, added in f0ac58c.

On reachability: Audit reaches AuditRecord via inputsAndOutputs(ctx, true, false, false), so verifyActions is false there. The Validate path doesn't close the gap either — integration/token/fungible/views/auditor.go:42 does call auditor.Validate(tx) before Audit, which reaches Request.IsValid, but IsValid only verifies actions structurally: VerifyTransfer checks amounts, types and action shape and never deserializes an owner verifier. Owner verifiers are deserialized solely by the validator, at token/core/fabtoken/v1/validator/validator_transfer.go:48, which runs at commit time. So validateComponentIdentities is genuinely unreachable on the auditor path, as you say.

Worth flagging one behavioural consequence: such an identity now fails at audit time rather than being attributed and rejected later at commit. That's the opposite direction from the empty-component change earlier in this PR, but the two cases differ — an empty component audit info is legal, whereas a duplicate or empty component identity is already rejected by WrapPolicyIdentity and DeserializeVerifier, so no legitimate identity is affected. Covered by TestPolicyEnrollmentIDInvalidComponentIdentities.

if err != nil {
return "", errors.Wrapf(err, "failed to deserialize audit info of component [%d]", k)
}
eids[k] = memberAuditInfo.EnrollmentID()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

memberAuditInfo is dereferenced without a nil check. a.inner is the interface injected by the exported constructor, and the counterfeiter fake for driver2.AuditInfoDeserializer returns (nil, nil) when unstubbed, so I think NewAuditInfoDeserializer(defaultMock).DeserializeAuditInfo(...) panics rather than returning an error. The analogous recursive code in htlc/deserializer.go:177 sidesteps it by returning ai without dereferencing.

Probably not reachable through the real multiplexer wiring, so this is mostly about not handing callers a panic — if memberAuditInfo == nil { continue } above this line would do it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in f0ac58c. I reproduced it with a fake returning (nil, nil): SIGSEGV at deserializer.go:216, so it is a panic rather than an error as you expected. Guarded with continue rather than an error, which keeps it consistent with the missing-audit-info case just above — the component simply contributes no enrollment ID. TestPolicyEnrollmentIDNilMemberAuditInfo covers it.

You're right that it isn't reachable through the real multiplexer: EIDRHDeserializer.DeserializeAuditInfo either errors or returns the leaf result, and none of the x509, idemix, multisig or boolpolicy leaves return (nil, nil). The guard is there because NewAuditInfoDeserializer takes an exported interface.

EvanYan1024 and others added 4 commits August 10, 2026 10:01
…info

commonEnrollmentID accepted wire-supplied component identities without the
validation both siblings in the file apply, so a policy carrying a duplicate
or empty component still yielded a derived enrollment ID. The audit path
never runs the verifier that would reject it: Audit reaches AuditRecord with
verifyActions false, and auditor.Service.Validate, the only entry point that
calls IsValid, has no caller. Such an identity is therefore attributed in
movements and transaction records and only rejected later at commit.

Also skip a component whose inner deserializer reports neither audit info
nor error instead of dereferencing it. That is unreachable through the real
multiplexer, but NewAuditInfoDeserializer takes an exported interface, so an
implementation returning (nil, nil) turns a caller-visible error into a
panic.

Signed-off-by: Evan <evanyan@sign.global>
…ype resolution

The boolpolicy enrollment-ID note said "an unresolvable component" is an
error without saying which rule wins when a component is both of an
unknown identity type and carries no audit info. The exemption is applied
before the member's type is resolved, so that case yields an empty
enrollment ID. Say so, and pin the contract with a test alongside the
existing unknown-type-with-audit-info error case.

Signed-off-by: Evan <evanyan@sign.global>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

boolpolicy: policy identity AuditInfo always reports an empty enrollment ID

2 participants