From 936f0e4dde037c9cc1182f03595c2b61515063c8 Mon Sep 17 00:00:00 2001 From: David Zech Date: Mon, 2 Oct 2023 13:19:06 -0700 Subject: [PATCH] Add Expanded CMS Support * Support BER Encoded CMS * Support Attached CMS * Support Signed Attributes CMS --- Package.swift | 2 +- Sources/X509/CMakeLists.txt | 1 + .../CMSAttribute.swift | 69 ++++++ .../CMSContentInfo.swift | 14 +- .../CMSEncapsulatedContentInfo.swift | 14 +- .../CMSOperations.swift | 199 +++++++++++++++--- .../CMSSignature.swift | 29 ++- .../CMSSignedData.swift | 34 ++- .../CMSSignerIdentifier.swift | 17 +- .../CMSSignerInfo.swift | 166 ++++++++++++++- .../X509BaseTypes/AlgorithmIdentifier.swift | 7 +- Tests/X509Tests/CMSTests.swift | 149 ++++++++++++- 12 files changed, 642 insertions(+), 59 deletions(-) create mode 100644 Sources/X509/CryptographicMessageSyntax/CMSAttribute.swift diff --git a/Package.swift b/Package.swift index 05304773..be0227d6 100644 --- a/Package.swift +++ b/Package.swift @@ -76,7 +76,7 @@ let package = Package( if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { package.dependencies += [ .package(url: "https://github.com/apple/swift-crypto.git", "2.5.0" ..< "4.0.0"), - .package(url: "https://github.com/apple/swift-asn1.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-asn1.git", from: "1.1.0"), .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), ] } else { diff --git a/Sources/X509/CMakeLists.txt b/Sources/X509/CMakeLists.txt index 480c3a26..b66b81e1 100644 --- a/Sources/X509/CMakeLists.txt +++ b/Sources/X509/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(X509 "CertificatePublicKey.swift" "CertificateSerialNumber.swift" "CertificateVersion.swift" + "CryptographicMessageSyntax/CMSAttribute.swift" "CryptographicMessageSyntax/CMSContentInfo.swift" "CryptographicMessageSyntax/CMSEncapsulatedContentInfo.swift" "CryptographicMessageSyntax/CMSIssuerAndSerialNumber.swift" diff --git a/Sources/X509/CryptographicMessageSyntax/CMSAttribute.swift b/Sources/X509/CryptographicMessageSyntax/CMSAttribute.swift new file mode 100644 index 00000000..59c1ee4d --- /dev/null +++ b/Sources/X509/CryptographicMessageSyntax/CMSAttribute.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftCertificates open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftCertificates project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftCertificates project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftASN1 + +/// ``CMSAttribute`` is defined in ASN.1 as: +/// ``` +/// Attribute ::= SEQUENCE { +/// attrType OBJECT IDENTIFIER, +/// attrValues SET OF AttributeValue } +/// +/// AttributeValue ::= ANY +/// ``` +@usableFromInline +struct CMSAttribute: DERImplicitlyTaggable, BERImplicitlyTaggable, Hashable, Sendable { + + @inlinable + static var defaultIdentifier: ASN1Identifier { + .sequence + } + + @usableFromInline var attrType: ASN1ObjectIdentifier + @usableFromInline var attrValues: [ASN1Any] + + @inlinable + init(derEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + self = try DER.sequence(rootNode, identifier: identifier) { nodes in + let attrType = try ASN1ObjectIdentifier(derEncoded: &nodes) + let attrValues = try DER.set(of: ASN1Any.self, identifier: .set, nodes: &nodes) + + return .init(attrType: attrType, attrValues: attrValues) + } + } + + @inlinable + init(berEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + self = try BER.sequence(rootNode, identifier: identifier) { nodes in + let attrType = try ASN1ObjectIdentifier(berEncoded: &nodes) + let attrValues = try BER.set(of: ASN1Any.self, identifier: .set, nodes: &nodes) + + return .init(attrType: attrType, attrValues: attrValues) + } + } + + @inlinable + init(attrType: ASN1ObjectIdentifier, attrValues: [ASN1Any]) { + self.attrType = attrType + self.attrValues = attrValues + } + + @inlinable + func serialize(into coder: inout DER.Serializer, withIdentifier identifier: ASN1Identifier) throws { + try coder.appendConstructedNode(identifier: identifier) { coder in + try coder.serialize(self.attrType) + try coder.serializeSetOf(self.attrValues) + } + } +} diff --git a/Sources/X509/CryptographicMessageSyntax/CMSContentInfo.swift b/Sources/X509/CryptographicMessageSyntax/CMSContentInfo.swift index 6d68a525..933f09b8 100644 --- a/Sources/X509/CryptographicMessageSyntax/CMSContentInfo.swift +++ b/Sources/X509/CryptographicMessageSyntax/CMSContentInfo.swift @@ -44,7 +44,7 @@ extension ASN1ObjectIdentifier { /// ContentType ::= OBJECT IDENTIFIER /// ``` @usableFromInline -struct CMSContentInfo: DERImplicitlyTaggable, Hashable, Sendable { +struct CMSContentInfo: DERImplicitlyTaggable, BERImplicitlyTaggable, Hashable, Sendable { @inlinable static var defaultIdentifier: ASN1Identifier { .sequence @@ -74,6 +74,18 @@ struct CMSContentInfo: DERImplicitlyTaggable, Hashable, Sendable { } } + @inlinable + init(berEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + self = try BER.sequence(rootNode, identifier: identifier) { nodes in + let contentType = try ASN1ObjectIdentifier(derEncoded: &nodes) + + let content = try BER.explicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { node in + ASN1Any(berEncoded: node) + } + return .init(contentType: contentType, content: content) + } + } + @inlinable func serialize(into coder: inout DER.Serializer, withIdentifier identifier: ASN1Identifier) throws { try coder.appendConstructedNode(identifier: identifier) { coder in diff --git a/Sources/X509/CryptographicMessageSyntax/CMSEncapsulatedContentInfo.swift b/Sources/X509/CryptographicMessageSyntax/CMSEncapsulatedContentInfo.swift index 7f573bd7..88e3ef86 100644 --- a/Sources/X509/CryptographicMessageSyntax/CMSEncapsulatedContentInfo.swift +++ b/Sources/X509/CryptographicMessageSyntax/CMSEncapsulatedContentInfo.swift @@ -22,7 +22,7 @@ import SwiftASN1 /// ContentType ::= OBJECT IDENTIFIER /// ``` @usableFromInline -struct CMSEncapsulatedContentInfo: DERImplicitlyTaggable, Hashable, Sendable { +struct CMSEncapsulatedContentInfo: DERImplicitlyTaggable, BERImplicitlyTaggable, Hashable, Sendable { @inlinable static var defaultIdentifier: ASN1Identifier { .sequence @@ -52,6 +52,18 @@ struct CMSEncapsulatedContentInfo: DERImplicitlyTaggable, Hashable, Sendable { } } + @inlinable + init(berEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + self = try BER.sequence(rootNode, identifier: identifier) { nodes in + let eContentType = try ASN1ObjectIdentifier(derEncoded: &nodes) + let eContent = try BER.optionalExplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { node in + try ASN1OctetString(berEncoded: node) + } + + return .init(eContentType: eContentType, eContent: eContent) + } + } + @inlinable func serialize(into coder: inout DER.Serializer, withIdentifier identifier: ASN1Identifier) throws { try coder.appendConstructedNode( diff --git a/Sources/X509/CryptographicMessageSyntax/CMSOperations.swift b/Sources/X509/CryptographicMessageSyntax/CMSOperations.swift index c8548cee..71a6b09b 100644 --- a/Sources/X509/CryptographicMessageSyntax/CMSOperations.swift +++ b/Sources/X509/CryptographicMessageSyntax/CMSOperations.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import Foundation import SwiftASN1 +import Crypto public enum CMS { @_spi(CMS) @@ -83,6 +84,37 @@ public enum CMS { return try CMSContentInfo(signedData) } + @_spi(CMS) + @inlinable + public static func isValidAttachedSignature( + signatureBytes: SignatureBytes, + additionalIntermediateCertificates: [Certificate] = [], + trustRoots: CertificateStore, + diagnosticCallback: ((VerificationDiagnostic) -> Void)? = nil, + microsoftCompatible: Bool = false, + @PolicyBuilder policy: () throws -> some VerifierPolicy + ) async rethrows -> SignatureVerificationResult { + do { + // this means we parse the blob twice, but that's probably better than repeating a lot of code. + let parsedSignature = try CMSContentInfo(berEncoded: ArraySlice(signatureBytes)) + guard let attachedData = try parsedSignature.signedData?.encapContentInfo.eContent else { + return .failure(.init(invalidCMSBlockReason: "No attached content")) + } + + return try await isValidSignature( + dataBytes: attachedData.bytes, + signatureBytes: signatureBytes, + trustRoots: trustRoots, + diagnosticCallback: diagnosticCallback, + microsoftCompatible: microsoftCompatible, + allowAttachedContent: true, + policy: policy + ) + } catch { + return .failure(.invalidCMSBlock(.init(reason: String(describing: error)))) + } + } + @_spi(CMS) @inlinable public static func isValidSignature< @@ -94,26 +126,68 @@ public enum CMS { additionalIntermediateCertificates: [Certificate] = [], trustRoots: CertificateStore, diagnosticCallback: ((VerificationDiagnostic) -> Void)? = nil, + microsoftCompatible: Bool = false, + allowAttachedContent: Bool = false, @PolicyBuilder policy: () throws -> some VerifierPolicy ) async rethrows -> SignatureVerificationResult { let signedData: CMSSignedData let signingCert: Certificate do { - let parsedSignature = try CMSContentInfo(derEncoded: ArraySlice(signatureBytes)) + let parsedSignature = try CMSContentInfo(berEncoded: ArraySlice(signatureBytes)) guard let _signedData = try parsedSignature.signedData else { return .failure(.init(invalidCMSBlockReason: "Unable to parse signed data")) } signedData = _signedData - // We have a bunch of very specific requirements here: in particular, we need to have only one signature. We also only want - // to tolerate v1 signatures and detached signatures. - guard signedData.version == .v1, signedData.signerInfos.count == 1, - signedData.encapContentInfo.eContentType == .cmsData, - signedData.encapContentInfo.eContent == nil - else { + guard signedData.signerInfos.count == 1 else { + return .failure(.init(invalidCMSBlockReason: "Too many signatures")) + } + + switch signedData.version { + case .v1: + // If no attribute certificates are present in the certificates field, the + // encapsulated content type is id-data, and all of the elements of + // SignerInfos are version 1, then the value of version shall be 1. + guard signedData.encapContentInfo.eContentType == .cmsData, + signedData.signerInfos.allSatisfy({ $0.version == .v1 }) + else { + return .failure(.init(invalidCMSBlockReason: "Invalid v1 signed data: \(signedData)")) + } + + case .v3: + // no v2 Attribute Certificates are allowed, but we don't currently support that anyway + guard + signedData.encapContentInfo.eContentType == .cmsData + || signedData.encapContentInfo.eContentType == .cmsSignedData + else { + return .failure(.init(invalidCMSBlockReason: "Invalid v3 signed data: \(signedData)")) + } + break + + case .v4: + guard + signedData.encapContentInfo.eContentType == .cmsData + || signedData.encapContentInfo.eContentType == .cmsSignedData + else { + return .failure(.init(invalidCMSBlockReason: "Invalid v4 signed data: \(signedData)")) + } + break + + default: + // v2 and v5 are not for SignedData return .failure(.init(invalidCMSBlockReason: "Invalid signed data: \(signedData)")) } + if let attachedContent = signedData.encapContentInfo.eContent { + guard allowAttachedContent else { + return .failure(.init(invalidCMSBlockReason: "Attached content data not allowed")) + } + // we will tolerate attached content, and simply check if what the caller provided matches the attached content. + guard dataBytes.elementsEqual(attachedContent.bytes) else { + return .failure(.init(invalidCMSBlockReason: "Attached content data does not match provided data")) + } + } + // This subscript is safe, we confirmed a count of 1 above. let signer = signedData.signerInfos[0] @@ -132,10 +206,34 @@ public enum CMS { // Convert the signature algorithm to confirm we understand it. // We also want to confirm the digest algorithm matches the signature algorithm. - let signatureAlgorithm = Certificate.SignatureAlgorithm(algorithmIdentifier: signer.signatureAlgorithm) - let expectedDigestAlgorithm = try AlgorithmIdentifier(digestAlgorithmFor: signatureAlgorithm) - guard expectedDigestAlgorithm == signer.digestAlgorithm else { - return .failure(.init(invalidCMSBlockReason: "Digest and signature algorithm mismatch")) + var signatureAlgorithm = Certificate.SignatureAlgorithm(algorithmIdentifier: signer.signatureAlgorithm) + + // For legacy reasons originating from Microsoft, some signatureAlgorithms will incorrectly be `ecPublicKey` + // instead of a correct Signature Algorithm Identifier. This affects macOS systems using Security.framework by default. + if microsoftCompatible + && signer.signatureAlgorithm.algorithm == ASN1ObjectIdentifier.AlgorithmIdentifier.idEcPublicKey + { + // We're under microsoft compatibility, so we can assume that the digest algorithm is ECDSA + let sigAlgID: AlgorithmIdentifier + switch signer.digestAlgorithm { + case .sha256: + sigAlgID = .ecdsaWithSHA256 + + case .sha384: + sigAlgID = .ecdsaWithSHA384 + + case .sha512: + sigAlgID = .ecdsaWithSHA512 + + default: + return .failure(.init(invalidCMSBlockReason: "Invalid digest algorithm")) + } + signatureAlgorithm = Certificate.SignatureAlgorithm(algorithmIdentifier: sigAlgID) + } else { + let expectedDigestAlgorithm = try AlgorithmIdentifier(digestAlgorithmFor: signatureAlgorithm) + guard expectedDigestAlgorithm == signer.digestAlgorithm else { + return .failure(.init(invalidCMSBlockReason: "Digest and signature algorithm mismatch")) + } } // Ok, now we need to find the signer. We expect to find them in the list of certificates provided @@ -147,22 +245,61 @@ public enum CMS { // Ok at this point we've done the cheap stuff and we're fairly confident we have the entity who should have // done the signing. Our next step is to confirm that they did in fact sign the data. For that we have to compute - // the digest and validate the signature. + // the digest and validate the signature. If SignedAttributes (Optional) is present, the Signature is over the DER encoding + // of the entire SignedAttributes, and not the immediate content data. let signature = try Certificate.Signature( signatureAlgorithm: signatureAlgorithm, signatureBytes: signer.signature ) - guard - signingCert.publicKey.isValidSignature( - signature, - for: dataBytes, - signatureAlgorithm: signatureAlgorithm - ) - else { - return .failure( - .init(invalidCMSBlockReason: "Invalid signature from signing certificate: \(signingCert)") - ) + if let signedAttrs = signer.signedAttrs { + guard let messageDigest = try signedAttrs.messageDigest else { + return .failure(.init(invalidCMSBlockReason: "Missing message digest from signed attributes")) + } + + let digestAlgorithm = try AlgorithmIdentifier(digestAlgorithmFor: signatureAlgorithm) + let actualDigest = try Digest.computeDigest(for: dataBytes, using: digestAlgorithm) + + let actualDigestSequence: any Sequence + switch actualDigest { + case .insecureSHA1(let sha1): + actualDigestSequence = sha1 + case .sha256(let sha256): + actualDigestSequence = sha256 + case .sha384(let sha384): + actualDigestSequence = sha384 + case .sha512(let sha512): + actualDigestSequence = sha512 + } + + guard actualDigestSequence.elementsEqual(messageDigest) else { + return .failure(.init(invalidCMSBlockReason: "Message digest mismatch")) + } + + guard + signingCert.publicKey.isValidSignature( + signature, + for: try signer._signedAttrsBytes(), + signatureAlgorithm: signatureAlgorithm + ) + else { + return .failure( + .init(invalidCMSBlockReason: "Invalid signature from signing certificate: \(signingCert)") + ) + } + } else { + guard + signingCert.publicKey.isValidSignature( + signature, + for: dataBytes, + signatureAlgorithm: signatureAlgorithm + ) + else { + return .failure( + .init(invalidCMSBlockReason: "Invalid signature from signing certificate: \(signingCert)") + ) + } } + } catch { return .failure(.invalidCMSBlock(.init(reason: String(describing: error)))) } @@ -242,19 +379,15 @@ extension Array where Element == Certificate { func certificate(signerInfo: CMSSignerInfo) throws -> Certificate? { switch signerInfo.signerIdentifier { case .issuerAndSerialNumber(let issuerAndSerialNumber): - for cert in self { - if cert.issuer == issuerAndSerialNumber.issuer - && cert.serialNumber == issuerAndSerialNumber.serialNumber - { - return cert - } + return self.first { cert in + cert.issuer == issuerAndSerialNumber.issuer && cert.serialNumber == issuerAndSerialNumber.serialNumber } - case .subjectKeyIdentifier: - // This is unsupported for now. - return nil - } - return nil + case .subjectKeyIdentifier(let subjectKeyIdentifier): + return self.first { cert in + (try? cert.extensions.subjectKeyIdentifier)?.keyIdentifier == subjectKeyIdentifier.keyIdentifier + } + } } } diff --git a/Sources/X509/CryptographicMessageSyntax/CMSSignature.swift b/Sources/X509/CryptographicMessageSyntax/CMSSignature.swift index 2080ae44..60d17ee0 100644 --- a/Sources/X509/CryptographicMessageSyntax/CMSSignature.swift +++ b/Sources/X509/CryptographicMessageSyntax/CMSSignature.swift @@ -13,6 +13,11 @@ //===----------------------------------------------------------------------===// import SwiftASN1 +#if canImport(Darwin) +import Foundation +#else +@preconcurrency import Foundation +#endif /// A representation of a CMS signature over some data. /// @@ -29,7 +34,9 @@ public struct CMSSignature: Sendable, Hashable { public var signers: [Signer] { get throws { try self.base.signerInfos.compactMap { signerInfo in - try self.base.certificates?.certificate(signerInfo: signerInfo).map { Signer(certificate: $0) } + try self.base.certificates?.certificate(signerInfo: signerInfo).map { + Signer(certificate: $0, signingTime: try signerInfo.signedAttrs?.signingTime) + } } } } @@ -41,7 +48,7 @@ public struct CMSSignature: Sendable, Hashable { } } -extension CMSSignature: DERImplicitlyTaggable { +extension CMSSignature: DERImplicitlyTaggable, BERImplicitlyTaggable { @inlinable public static var defaultIdentifier: ASN1Identifier { CMSContentInfo.defaultIdentifier @@ -50,7 +57,18 @@ extension CMSSignature: DERImplicitlyTaggable { @inlinable public init(derEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { guard let base = try CMSContentInfo(derEncoded: rootNode, withIdentifier: identifier).signedData, - base.version == .v1 + base.version == .v1 || base.version == .v4 + else { + throw CMS.Error.unexpectedCMSType + } + + self.base = base + } + + @inlinable + public init(berEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + guard let base = try CMSContentInfo(berEncoded: rootNode, withIdentifier: identifier).signedData, + base.version == .v1 || base.version == .v4 else { throw CMS.Error.unexpectedCMSType } @@ -73,9 +91,12 @@ extension CMSSignature { public struct Signer: Sendable, Hashable { public let certificate: Certificate + public let signingTime: Date? + @inlinable - init(certificate: Certificate) { + init(certificate: Certificate, signingTime: Date? = nil) { self.certificate = certificate + self.signingTime = signingTime } } } diff --git a/Sources/X509/CryptographicMessageSyntax/CMSSignedData.swift b/Sources/X509/CryptographicMessageSyntax/CMSSignedData.swift index 8f5e27f8..8fd5765a 100644 --- a/Sources/X509/CryptographicMessageSyntax/CMSSignedData.swift +++ b/Sources/X509/CryptographicMessageSyntax/CMSSignedData.swift @@ -42,7 +42,7 @@ import SwiftASN1 /// ``` /// - Note: At the moment we don't support `crls` (`RevocationInfoChoices`) @usableFromInline -struct CMSSignedData: DERImplicitlyTaggable, Hashable, Sendable { +struct CMSSignedData: DERImplicitlyTaggable, BERImplicitlyTaggable, Hashable, Sendable { @inlinable static var defaultIdentifier: ASN1Identifier { .sequence @@ -100,6 +100,38 @@ struct CMSSignedData: DERImplicitlyTaggable, Hashable, Sendable { } } + @inlinable + init(berEncoded: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + self = try BER.sequence(berEncoded, identifier: identifier) { nodes in + let version = try CMSVersion(rawValue: Int.init(derEncoded: &nodes)) + let digestAlgorithms = try BER.set(of: AlgorithmIdentifier.self, identifier: .set, nodes: &nodes) + + let encapContentInfo = try CMSEncapsulatedContentInfo(berEncoded: &nodes) + let certificates = try BER.optionalImplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { + node in + // Certificates should be DER encoded. Technically they can be in BER, but requires round-tripping for verification. + try DER.set( + of: Certificate.self, + identifier: .init(tagWithNumber: 0, tagClass: .contextSpecific), + rootNode: node + ) + } + + // we need to skip this node even though we don't support it + _ = BER.optionalImplicitlyTagged(&nodes, tagNumber: 1, tagClass: .contextSpecific) { _ in } + + let signerInfos = try BER.set(of: CMSSignerInfo.self, identifier: .set, nodes: &nodes) + + return .init( + version: version, + digestAlgorithms: digestAlgorithms, + encapContentInfo: encapContentInfo, + certificates: certificates, + signerInfos: signerInfos + ) + } + } + @inlinable func serialize(into coder: inout DER.Serializer, withIdentifier identifier: ASN1Identifier) throws { try coder.appendConstructedNode(identifier: identifier) { coder in diff --git a/Sources/X509/CryptographicMessageSyntax/CMSSignerIdentifier.swift b/Sources/X509/CryptographicMessageSyntax/CMSSignerIdentifier.swift index cfd9007e..69726fe2 100644 --- a/Sources/X509/CryptographicMessageSyntax/CMSSignerIdentifier.swift +++ b/Sources/X509/CryptographicMessageSyntax/CMSSignerIdentifier.swift @@ -21,7 +21,7 @@ import SwiftASN1 /// subjectKeyIdentifier [0] SubjectKeyIdentifier } /// ``` @usableFromInline -enum CMSSignerIdentifier: DERParseable, DERSerializable, Hashable, Sendable { +enum CMSSignerIdentifier: DERParseable, BERParseable, DERSerializable, BERSerializable, Hashable, Sendable { @usableFromInline static let skiIdentifier = ASN1Identifier(tagWithNumber: 0, tagClass: .contextSpecific) @@ -36,9 +36,13 @@ enum CMSSignerIdentifier: DERParseable, DERSerializable, Hashable, Sendable { self = try .issuerAndSerialNumber(.init(derEncoded: node)) case Self.skiIdentifier: - self = try .subjectKeyIdentifier( - .init(keyIdentifier: .init(derEncoded: node, withIdentifier: Self.skiIdentifier)) - ) + self = try DER.explicitlyTagged( + node, + tagNumber: Self.skiIdentifier.tagNumber, + tagClass: Self.skiIdentifier.tagClass + ) { node in + .subjectKeyIdentifier(.init(keyIdentifier: try ASN1OctetString(derEncoded: node).bytes)) + } default: throw ASN1Error.unexpectedFieldType(node.identifier) @@ -52,7 +56,10 @@ enum CMSSignerIdentifier: DERParseable, DERSerializable, Hashable, Sendable { try issuerAndSerialNumber.serialize(into: &coder) case .subjectKeyIdentifier(let subjectKeyIdentifier): - try subjectKeyIdentifier.keyIdentifier.serialize(into: &coder, withIdentifier: Self.skiIdentifier) + try coder.serialize( + ASN1OctetString(contentBytes: subjectKeyIdentifier.keyIdentifier), + explicitlyTaggedWithIdentifier: Self.skiIdentifier + ) } } diff --git a/Sources/X509/CryptographicMessageSyntax/CMSSignerInfo.swift b/Sources/X509/CryptographicMessageSyntax/CMSSignerInfo.swift index b5de87f1..73daab9c 100644 --- a/Sources/X509/CryptographicMessageSyntax/CMSSignerInfo.swift +++ b/Sources/X509/CryptographicMessageSyntax/CMSSignerInfo.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import Foundation import SwiftASN1 /// ``CMSSignerInfo`` is defined in ASN.1 as: @@ -32,9 +33,8 @@ import SwiftASN1 /// - Note: If the `SignerIdentifier` is the CHOICE `issuerAndSerialNumber`, /// then the `version` MUST be 1. If the `SignerIdentifier` is `subjectKeyIdentifier`, /// then the `version` MUST be 3. -/// - Note: At the moment we neither support `signedAttrs` (`SignedAttributes`) nor `unsignedAttrs` (`UnsignedAttributes`) @usableFromInline -struct CMSSignerInfo: DERImplicitlyTaggable, Hashable, Sendable { +struct CMSSignerInfo: DERImplicitlyTaggable, BERImplicitlyTaggable, Hashable, Sendable { @usableFromInline enum Error: Swift.Error { case versionAndSignerIdentifierMismatch(String) @@ -48,15 +48,19 @@ struct CMSSignerInfo: DERImplicitlyTaggable, Hashable, Sendable { @usableFromInline var version: CMSVersion @usableFromInline var signerIdentifier: CMSSignerIdentifier @usableFromInline var digestAlgorithm: AlgorithmIdentifier + @usableFromInline var signedAttrs: [CMSAttribute]? @usableFromInline var signatureAlgorithm: AlgorithmIdentifier @usableFromInline var signature: ASN1OctetString + @usableFromInline var unsignedAttrs: [CMSAttribute]? @inlinable init( signerIdentifier: CMSSignerIdentifier, digestAlgorithm: AlgorithmIdentifier, + signedAttrs: [CMSAttribute]? = nil, signatureAlgorithm: AlgorithmIdentifier, - signature: ASN1OctetString + signature: ASN1OctetString, + unsignedAttrs: [CMSAttribute]? = nil ) { switch signerIdentifier { case .issuerAndSerialNumber: @@ -66,8 +70,10 @@ struct CMSSignerInfo: DERImplicitlyTaggable, Hashable, Sendable { } self.signerIdentifier = signerIdentifier self.digestAlgorithm = digestAlgorithm + self.signedAttrs = signedAttrs self.signatureAlgorithm = signatureAlgorithm self.signature = signature + self.unsignedAttrs = unsignedAttrs } @inlinable @@ -75,14 +81,18 @@ struct CMSSignerInfo: DERImplicitlyTaggable, Hashable, Sendable { version: CMSVersion, signerIdentifier: CMSSignerIdentifier, digestAlgorithm: AlgorithmIdentifier, + signedAttrs: [CMSAttribute]? = nil, signatureAlgorithm: AlgorithmIdentifier, - signature: ASN1OctetString + signature: ASN1OctetString, + unsignedAttrs: [CMSAttribute]? = nil ) { self.version = version self.signerIdentifier = signerIdentifier self.digestAlgorithm = digestAlgorithm + self.signedAttrs = signedAttrs self.signatureAlgorithm = signatureAlgorithm self.signature = signature + self.unsignedAttrs = unsignedAttrs } @inlinable @@ -106,21 +116,90 @@ struct CMSSignerInfo: DERImplicitlyTaggable, Hashable, Sendable { } let digestAlgorithm = try AlgorithmIdentifier(derEncoded: &nodes) - // we don't support signedAttrs yet but we still need to skip them - _ = DER.optionalImplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { _ in } + let signedAttrs = try DER.optionalImplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { + node in + return try DER.set( + of: CMSAttribute.self, + identifier: .init(tagWithNumber: 0, tagClass: .contextSpecific), + rootNode: node + ) + } let signatureAlgorithm = try AlgorithmIdentifier(derEncoded: &nodes) let signature = try ASN1OctetString(derEncoded: &nodes) - // we don't support unsignedAttrs yet but we still need to skip them - _ = DER.optionalImplicitlyTagged(&nodes, tagNumber: 1, tagClass: .contextSpecific) { _ in } + let unsignedAttrs = try DER.optionalImplicitlyTagged(&nodes, tagNumber: 1, tagClass: .contextSpecific) { + node in + return try DER.set( + of: CMSAttribute.self, + identifier: .init(tagWithNumber: 0, tagClass: .contextSpecific), + rootNode: node + ) + } return .init( version: version, signerIdentifier: signerIdentifier, digestAlgorithm: digestAlgorithm, + signedAttrs: signedAttrs, signatureAlgorithm: signatureAlgorithm, - signature: signature + signature: signature, + unsignedAttrs: unsignedAttrs + ) + } + } + + @inlinable + init(berEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + self = try BER.sequence(rootNode, identifier: identifier) { nodes in + let version = try CMSVersion(rawValue: Int(derEncoded: &nodes)) + let signerIdentifier = try CMSSignerIdentifier(berEncoded: &nodes) + switch signerIdentifier { + case .issuerAndSerialNumber: + guard version == .v1 else { + throw Error.versionAndSignerIdentifierMismatch( + "expected \(CMSVersion.v1) but got \(version) where signerIdentifier is \(signerIdentifier)" + ) + } + case .subjectKeyIdentifier: + guard version == .v3 else { + throw Error.versionAndSignerIdentifierMismatch( + "expected \(CMSVersion.v3) but got \(version) where signerIdentifier is \(signerIdentifier)" + ) + } + } + let digestAlgorithm = try AlgorithmIdentifier(berEncoded: &nodes) + + // SignedAttrs MUST be in DER: https://datatracker.ietf.org/doc/html/rfc5652#section-2 + let signedAttrs = try DER.optionalImplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { + node in + return try DER.set( + of: CMSAttribute.self, + identifier: .init(tagWithNumber: 0, tagClass: .contextSpecific), + rootNode: node + ) + } + + let signatureAlgorithm = try AlgorithmIdentifier(berEncoded: &nodes) + let signature = try ASN1OctetString(berEncoded: &nodes) + + let unsignedAttrs = try BER.optionalImplicitlyTagged(&nodes, tagNumber: 1, tagClass: .contextSpecific) { + node in + return try BER.set( + of: CMSAttribute.self, + identifier: .init(tagWithNumber: 0, tagClass: .contextSpecific), + rootNode: node + ) + } + + return .init( + version: version, + signerIdentifier: signerIdentifier, + digestAlgorithm: digestAlgorithm, + signedAttrs: signedAttrs, + signatureAlgorithm: signatureAlgorithm, + signature: signature, + unsignedAttrs: unsignedAttrs ) } } @@ -131,8 +210,77 @@ struct CMSSignerInfo: DERImplicitlyTaggable, Hashable, Sendable { try coder.serialize(self.version.rawValue) try coder.serialize(self.signerIdentifier) try coder.serialize(self.digestAlgorithm) + if let signedAttrs = self.signedAttrs { + try coder.serializeSetOf(signedAttrs, identifier: .init(tagWithNumber: 0, tagClass: .contextSpecific)) + } try coder.serialize(self.signatureAlgorithm) try coder.serialize(self.signature) + if let unsignedAttrs = self.unsignedAttrs { + try coder.serializeSetOf(unsignedAttrs, identifier: .init(tagWithNumber: 1, tagClass: .contextSpecific)) + } + } + } +} + +// MARK: - SignedAttrs +extension CMSSignerInfo { + @inlinable + /// Returns the signedAttrs in DER encoded form by re-serializes the parsed signedAttrs, or immediately returning + /// a saved slice of the original data bytes. + func _signedAttrsBytes() throws -> ArraySlice { + precondition(self.signedAttrs != nil) + var coder = DER.Serializer() + try coder.serializeSetOf(self.signedAttrs!) + return coder.serializedBytes[...] + } +} + +// MARK: - Attribute Getters + +extension Array where Element == CMSAttribute { + @inlinable + subscript(oid: ASN1ObjectIdentifier) -> CMSAttribute? { + if let attr = self.first(where: { $0.attrType == oid }) { + return attr } + return nil } } + +extension Array where Element == CMSAttribute { + @inlinable + var signingTime: Date? { + get throws { + if let attr = self[.signingTime] { + guard attr.attrValues.count == 1 else { + throw ASN1Error.invalidASN1Object(reason: "Signing time attribute must have a single value") + } + let time = try Time(asn1Any: attr.attrValues[0]) + return Date(time) + } + return nil + } + } + + @inlinable + var messageDigest: ArraySlice? { + get throws { + if let attr = self[.messageDigest] { + guard attr.attrValues.count == 1 else { + throw ASN1Error.invalidASN1Object(reason: "Message digest attribute must have a single value") + } + let octets = try ASN1OctetString(asn1Any: attr.attrValues[0]) + return octets.bytes + } + return nil + } + } +} + +extension ASN1ObjectIdentifier { + @usableFromInline + static let messageDigest: Self = [1, 2, 840, 113549, 1, 9, 4] + + @usableFromInline + static let signingTime: Self = [1, 2, 840, 113549, 1, 9, 5] +} diff --git a/Sources/X509/X509BaseTypes/AlgorithmIdentifier.swift b/Sources/X509/X509BaseTypes/AlgorithmIdentifier.swift index d753b0cd..665fd20c 100644 --- a/Sources/X509/X509BaseTypes/AlgorithmIdentifier.swift +++ b/Sources/X509/X509BaseTypes/AlgorithmIdentifier.swift @@ -15,7 +15,7 @@ import SwiftASN1 @usableFromInline -struct AlgorithmIdentifier: DERImplicitlyTaggable, Hashable, Sendable { +struct AlgorithmIdentifier: DERImplicitlyTaggable, BERImplicitlyTaggable, Hashable, Sendable { @inlinable static var defaultIdentifier: ASN1Identifier { .sequence @@ -50,6 +50,11 @@ struct AlgorithmIdentifier: DERImplicitlyTaggable, Hashable, Sendable { } } + @inlinable + init(berEncoded rootNode: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { + self = try .init(derEncoded: rootNode, withIdentifier: identifier) + } + @inlinable func serialize(into coder: inout DER.Serializer, withIdentifier identifier: ASN1Identifier) throws { try coder.appendConstructedNode(identifier: identifier) { coder in diff --git a/Tests/X509Tests/CMSTests.swift b/Tests/X509Tests/CMSTests.swift index 2a38638a..c9a491b9 100644 --- a/Tests/X509Tests/CMSTests.swift +++ b/Tests/X509Tests/CMSTests.swift @@ -453,7 +453,7 @@ final class CMSTests: XCTestCase { privateKey: Self.leaf1Key ) - // Change the version number to v3 in both places. + // Change the version number to v3 var signedData = try CMSSignedData(asn1Any: cmsData.content) signedData.version = .v3 cmsData.content = try ASN1Any(erasing: signedData) @@ -526,7 +526,7 @@ final class CMSTests: XCTestCase { XCTAssertInvalidCMSBlock(isValidSignature) } - func testRequireCMSV1Signature() async throws { + func testRequireCMSV1SignatureWhenInvalidV3SignerInfo() async throws { let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] var cmsData = try CMS.generateSignedTestData( data, @@ -535,7 +535,7 @@ final class CMSTests: XCTestCase { privateKey: Self.leaf1Key ) - // Change the version number to v3 in both places. + // Change the version number to v3 in both places, but not the signerIdentifier var signedData = try CMSSignedData(asn1Any: cmsData.content) signedData.version = .v3 signedData.signerInfos[0].version = .v3 @@ -596,6 +596,126 @@ final class CMSTests: XCTestCase { XCTAssertInvalidCMSBlock(isValidSignature) } + func testCMSV3Signature() async throws { + let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + var cmsData = try CMS.generateSignedTestData( + data, + signatureAlgorithm: .ecdsaWithSHA256, + certificate: Self.leaf1Cert, + privateKey: Self.leaf1Key + ) + + // Change the version number to v3 everywhere + var signedData = try CMSSignedData(asn1Any: cmsData.content) + signedData.version = .v3 + signedData.signerInfos[0].version = .v3 + signedData.signerInfos[0].signerIdentifier = try .subjectKeyIdentifier( + Self.leaf1Cert.extensions.subjectKeyIdentifier! + ) + cmsData.content = try ASN1Any(erasing: signedData) + + let isValidSignature = try await CMS.isValidSignature( + dataBytes: data, + signatureBytes: cmsData.encodedBytes, + trustRoots: CertificateStore([Self.rootCert]) + ) {} + XCTAssertUnableToValidateSigner(isValidSignature) + } + + func testCMSV4SignatureWithV1SignerInfo() async throws { + let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + var cmsData = try CMS.generateSignedTestData( + data, + signatureAlgorithm: .ecdsaWithSHA256, + certificate: Self.leaf1Cert, + privateKey: Self.leaf1Key + ) + + // Change the version number to v4 + var signedData = try CMSSignedData(asn1Any: cmsData.content) + signedData.version = .v4 + cmsData.content = try ASN1Any(erasing: signedData) + + let isValidSignature = try await CMS.isValidSignature( + dataBytes: data, + signatureBytes: cmsData.encodedBytes, + trustRoots: CertificateStore([Self.rootCert]) + ) {} + XCTAssertUnableToValidateSigner(isValidSignature) + } + + func testCMSV4SignatureWithV3SignerInfo() async throws { + let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + var cmsData = try CMS.generateSignedTestData( + data, + signatureAlgorithm: .ecdsaWithSHA256, + certificate: Self.leaf1Cert, + privateKey: Self.leaf1Key + ) + + // Change the version number to v4 + var signedData = try CMSSignedData(asn1Any: cmsData.content) + signedData.version = .v4 + // change the signerInfo to v3 + signedData.signerInfos[0].version = .v3 + signedData.signerInfos[0].signerIdentifier = try .subjectKeyIdentifier( + Self.leaf1Cert.extensions.subjectKeyIdentifier! + ) + cmsData.content = try ASN1Any(erasing: signedData) + + let isValidSignature = try await CMS.isValidSignature( + dataBytes: data, + signatureBytes: cmsData.encodedBytes, + trustRoots: CertificateStore([Self.rootCert]) + ) {} + XCTAssertUnableToValidateSigner(isValidSignature) + } + + func testCMSV4SignatureWithInvalidV3SignerInfo() async throws { + let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + var cmsData = try CMS.generateSignedTestData( + data, + signatureAlgorithm: .ecdsaWithSHA256, + certificate: Self.leaf1Cert, + privateKey: Self.leaf1Key + ) + + // Change the version number to v4 + var signedData = try CMSSignedData(asn1Any: cmsData.content) + signedData.version = .v4 + // change the signerInfo to invalid v3 + signedData.signerInfos[0].version = .v3 + cmsData.content = try ASN1Any(erasing: signedData) + + let isValidSignature = try await CMS.isValidSignature( + dataBytes: data, + signatureBytes: cmsData.encodedBytes, + trustRoots: CertificateStore([Self.rootCert]) + ) {} + XCTAssertInvalidCMSBlock(isValidSignature) + } + + func testCMSAttachedSignature() async throws { + let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + var cmsData = try CMS.generateSignedTestData( + data, + signatureAlgorithm: .ecdsaWithSHA256, + certificate: Self.leaf1Cert, + privateKey: Self.leaf1Key + ) + + // Let's add the signed data in here! + var signedData = try CMSSignedData(asn1Any: cmsData.content) + signedData.encapContentInfo.eContent = ASN1OctetString(contentBytes: data[...]) + cmsData.content = try ASN1Any(erasing: signedData) + + let isValidSignature = try await CMS.isValidAttachedSignature( + signatureBytes: cmsData.encodedBytes, + trustRoots: CertificateStore([Self.rootCert]) + ) {} + XCTAssertUnableToValidateSigner(isValidSignature) + } + func testForbidsAdditionalSignerInfos() async throws { let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] var cmsData = try CMS.generateSignedTestData( @@ -662,6 +782,29 @@ final class CMSTests: XCTestCase { XCTAssertInvalidCMSBlock(isValidSignature) } + func testRequireValidDetachedSignatureWhenTolerated() async throws { + let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + var cmsData = try CMS.generateSignedTestData( + data, + signatureAlgorithm: .ecdsaWithSHA256, + certificate: Self.leaf1Cert, + privateKey: Self.leaf1Key + ) + + // Let's add the signed data in here! + var signedData = try CMSSignedData(asn1Any: cmsData.content) + signedData.encapContentInfo.eContent = ASN1OctetString(contentBytes: [0xba, 0xd]) + cmsData.content = try ASN1Any(erasing: signedData) + + let isValidSignature = try await CMS.isValidSignature( + dataBytes: data, + signatureBytes: cmsData.encodedBytes, + trustRoots: CertificateStore([Self.rootCert]), + allowAttachedContent: true + ) {} + XCTAssertInvalidCMSBlock(isValidSignature) + } + func testDigestAlgorithmsNotPresentInTheMainSetAreRejected() async throws { let data: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] var cmsData = try CMS.generateSignedTestData(