Skip to content
Open
78 changes: 78 additions & 0 deletions v3/lints/etsi/lint_qcstatem_psd2_valid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* ZLint Copyright 2026 Regents of the University of Michigan
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

package etsi

import (
"github.com/zmap/zcrypto/x509"
"github.com/zmap/zlint/v3/lint"
"github.com/zmap/zlint/v3/util"
)

type qcStatemPsd2Valid struct{}

// ETSI TS 119 495 V1.8.1 (2026-04), Section 5.1:
//
// GEN-5.1-3: The syntax of the defined statement shall comply with ASN.1 [6]. The
// complete ASN.1 module for all defined statements shall be as provided in Annex A;
// it takes precedence over the ASN.1 definition provided in the body of the present
// document, in case of discrepancy.
//
// ETSI TS 119 495 V1.8.1 (2026-04), Annex A (normative): ASN.1 Declaration:
//
// PSD2QcType ::= SEQUENCE{
// rolesOfPSP RolesOfPSP,
// nCAName NCAName,
// nCAId NCAId }
//
// NCAName ::= UTF8String (SIZE(1..256))
// NCAId ::= UTF8String (SIZE(1..256))
//
// RolesOfPSP ::= SEQUENCE OF RoleOfPSP
//
// RoleOfPSP ::= SEQUENCE{
// roleOfPspOid RoleOfPspOid,
// roleOfPspName RoleOfPspName}
func init() {
lint.RegisterCertificateLint(&lint.CertificateLint{
LintMetadata: lint.LintMetadata{
Name: "e_qcstatem_psd2_valid",
Description: "Checks that a QC Statement of the type id-etsi-psd2-qcStatement has the correct ASN.1 encoding",
Citation: "ETSI TS 119 495 V1.8.1 (2026-04), Section 5.1, GEN-5.1-3, and Annex A (normative): ASN.1 Declaration",
Source: lint.EtsiEsi,
EffectiveDate: util.EtsiTs119495_V1_1_2_Date,
},
Lint: NewQcStatemPsd2Valid,
})
}

func NewQcStatemPsd2Valid() lint.LintInterface {
return &qcStatemPsd2Valid{}
}

func (l *qcStatemPsd2Valid) CheckApplies(c *x509.Certificate) bool {
if !util.IsExtInCert(c, util.QcStateOid) {
return false
}
return util.ParseQcStatem(util.GetExtFromCert(c, util.QcStateOid).Value, util.IdEtsiPsd2Statem).IsPresent()
}

func (l *qcStatemPsd2Valid) Execute(c *x509.Certificate) *lint.LintResult {
ext := util.GetExtFromCert(c, util.QcStateOid)
s := util.ParseQcStatem(ext.Value, util.IdEtsiPsd2Statem)
if s.GetErrorInfo() != "" {
return &lint.LintResult{Status: lint.Error, Details: s.GetErrorInfo()}
}
return &lint.LintResult{Status: lint.Pass}
}
42 changes: 42 additions & 0 deletions v3/lints/etsi/lint_qcstatem_psd2_valid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package etsi

/*
* ZLint Copyright 2026 Regents of the University of Michigan
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

import (
"testing"

"github.com/zmap/zlint/v3/lint"
"github.com/zmap/zlint/v3/test"
)

func TestEtsiQcStatemPsd2Valid(t *testing.T) {
m := map[string]lint.LintStatus{
"QcStmtEtsiPsd2ValidCert01.pem": lint.Pass,
"QcStmtEtsiPsd2WrongEncodingCert01.pem": lint.Error,
"QcStmtEtsiValidCert11.pem": lint.NA,
}
for inputPath, expected := range m {
out := test.TestLint("e_qcstatem_psd2_valid", inputPath)
if out.Status != expected {
t.Errorf("%s: expected %s, got %s", inputPath, expected, out.Status)
}
}

const wrongEncodingDetails = "error with ASN.1 encoding, possibly a wrong ASN.1 string type was used"
out := test.TestLint("e_qcstatem_psd2_valid", "QcStmtEtsiPsd2WrongEncodingCert01.pem")
if out.Details != wrongEncodingDetails {
t.Errorf("QcStmtEtsiPsd2WrongEncodingCert01.pem: expected details %q, got %q", wrongEncodingDetails, out.Details)
}
}
42 changes: 42 additions & 0 deletions v3/testdata/QcStmtEtsiPsd2ValidCert01.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 2 (0x2)
Signature Algorithm: ecdsa-with-SHA256
Issuer:
Validity
Not Before: Jan 1 00:00:00 2020 GMT
Not After : Jan 1 00:00:00 2030 GMT
Subject: CN = PSD2 Test Leaf
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:4b:bf:a0:29:15:25:63:9d:0d:30:fe:c1:82:16:
4a:5d:15:18:15:0f:b2:89:1a:a9:c8:da:77:97:2f:
4d:9c:b3:b3:66:76:64:1f:3f:ff:af:ac:55:2a:13:
9c:5d:7e:b8:9c:83:f2:74:b4:d3:5d:c3:cd:95:3b:
50:94:2f:15:34
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Basic Constraints: critical
CA:FALSE
qcStatements:
0;09......'.0/0.0.......'....PSP_AS..Banco de Espa..a..ES-BDE
Signature Algorithm: ecdsa-with-SHA256
Signature Value:
30:44:02:20:59:51:89:da:a6:4a:92:de:c6:e3:9e:a1:3e:0b:
8e:35:c9:65:74:48:93:b4:e5:36:01:a7:e4:c0:a6:09:a8:7a:
02:20:23:c2:7e:00:dc:80:c6:93:b8:15:98:42:4a:18:6f:b1:
18:44:ef:77:08:de:b8:c7:f3:78:30:24:dc:e6:da:86
-----BEGIN CERTIFICATE-----
MIIBYjCCAQmgAwIBAgIBAjAKBggqhkjOPQQDAjAAMB4XDTIwMDEwMTAwMDAwMFoX
DTMwMDEwMTAwMDAwMFowGTEXMBUGA1UEAxMOUFNEMiBUZXN0IExlYWYwWTATBgcq
hkjOPQIBBggqhkjOPQMBBwNCAARLv6ApFSVjnQ0w/sGCFkpdFRgVD7KJGqnI2neX
L02cs7NmdmQfP/+vrFUqE5xdfricg/J0tNNdw82VO1CULxU0o1swWTAMBgNVHRMB
Af8EAjAAMEkGCCsGAQUFBwEDBD0wOzA5BgYEAIGYJwIwLzATMBEGBwQAgZgnAQEM
BlBTUF9BUwwQQmFuY28gZGUgRXNwYcOxYQwGRVMtQkRFMAoGCCqGSM49BAMCA0cA
MEQCIFlRidqmSpLexuOeoT4LjjXJZXRIk7TlNgGn5MCmCah6AiAjwn4A3IDGk7gV
mEJKGG+xGETvdwjeuMfzeDAk3Obahg==
-----END CERTIFICATE-----
42 changes: 42 additions & 0 deletions v3/testdata/QcStmtEtsiPsd2WrongEncodingCert01.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 3 (0x3)
Signature Algorithm: ecdsa-with-SHA256
Issuer:
Validity
Not Before: Jan 1 00:00:00 2020 GMT
Not After : Jan 1 00:00:00 2030 GMT
Subject: CN = PSD2 Test Leaf
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:37:14:76:df:e7:27:f2:6c:83:c1:3a:60:10:74:
60:2c:96:6c:70:97:55:4d:e8:4e:e8:09:58:0d:a4:
67:93:ba:f4:ba:b2:96:08:37:d9:84:95:9e:f7:64:
76:86:89:47:a2:25:54:6d:5c:76:d7:48:1c:bc:6d:
d8:02:fb:49:1c
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Basic Constraints: critical
CA:FALSE
qcStatements:
0:08......'.0.0.0.......'....PSP_AS..Banco de Espana..ES-BDE
Signature Algorithm: ecdsa-with-SHA256
Signature Value:
30:45:02:20:05:e4:aa:35:90:c8:4a:84:ab:4f:7e:cc:f5:32:
50:aa:6a:84:cb:81:55:65:1c:7d:6a:3d:b3:74:0e:08:c5:1b:
02:21:00:8d:d6:95:fa:38:73:eb:0c:ae:0b:af:7c:12:f3:72:
e2:73:24:8f:af:2b:7a:29:ca:e3:72:3e:bf:e5:10:fe:d5
-----BEGIN CERTIFICATE-----
MIIBYjCCAQigAwIBAgIBAzAKBggqhkjOPQQDAjAAMB4XDTIwMDEwMTAwMDAwMFoX
DTMwMDEwMTAwMDAwMFowGTEXMBUGA1UEAxMOUFNEMiBUZXN0IExlYWYwWTATBgcq
hkjOPQIBBggqhkjOPQMBBwNCAAQ3FHbf5yfybIPBOmAQdGAslmxwl1VN6E7oCVgN
pGeTuvS6spYIN9mElZ73ZHaGiUeiJVRtXHbXSBy8bdgC+0kco1owWDAMBgNVHRMB
Af8EAjAAMEgGCCsGAQUFBwEDBDwwOjA4BgYEAIGYJwIwLjATMBEGBwQAgZgnAQEM
BlBTUF9BUxMPQmFuY28gZGUgRXNwYW5hDAZFUy1CREUwCgYIKoZIzj0EAwIDSAAw
RQIgBeSqNZDISoSrT37M9TJQqmqEy4FVZRx9aj2zdA4IxRsCIQCN1pX6OHPrDK4L
r3wS83LicySPryt6Kcrjcj6/5RD+1Q==
-----END CERTIFICATE-----
1 change: 1 addition & 0 deletions v3/util/oid.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ var (
QEVCPwPolicyOID = asn1.ObjectIdentifier{0, 4, 0, 194112, 1, 4}
QNCPwPolicyOID = asn1.ObjectIdentifier{0, 4, 0, 194112, 1, 5}
QNCPwgenPolicyOID = asn1.ObjectIdentifier{0, 4, 0, 194112, 1, 6}
IdEtsiPsd2Statem = asn1.ObjectIdentifier{0, 4, 0, 19495, 2} // ETSI TS 119 495 V1.1.2, Annex A: id-etsi-psd2-qcStatement
)

const (
Expand Down
57 changes: 57 additions & 0 deletions v3/util/qc_stmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"bytes"
"fmt"
"reflect"
"unicode/utf8"

"github.com/zmap/zcrypto/encoding/asn1"
"github.com/zmap/zcrypto/x509"
Expand Down Expand Up @@ -99,6 +100,22 @@ type EtsiQcPds struct {
PdsLocations []PdsLocation
}

type RoleOfPSP struct {
RoleOfPspOid asn1.ObjectIdentifier
RoleOfPspName string `asn1:"utf8"`
}

type PSD2QcType struct {
RolesOfPSP []RoleOfPSP
NCAName string `asn1:"utf8"`
NCAId string `asn1:"utf8"`
}

type EtsiPsd2 struct {
etsiBase
Decoded PSD2QcType
}

func AppendToStringSemicolonDelim(this *string, s string) {
if len(*this) > 0 && len(s) > 0 {
(*this) += "; "
Expand All @@ -119,6 +136,34 @@ func checkAsn1Reencoding(i interface{}, originalEncoding []byte, appendIfCompari
return result
}

// psd2NameFieldMinLen and psd2NameFieldMaxLen are the SIZE(1..256) bounds
// that ETSI TS 119 495, Annex A places on the NCAName, NCAId, and
// RoleOfPspName UTF8String fields of PSD2QcType.
const (
psd2NameFieldMinLen = 1
psd2NameFieldMaxLen = 256
)

// checkPsd2QcTypeSizeConstraints validates the Annex A SIZE(1..256) bound on
// NCAName, NCAId, and each RoleOfPspName. It counts Unicode characters
// (runes), not bytes, since the bound applies to a UTF8String's SIZE.
func checkPsd2QcTypeSizeConstraints(psd2 PSD2QcType) string {
result := ""
checkFieldLen := func(fieldName, value string) {
if n := utf8.RuneCountInString(value); n < psd2NameFieldMinLen || n > psd2NameFieldMaxLen {
AppendToStringSemicolonDelim(&result, fmt.Sprintf(
"%s must be between %d and %d UTF8String characters, got %d",
fieldName, psd2NameFieldMinLen, psd2NameFieldMaxLen, n))
}
}
checkFieldLen("NCAName", psd2.NCAName)
checkFieldLen("NCAId", psd2.NCAId)
for _, role := range psd2.RolesOfPSP {
checkFieldLen("RoleOfPspName", role.RoleOfPspName)
}
return result
}

func IsAnyEtsiQcStatementPresent(extVal []byte) bool {
oidList := make([]*asn1.ObjectIdentifier, 6)
oidList[0] = &IdEtsiQcsQcCompliance
Expand Down Expand Up @@ -244,6 +289,18 @@ func ParseQcStatem(extVal []byte, sought asn1.ObjectIdentifier) EtsiQcStmtIf {
return etsiBase{errorInfo: "error parsing IdEtsiQcsQcType extension statementInfo field", isPresent: true}
}
return qcType
} else if statem.Oid.Equal(IdEtsiPsd2Statem) {
etsiObj := EtsiPsd2{etsiBase: etsiBase{isPresent: true}}
rest, err := asn1.Unmarshal(statem.Any.FullBytes, &etsiObj.Decoded)
if len(rest) != 0 || err != nil {
etsiObj.errorInfo = "error parsing the statementInfo field"
} else {
AppendToStringSemicolonDelim(&etsiObj.errorInfo,
checkAsn1Reencoding(reflect.ValueOf(etsiObj.Decoded).Interface(), statem.Any.FullBytes,
"error with ASN.1 encoding, possibly a wrong ASN.1 string type was used"))
AppendToStringSemicolonDelim(&etsiObj.errorInfo, checkPsd2QcTypeSizeConstraints(etsiObj.Decoded))
}
return etsiObj
} else {
return etsiBase{errorInfo: "", isPresent: true}
}
Expand Down
Loading
Loading