All Downloads are FREE. Search and download functionalities are using the official Maven repository.

se.swedenconnect.signservice.signature.tbsdata.XMLTBSDataProcessor Maven / Gradle / Ivy

/*
 * Copyright 2022 Sweden Connect
 *
 * 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 se.swedenconnect.signservice.signature.tbsdata;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.SignatureException;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.xml.bind.JAXBException;
import javax.xml.datatype.DatatypeConfigurationException;
import javax.xml.datatype.DatatypeFactory;
import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.apache.xml.security.binding.xmldsig.CanonicalizationMethodType;
import org.apache.xml.security.binding.xmldsig.DigestMethodType;
import org.apache.xml.security.binding.xmldsig.ObjectType;
import org.apache.xml.security.binding.xmldsig.ReferenceType;
import org.apache.xml.security.binding.xmldsig.SignatureMethodType;
import org.apache.xml.security.binding.xmldsig.SignedInfoType;
import org.apache.xml.security.binding.xmldsig.TransformType;
import org.apache.xml.security.binding.xmldsig.TransformsType;
import org.apache.xml.security.c14n.CanonicalizationException;
import org.apache.xml.security.c14n.Canonicalizer;
import org.apache.xml.security.c14n.InvalidCanonicalizerException;
import org.apache.xml.security.parser.XMLParserException;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.IssuerSerial;
import org.bouncycastle.cert.X509CertificateHolder;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import se.idsec.signservice.xml.DOMUtils;
import se.idsec.signservice.xml.InternalXMLException;
import se.idsec.signservice.xml.JAXBMarshaller;
import se.idsec.signservice.xml.JAXBUnmarshaller;
import se.swedenconnect.schemas.etsi.xades_1_3_2.CertIDTypeV2;
import se.swedenconnect.schemas.etsi.xades_1_3_2.DigestAlgAndValueType;
import se.swedenconnect.schemas.etsi.xades_1_3_2.QualifyingProperties;
import se.swedenconnect.schemas.etsi.xades_1_3_2.SignedProperties;
import se.swedenconnect.schemas.etsi.xades_1_3_2.SignedSignatureProperties;
import se.swedenconnect.schemas.etsi.xades_1_3_2.SigningCertificateV2;
import se.swedenconnect.security.algorithms.MessageDigestAlgorithm;
import se.swedenconnect.security.algorithms.SignatureAlgorithm;
import se.swedenconnect.signservice.core.types.InvalidRequestException;
import se.swedenconnect.signservice.signature.AdESObject;
import se.swedenconnect.signservice.signature.AdESType;
import se.swedenconnect.signservice.signature.RequestedSignatureTask;
import se.swedenconnect.signservice.signature.SignatureType;
import se.swedenconnect.signservice.signature.impl.DefaultAdESObject;

/**
 * XML Data to be signed processor.
 */
@Slf4j
public class XMLTBSDataProcessor extends AbstractTBSDataProcessor {

  /** URI identifier for XAdES SignedProperties */
  public static String SIGNED_PROPERTIES_TYPE = "http://uri.etsi.org/01903#SignedProperties";

  /** XAdES XML name space URI */
  public static String XADES_XML_NS = "http://uri.etsi.org/01903/v1.3.2#";

  /** SignedProperties element name */
  public static String SIGNED_PROPERTIES_ELEMENT_NAME = "SignedProperties";

  /**
   * Default canonicalization algorithm.
   *
   * @param defaultCanonicalizationAlgorithm set default canonicalization algorithm
   */
  @Setter
  private String defaultCanonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#";

  /** Object factory for XML digital signature elements */
  public static final org.apache.xml.security.binding.xmldsig.ObjectFactory dsObjectFactory =
      new org.apache.xml.security.binding.xmldsig.ObjectFactory();

  /** Object factory for XAdES digital signature elements */
  public static final se.swedenconnect.schemas.etsi.xades_1_3_2.ObjectFactory xadesObjectFactory =
      new se.swedenconnect.schemas.etsi.xades_1_3_2.ObjectFactory();

  /** Transformer for transforming XML fragments to bytes without XML declaration */
  public static Transformer xmlFragmentTransformer;

  private static final Random RNG = new SecureRandom();

  static {
    final TransformerFactory transformerFactory = TransformerFactory.newInstance();
    try {
      xmlFragmentTransformer = transformerFactory.newTransformer();
      xmlFragmentTransformer.setOutputProperty("omit-xml-declaration", "yes");
    }
    catch (final TransformerConfigurationException e) {
      throw new SecurityException(e);
    }
  }

  /**
   * Constructor for this XML TBS data processor with default settings.
   */
  public XMLTBSDataProcessor() {
    super(null);
  }

  /**
   * Constructor that allows setting of supported processing rules.
   *
   * @param supportedProcessingRules list of supported processing rules for this TBS data processor
   */
  public XMLTBSDataProcessor(@Nonnull final List supportedProcessingRules) {
    super(supportedProcessingRules);
  }

  /** {@inheritDoc} */
  @Override
  public boolean supportsType(@Nonnull final SignatureType signatureType) {
    return signatureType == SignatureType.XML;
  }

  /** {@inheritDoc} */
  @Override
  protected void checkToBeSignedData(@Nonnull final byte[] tbsData, final boolean ades,
      @Nullable final AdESObject adESObject, @Nonnull final SignatureAlgorithm signatureAlgorithm)
      throws InvalidRequestException {
    log.debug("Checking XML to be signed data");

    try {
      if (ades) {
        if (adESObject == null) {
          throw new InvalidRequestException(
              "the AdESObject must not be null when the signature is an AdES XML signature");
        }
        Optional.ofNullable(adESObject.getSignatureId())
            .orElseThrow(() -> new InvalidRequestException("Signature ID must not be null in a XAdES signature"));
      }

      // Checking any present signing time if it is too old or not yet valid
      if (adESObject != null && adESObject.getObjectBytes() != null) {
        final Document adesObjectDocument = DOMUtils.bytesToDocument(adESObject.getObjectBytes());
        final ObjectType adesObjectType = JAXBUnmarshaller.unmarshall(adesObjectDocument, ObjectType.class);
        final XadesQualifyingProperties xadesObject = new XadesQualifyingProperties(adesObjectType);
        // We only care about signing time if is set
        if (xadesObject.getSigningTime() != null) {
          // A signing time is present, check that it is current.
          this.checkSigningTime(Instant.ofEpochMilli(xadesObject.getSigningTime()));
        }
      }

      final Document tbsDocument = DOMUtils.bytesToDocument(tbsData);
      final SignedInfoType signedInfo = JAXBUnmarshaller.unmarshall(tbsDocument, SignedInfoType.class);

      // Check algorithm consistency (the data to be signed must match the requested algorithm)
      final SignatureMethodType signatureMethod = Optional.ofNullable(signedInfo.getSignatureMethod())
          .orElseThrow(() -> new NoSuchAlgorithmException("SignInfo does not have a specified signature algorithm"));
      if (!signatureAlgorithm.getUri().equals(signatureMethod.getAlgorithm())) {
        throw new IOException("Signature algorithm of request does not match provided data to be signed");
      }

      final List referenceList = signedInfo.getReference();
      if (referenceList == null || referenceList.isEmpty()) {
        // We do require at least one reference to signed data
        throw new InvalidRequestException("Input SignedInfo does not contain any reference data");
      }
      final List xadesReferenceList = referenceList.stream()
          .filter(referenceType -> SIGNED_PROPERTIES_TYPE.equalsIgnoreCase(referenceType.getType()))
          .collect(Collectors.toList());

      if (xadesReferenceList.size() > 1) {
        // We do not allow more than one XAdES SignedProperties reference
        throw new InvalidRequestException("SignedInfo has more than one XAdES reference");
      }

    }
    catch (final JAXBException | NoSuchAlgorithmException | IOException | InternalXMLException | SignatureException e) {
      throw new InvalidRequestException(e.getMessage(), e);
    }
  }

  /** {@inheritDoc} */
  @Override
  @Nonnull
  public TBSProcessingData processSignatureTypeTBSData(@Nonnull final RequestedSignatureTask signatureTask,
      @Nonnull final X509Certificate signerCertificate, @Nonnull final SignatureAlgorithm signatureAlgorithm)
      throws SignatureException {
    log.debug("Processing XML to be signed data");

    // Check and collect data
    this.defaultProcessingRuleCheck(signatureTask.getProcessingRulesUri());
    final byte[] tbsBytes = signatureTask.getTbsData();
    final SignatureType signatureType = signatureTask.getSignatureType();
    if (!signatureType.equals(SignatureType.XML)) {
      throw new SignatureException("Signature type must be XML");
    }
    final AdESType adESType = signatureTask.getAdESType();
    final AdESObject adESObject = signatureTask.getAdESObject();
    final boolean xades = AdESType.BES.equals(adESType) || AdESType.EPES.equals(adESType);
    log.debug("XAdES signature = {}", xades);

    // Process TBS data
    try {
      if (xades) {
        final String signatureId = Optional.ofNullable(adESObject.getSignatureId())
            .orElseThrow(() -> new SignatureException("Signature ID must not be null in a XAdES signature"));
        final byte[] adesObjectBytes = adESObject.getObjectBytes();
        XadesQualifyingProperties xadesObject;
        if (adesObjectBytes == null) {
          xadesObject = XadesQualifyingProperties.createXadesQualifyingProperties();
        }
        else {
          final Document adesObjectDocument = DOMUtils.bytesToDocument(adesObjectBytes);
          final ObjectType adesObjectType = JAXBUnmarshaller.unmarshall(adesObjectDocument, ObjectType.class);
          xadesObject = new XadesQualifyingProperties(adesObjectType);
        }

        final Element adesElement = xadesObject.getAdesElement();
        final QualifyingProperties qualifyingProperties = Optional.ofNullable(this.getQualifyingProperties(adesElement))
            .orElseThrow(
                () -> new SignatureException("Failed to obtain QualifyingProperties from provided AdES object"));
        final String ref = this.addSigningCertRef(signerCertificate, qualifyingProperties, signatureId,
            signatureAlgorithm);
        final Element updatedAdesElement = this.getUpdatedAdesElement(qualifyingProperties);
        final byte[] updatedAdesObjectBytes = nodeToBytes(updatedAdesElement);
        final byte[] updatedTbsData = this.getUpdatedTbsData(tbsBytes, updatedAdesElement, signatureAlgorithm, ref);

        final AdESObject updatedAdesObject = new DefaultAdESObject(signatureId, updatedAdesObjectBytes);

        return TBSProcessingData.builder()
            .processingRules(signatureTask.getProcessingRulesUri())
            .adesObject(updatedAdesObject)
            .tbsBytes(updatedTbsData)
            .build();
      }
      else {
        return TBSProcessingData.builder()
            .processingRules(signatureTask.getProcessingRulesUri())
            .tbsBytes(tbsBytes)
            .build();
      }
    }
    catch (SignatureException | JAXBException | DatatypeConfigurationException | NoSuchAlgorithmException
        | CertificateEncodingException | IOException | XMLParserException | InvalidCanonicalizerException
        | CanonicalizationException | ParserConfigurationException | SAXException e) {
      throw new SignatureException("Unable to parse data to be signed in request:" + e, e);
    }
  }

  /**
   * Adds signer certificate reference to AdES object.
   *
   * @param certificate certificate to reference in AdES object
   * @param qualifyingProperties QualifyingProperties to update
   * @param signatureId the id of the Signature being updated
   * @param signatureAlgorithm the XML signature algorithm
   * @return the id of the SignedProperties element being signed inside QualifyingProperties
   * @throws DatatypeConfigurationException on error extending QualifyingProperties
   * @throws NoSuchAlgorithmException on error extending QualifyingProperties
   * @throws CertificateEncodingException on error extending QualifyingProperties
   * @throws IOException on error extending QualifyingProperties
   */
  @Nonnull
  private String addSigningCertRef(@Nonnull final X509Certificate certificate,
      @Nonnull final QualifyingProperties qualifyingProperties,
      @Nonnull final String signatureId, final @Nonnull SignatureAlgorithm signatureAlgorithm)
      throws DatatypeConfigurationException, NoSuchAlgorithmException, CertificateEncodingException, IOException {

    // Set the expected signature ID ref
    qualifyingProperties.setTarget("#" + signatureId);

    // Add or create SignedProperties
    if (!qualifyingProperties.isSetSignedProperties()) {
      qualifyingProperties.setSignedProperties(xadesObjectFactory.createSignedProperties());
    }
    final SignedProperties signedProperties = qualifyingProperties.getSignedProperties();
    String signedPropertiesId = signedProperties.getId();
    if (signedPropertiesId == null) {
      signedPropertiesId = "xades-id-" + new BigInteger(128, RNG).toString(16);
      signedProperties.setId(signedPropertiesId);
    }
    // Add or create SignedSignatureProperties
    if (!signedProperties.isSetSignedSignatureProperties()) {
      signedProperties.setSignedSignatureProperties(xadesObjectFactory.createSignedSignatureProperties());
    }
    final SignedSignatureProperties signedSignatureProperties = signedProperties.getSignedSignatureProperties();
    // Set signing time to current time
    final XMLGregorianCalendar signingTime =
        DatatypeFactory.newInstance().newXMLGregorianCalendar(new GregorianCalendar());
    signedSignatureProperties.setSigningTime(signingTime);
    if (signedSignatureProperties.isSetSigningCertificate()) {
      // There is an old outdated certificate reference deltet it
      signedSignatureProperties.setSigningCertificate(null);
      log.debug("AdES object from sign request contained V1 certificate reference. This was deleted");
    }

    // Hash certificate
    final MessageDigestAlgorithm messageDigestAlgorithm = signatureAlgorithm.getMessageDigestAlgorithm();
    final MessageDigest md = MessageDigest.getInstance(messageDigestAlgorithm.getJcaName());
    final byte[] certDigest = md.digest(certificate.getEncoded());

    // Create and set a new instance of signingCertificateV2
    signedSignatureProperties.setSigningCertificateV2(xadesObjectFactory.createSigningCertificateV2());
    final SigningCertificateV2 signingCertificateV2 = signedSignatureProperties.getSigningCertificateV2();
    final List certs = signingCertificateV2.getCerts();
    final CertIDTypeV2 certIDTypeV2 = xadesObjectFactory.createCertIDTypeV2();
    final DigestAlgAndValueType digestAlgAndValueType = xadesObjectFactory.createDigestAlgAndValueType();
    final DigestMethodType digestMethodType = dsObjectFactory.createDigestMethodType();
    digestMethodType.setAlgorithm(messageDigestAlgorithm.getUri());
    digestAlgAndValueType.setDigestMethod(digestMethodType);
    digestAlgAndValueType.setDigestValue(certDigest);
    certIDTypeV2.setCertDigest(digestAlgAndValueType);
    if (this.isIncludeIssuerSerial()) {
      final byte[] issuerSerial = getRfc5035IssuerSerialBytes(certificate);
      certIDTypeV2.setIssuerSerialV2(issuerSerial);
    }
    certs.add(certIDTypeV2);

    return signedPropertiesId;
  }

  /**
   * Updates the SignedInfo based on an updated XAdES object.
   *
   * @param tbsBytes input unmodified SignedInfo
   * @param updatedAdesElement the updated XAdES object with updated certificate reference
   * @param signatureAlgorithm signature algorithm used to sign XML
   * @param ref the reference ID used to identify XAdES signed properties
   * @return updated canonical SignedInfo bytes
   * @throws JAXBException on error processing and updating SignedInfo
   * @throws SignatureException on error processing and updating SignedInfo
   * @throws XMLParserException on error processing and updating SignedInfo
   * @throws InvalidCanonicalizerException on error processing and updating SignedInfo
   * @throws CanonicalizationException on error processing and updating SignedInfo
   * @throws ParserConfigurationException on error processing and updating SignedInfo
   * @throws IOException on error processing and updating SignedInfo
   * @throws SAXException on error processing and updating SignedInfo
   * @throws NoSuchAlgorithmException on error processing and updating SignedInfo
   */
  @Nonnull
  private byte[] getUpdatedTbsData(@Nonnull final byte[] tbsBytes, @Nonnull final Element updatedAdesElement,
      @Nonnull final SignatureAlgorithm signatureAlgorithm, @Nonnull final String ref)
      throws JAXBException, SignatureException, XMLParserException, InvalidCanonicalizerException,
      CanonicalizationException, ParserConfigurationException, IOException, SAXException, NoSuchAlgorithmException {

    final Document tbsDocument = DOMUtils.bytesToDocument(tbsBytes);
    final SignedInfoType signedInfo = JAXBUnmarshaller.unmarshall(tbsDocument, SignedInfoType.class);

    final List referenceList = signedInfo.getReference();
    final List xadesReferenceList = referenceList.stream()
        .filter(referenceType -> SIGNED_PROPERTIES_TYPE.equalsIgnoreCase(referenceType.getType()))
        .collect(Collectors.toList());

    if (xadesReferenceList.isEmpty()) {
      final ReferenceType newXadesRef = dsObjectFactory.createReferenceType();
      newXadesRef.setType(SIGNED_PROPERTIES_TYPE);
      referenceList.add(newXadesRef);
    }

    final ReferenceType xadesReference = referenceList.stream()
        .filter(referenceType -> SIGNED_PROPERTIES_TYPE.equalsIgnoreCase(referenceType.getType()))
        .findFirst().orElseThrow();

    xadesReference.setURI("#" + ref);
    // Set digest method
    final DigestMethodType digestMethodType = dsObjectFactory.createDigestMethodType();
    digestMethodType.setAlgorithm(signatureAlgorithm.getMessageDigestAlgorithm().getUri());
    xadesReference.setDigestMethod(digestMethodType);

    // Set digest value
    final Node signedPropertiesNode = updatedAdesElement.getElementsByTagNameNS(XADES_XML_NS,
        SIGNED_PROPERTIES_ELEMENT_NAME).item(0);
    final byte[] signedPropertyBytes = getCanonicalXml(nodeToBytes(signedPropertiesNode),
        this.defaultCanonicalizationAlgorithm);
    final MessageDigest md = MessageDigest.getInstance(signatureAlgorithm.getMessageDigestAlgorithm().getJcaName());
    final byte[] signedPropertyHash = md.digest(signedPropertyBytes);
    xadesReference.setDigestValue(signedPropertyHash);

    // Set transform algo
    final TransformType transform = dsObjectFactory.createTransformType();
    transform.setAlgorithm(this.defaultCanonicalizationAlgorithm);
    final TransformsType transforms = dsObjectFactory.createTransformsType();
    transforms.getTransform().add(transform);
    xadesReference.setTransforms(transforms);

    // Done updating SignedInfo. Finally, transform SignedInfo to bytes
    final CanonicalizationMethodType canonicalizationMethodType = Optional.ofNullable(
        signedInfo.getCanonicalizationMethod())
        .orElseThrow(() -> new SignatureException(
            "SignedInfo has no canonicalization algorithm element"));
    final String canonicalizationAlgorithm = Optional.ofNullable(canonicalizationMethodType.getAlgorithm())
        .orElseThrow(() -> new SignatureException(
            "SignedInfo has no canonicalization algorithm"));
    return getCanonicalXml(nodeToBytes(
        JAXBMarshaller.marshallNonRootElement(dsObjectFactory.createSignedInfo(signedInfo)).getDocumentElement()),
        canonicalizationAlgorithm);
  }

  /**
   * Get canonical XML from XML input.
   *
   * @param xmlBytes XML data to canonicalize
   * @param canonicalizationAlgo canonicalization algorithm
   * @return canonical XML
   * @throws InvalidCanonicalizerException bad canonicalization algorithm
   * @throws IOException data parsing error
   * @throws CanonicalizationException canonicalization error
   * @throws XMLParserException error parsing XML input
   */
  @Nonnull
  public static byte[] getCanonicalXml(@Nonnull final byte[] xmlBytes, @Nonnull final String canonicalizationAlgo)
      throws InvalidCanonicalizerException, IOException, CanonicalizationException, XMLParserException {
    Objects.requireNonNull(xmlBytes, "XML Bytes to canonicalize must not be null");
    Objects.requireNonNull(canonicalizationAlgo, "Canonicalization algorithm must be specified");
    final Canonicalizer canon = Canonicalizer.getInstance(canonicalizationAlgo);
    try (final ByteArrayOutputStream os = new ByteArrayOutputStream()) {
      canon.canonicalize(xmlBytes, os, true);
      return os.toByteArray();
    }
  }

  /**
   * Transforms an XML node to bytes without XML declaration.
   *
   * @param node node to transform to byte
   * @return byte representation of the XML node without XML declaration
   */
  @Nonnull
  public static byte[] nodeToBytes(@Nonnull final Node node) {
    try (final ByteArrayOutputStream output = new ByteArrayOutputStream()) {
      xmlFragmentTransformer.transform(new DOMSource(node), new StreamResult(output));
      return output.toByteArray();
    }
    catch (final TransformerException | IOException e) {
      throw new InternalXMLException("Failed to transform XML node to bytes", e);
    }
  }

  /**
   * Get IssuerSerial data according to RFC5035.
   *
   * @param cert certificate to extract IssuerSerial from
   * @return bytes of DER encoded IssuerSerial according to RFC5035
   * @throws IOException error parsing certificate
   * @throws CertificateEncodingException error parsing certificate
   */
  @Nonnull
  public static byte[] getRfc5035IssuerSerialBytes(@Nonnull final X509Certificate cert)
      throws IOException, CertificateEncodingException {
    return getRfc5035IssuerAndSerial(cert).getEncoded("DER");
  }

  @Nonnull
  private static IssuerSerial getRfc5035IssuerAndSerial(@Nonnull final X509Certificate sigCert)
      throws CertificateEncodingException, IOException {
    final X500Name issuerX500Name = new X509CertificateHolder(sigCert.getEncoded()).getIssuer();
    return new IssuerSerial(new GeneralNames(new GeneralName(issuerX500Name)), sigCert.getSerialNumber());
  }

  @Nonnull
  private Element getUpdatedAdesElement(@Nonnull final QualifyingProperties qualifyingProperties) throws JAXBException {
    final ObjectType newAdesObject = dsObjectFactory.createObjectType();
    newAdesObject.getContent().add(JAXBMarshaller.marshall(qualifyingProperties).getDocumentElement());
    return JAXBMarshaller.marshallNonRootElement(dsObjectFactory.createObject(newAdesObject))
        .getDocumentElement();
  }

  @Nullable
  private QualifyingProperties getQualifyingProperties(@Nonnull final Element adesElement) throws JAXBException {
    final NodeList objectNodeList = adesElement.getChildNodes();
    for (int i = 0; i < objectNodeList.getLength(); i++) {
      final Node child = objectNodeList.item(i);
      if (child instanceof Element) {
        final Element elm = (Element) child;
        if (XadesQualifyingProperties.LOCAL_NAME.equals(elm.getLocalName())) {
          return JAXBUnmarshaller.unmarshall(elm, QualifyingProperties.class);
        }
      }
    }
    return null;
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy