Skip to content

Commit

Permalink
feat(sdk): Custom masking string option (#586)
Browse files Browse the repository at this point in the history
Also updated docs

Signed-off-by: Derek Trider <[email protected]>
  • Loading branch information
Derek Trider authored Sep 1, 2023
1 parent 260f88b commit 2dcdb29
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 43 deletions.
4 changes: 2 additions & 2 deletions cmd/wallet-sdk-gomobile/display/displaydata.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,11 @@ func (c *Claim) ValueType() string {
// For example, if the UI were to display "Given Name: Alice", then the Value would be "Alice".
// If no special formatting was applied to the display value, then this method will be equivalent to calling RawValue.
func (c *Claim) Value() string {
if c.claim.Value == "" {
if c.claim.Value == nil {
return c.claim.RawValue
}

return c.claim.Value
return *c.claim.Value
}

// RawValue returns the raw display value for this claim without any formatting.
Expand Down
30 changes: 30 additions & 0 deletions cmd/wallet-sdk-gomobile/display/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Opts struct {
additionalHeaders api.Headers
httpTimeout *time.Duration
disableHTTPClientTLSVerification bool
maskingString *string
}

// NewOpts returns a new Opts object.
Expand Down Expand Up @@ -69,3 +70,32 @@ func (o *Opts) DisableHTTPClientTLSVerify() *Opts {

return o
}

// SetMaskingString sets the string to be used when creating masked values for display.
// The substitution is done on a character-by-character basis, whereby each individual character to be masked
// will be replaced by the entire string. See the examples below to better understand exactly how the
// substitution works.
//
// (Note that any quote characters in the examples below are only there for readability reasons - they're not actually
// part of the values.)
//
// Scenario: The unmasked display value is 12345, and the issuer's metadata specifies that the first 3 characters are
// to be masked. The most common use-case is to substitute every masked character with a single character. This is
// achieved by specifying just a single character in the maskingString. Here's what the masked value would look like
// with different maskingString choices:
//
// maskingString: "•" --> •••45
// maskingString: "*" --> ***45
//
// It's also possible to specify multiple characters in the maskingString, or even an empty string if so desired.
// Here's what the masked value would like in such cases:
//
// maskingString: "???" --> ?????????45
// maskingString: "" --> 45
//
// If this option isn't used, then by default "•" characters (without the quotes) will be used for masking.
func (o *Opts) SetMaskingString(maskingString string) *Opts {
o.maskingString = &maskingString

return o
}
6 changes: 6 additions & 0 deletions cmd/wallet-sdk-gomobile/display/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ func generateGoAPIOpts(vcs *verifiable.CredentialsArray, issuerURI string,
goAPIOpts = append(goAPIOpts, goAPIOpt)
}

if opts.maskingString != nil {
goAPIOpt := goapicredentialschema.WithMaskingString(*opts.maskingString)

goAPIOpts = append(goAPIOpts, goAPIOpt)
}

return goAPIOpts, nil
}

Expand Down
62 changes: 62 additions & 0 deletions cmd/wallet-sdk-gomobile/display/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import (
"github.com/trustbloc/wallet-sdk/cmd/wallet-sdk-gomobile/verifiable"
)

const (
sensitiveIDLabel = "Sensitive ID"
reallySensitiveIDLabel = "Really Sensitive ID"
)

var (
//go:embed testdata/issuer_metadata.json
sampleIssuerMetadata []byte
Expand Down Expand Up @@ -67,6 +72,62 @@ func TestResolve(t *testing.T) {
vcs := verifiable.NewCredentialsArray()
vcs.Add(vc)

t.Run("With custom masking string", func(t *testing.T) {
t.Run(`"*"`, func(t *testing.T) {
opts := display.NewOpts().SetMaskingString("*")

resolvedDisplayData, err := display.Resolve(vcs, server.URL, opts)
require.NoError(t, err)

credentialDisplay := resolvedDisplayData.CredentialDisplayAtIndex(0)

for i := 0; i < credentialDisplay.ClaimsLength(); i++ {
claim := credentialDisplay.ClaimAtIndex(i)

if claim.Label() == sensitiveIDLabel {
require.Equal(t, "*****6789", claim.Value())
} else if claim.Label() == reallySensitiveIDLabel {
require.Equal(t, "*******", claim.Value())
}
}
})
t.Run(`"+++"`, func(t *testing.T) {
opts := display.NewOpts().SetMaskingString("+++")

resolvedDisplayData, err := display.Resolve(vcs, server.URL, opts)
require.NoError(t, err)

credentialDisplay := resolvedDisplayData.CredentialDisplayAtIndex(0)

for i := 0; i < credentialDisplay.ClaimsLength(); i++ {
claim := credentialDisplay.ClaimAtIndex(i)

if claim.Label() == sensitiveIDLabel {
require.Equal(t, "+++++++++++++++6789", claim.Value())
} else if claim.Label() == reallySensitiveIDLabel {
require.Equal(t, "+++++++++++++++++++++", claim.Value())
}
}
})
t.Run(`"" (empty string`, func(t *testing.T) {
opts := display.NewOpts().SetMaskingString("")

resolvedDisplayData, err := display.Resolve(vcs, server.URL, opts)
require.NoError(t, err)

credentialDisplay := resolvedDisplayData.CredentialDisplayAtIndex(0)

for i := 0; i < credentialDisplay.ClaimsLength(); i++ {
claim := credentialDisplay.ClaimAtIndex(i)

if claim.Label() == sensitiveIDLabel {
require.Equal(t, "6789", claim.Value())
} else if claim.Label() == reallySensitiveIDLabel {
require.Equal(t, "", claim.Value())
}
}
})
})
t.Run("Without additional headers", func(t *testing.T) {
t.Run("Without a preferred locale specified", func(t *testing.T) {
opts := display.NewOpts().SetHTTPTimeoutNanoseconds(0)
Expand Down Expand Up @@ -100,6 +161,7 @@ func TestResolve(t *testing.T) {
checkResolvedDisplayData(t, resolvedDisplayData)
})
})

t.Run("No credentials specified", func(t *testing.T) {
resolvedDisplayData, err := display.Resolve(nil, "", nil)
require.EqualError(t, err, "no credentials specified")
Expand Down
47 changes: 47 additions & 0 deletions cmd/wallet-sdk-gomobile/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,53 @@ from the issuer. See [Resolve Display](#resolve-display) for more information.
Display data objects can be serialized using the `serialize()` method (useful for storage) and parsed from serialized
form back into display data objects using the `parseDisplayData()` function.

## Options

The `resolveDisplay` function has a number of different options available. For a full list of available options, check
the associated `Opts` object. This section will highlight some especially notable ones:

### Set Masking String

The `setMaskingString(maskingString)` option allows you to specify the string to be used when creating masked values for
display. The substitution is done on a character-by-character basis, whereby each individual character to be masked
will be replaced by the entire string. See the examples below to better understand exactly how the substitution works.

#### Examples

Note that any quote characters used in these examples are only there for readability reasons - they're not actually
part of the values.

Scenario: The unmasked display value is 12345, and the issuer's metadata specifies that the first 3 characters are
to be masked. The most common use-case is to substitute every masked character with a single character. This is
achieved by specifying just a single character in the `maskingString`. Here's what the masked value would look like
with different `maskingString` choices:
```
maskingString="•" --> •••45
maskingString="*" --> ***45
```

It's also possible to specify multiple characters in the `maskingString`, or even an empty string if so desired.
Here's what the masked value would like in such cases:

```
maskingString="???" --> ?????????45
maskingString="" --> 45
```

If this option isn't used, then by default "•" characters (without the quotes) will be used for masking.

### Set Preferred Locale

Use the `setPreferredLocale` method to specify what locale to use for resolving display values. The effectiveness of
this option is contingent on what information the issuer provides. If the issuer specifies display value localizations
for your preferred locale, then those will be used whenever possible. If localized values aren't available (for your
preferred locale), then the first locale listed by the issuer will be used instead. Note that some pieces of display
data may be localized more or less than other pieces.

To determine what locales the resolved display values are in, use the various `locale()` methods available in the
display data. You can use this to figure out which display values are actually in your preferred locale and which
used a fallback default locale instead.

### The Display Object Structure

The structure of the display data object is as follows:
Expand Down
25 changes: 13 additions & 12 deletions pkg/credentialschema/credentialdisplay.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var (
)

func buildCredentialDisplays(vcs []*verifiable.Credential, credentialsSupported []issuer.SupportedCredential,
preferredLocale string,
preferredLocale, maskingString string,
) ([]CredentialDisplay, error) {
var credentialDisplays []CredentialDisplay

Expand All @@ -46,7 +46,8 @@ func buildCredentialDisplays(vcs []*verifiable.Credential, credentialsSupported
continue
}

credentialDisplay, err := buildCredentialDisplay(&credentialsSupported[i], subject, preferredLocale)
credentialDisplay, err := buildCredentialDisplay(&credentialsSupported[i], subject, preferredLocale,
maskingString)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -92,9 +93,9 @@ func haveMatchingTypes(supportedCredential *issuer.SupportedCredential, vc *veri
}

func buildCredentialDisplay(supportedCredential *issuer.SupportedCredential, subject *verifiable.Subject,
preferredLocale string,
preferredLocale, maskingString string,
) (*CredentialDisplay, error) {
resolvedClaims, err := resolveClaims(supportedCredential, subject, preferredLocale)
resolvedClaims, err := resolveClaims(supportedCredential, subject, preferredLocale, maskingString)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -145,14 +146,14 @@ func getSubject(vc *verifiable.Credential) (*verifiable.Subject, error) {
}

func resolveClaims(supportedCredential *issuer.SupportedCredential, credentialSubject *verifiable.Subject,
preferredLocale string,
preferredLocale, maskingString string,
) ([]ResolvedClaim, error) {
var resolvedClaims []ResolvedClaim

for fieldName, claim := range supportedCredential.CredentialSubject {
claim := claim // Resolves implicit memory aliasing warning from linter

resolvedClaim, err := resolveClaim(fieldName, &claim, credentialSubject, preferredLocale)
resolvedClaim, err := resolveClaim(fieldName, &claim, credentialSubject, preferredLocale, maskingString)
if err != nil && !errors.Is(err, errNoClaimDisplays) && !errors.Is(err, errClaimValueNotFoundInVC) {
return nil, err
}
Expand All @@ -166,7 +167,7 @@ func resolveClaims(supportedCredential *issuer.SupportedCredential, credentialSu
}

func resolveClaim(fieldName string, claim *issuer.Claim, credentialSubject *verifiable.Subject,
preferredLocale string,
preferredLocale, maskingString string,
) (*ResolvedClaim, error) {
if len(claim.LocalizedClaimDisplays) == 0 {
return nil, errNoClaimDisplays
Expand All @@ -181,15 +182,15 @@ func resolveClaim(fieldName string, claim *issuer.Claim, credentialSubject *veri

rawValue := fmt.Sprintf("%v", untypedValue)

var value string
var value *string

if claim.Mask != "" {
maskedValue, err := getMaskedValue(rawValue, claim.Mask)
maskedValue, err := getMaskedValue(rawValue, claim.Mask, maskingString)
if err != nil {
return nil, err
}

value = maskedValue
value = &maskedValue
}

return &ResolvedClaim{
Expand All @@ -205,7 +206,7 @@ func resolveClaim(fieldName string, claim *issuer.Claim, credentialSubject *veri
}, nil
}

func getMaskedValue(rawValue, maskingPattern string) (string, error) {
func getMaskedValue(rawValue, maskingPattern, maskingString string) (string, error) {
// Trim "regex(" from the beginning and ")" from the end
regex := maskingPattern[6 : len(maskingPattern)-1]

Expand All @@ -217,7 +218,7 @@ func getMaskedValue(rawValue, maskingPattern string) (string, error) {
// Always use the first submatch.
valueToBeMasked := r.ReplaceAllString(rawValue, "$1")

maskedValue := strings.ReplaceAll(rawValue, valueToBeMasked, strings.Repeat("•", len(valueToBeMasked)))
maskedValue := strings.ReplaceAll(rawValue, valueToBeMasked, strings.Repeat(maskingString, len(valueToBeMasked)))

return maskedValue, nil
}
Expand Down
10 changes: 8 additions & 2 deletions pkg/credentialschema/credentialschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ package credentialschema
// same order.
// This method requires one VC source and one issuer metadata source. See opts.go for more information.
func Resolve(opts ...ResolveOpt) (*ResolvedDisplayData, error) {
vcs, metadata, preferredLocale, err := processOpts(opts)
vcs, metadata, preferredLocale, maskingString, err := processOpts(opts)
if err != nil {
return nil, err
}

credentialDisplays, err := buildCredentialDisplays(vcs, metadata.CredentialsSupported, preferredLocale)
if maskingString == nil {
defaultMaskingString := "•"
maskingString = &defaultMaskingString
}

credentialDisplays, err := buildCredentialDisplays(vcs, metadata.CredentialsSupported, preferredLocale,
*maskingString)
if err != nil {
return nil, err
}
Expand Down
Loading

0 comments on commit 2dcdb29

Please sign in to comment.