Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / productivity / SharePoint

Signing Documents in SharePoint with a Smart Card

5.00/5 (1 vote)
3 Aug 2022CPOL8 min read 5.9K   71  
A tutorial showing how to use a smartcard API, a SharePoint extension and web services, to add a qualified electronic signature (QES) to a PDF file located in a SharePoint online library.
A complete roadmap for a 2 module solution for adding a qualified electronic signature to a PDF file stored in a SharePoint online library. Creating a SharePoint framework (SPFx) extension, a wrapper for a smartcard reader API and connecting them together with cross origin resource sharing (CORS) enabled web services. Brings together several readily available tutorials into a single integrated solution and discussing some pitfalls. Aimed at developers unfamiliar with either SharePoint development, desktop application development or who are unsure about how to connect all the pieces together.

Introduction

One form of implementing qualified electronic signature (QES) is through smart cards that contain in the chip, an electronic certificate suited for document signing. Such cards are extensions on ID cards where the certificate present on the chip is only suited for identification as those lack the ability to work as a qualified signature creation device.

Although some industries may add signing capability to their smart cards, the ubiquitous use of smart cards as the standard form of document signing is probably limited to countries like Portugal where this functionality is included in the Citizen ID card. For this reason, most commercial solutions for document signing do not even mention smart cards.

For SharePoint in particular, none of the applications found on the app store for managing document mentioned smart cards in any way.

Given to its nature as a less common form of document signing, not much information was found online showing a comprehensive approach on how to integrate SharePoint document libraries and smart cards, in order to avoid having to manually download the document, sign it with the smart card’s specific application and then re-upload it as a new version.

Although a fully functional demo is provided, the purpose of this article is to identify the several connecting parts needed and how they work together for the browser to pull the document from the server, send it to be signed by the card’s middleware, receive the signed file back and store it in the server, rather than show either novel uses or the best implementation for each individual step.

Image 1

To make this article accessible to those inexperienced with either SharePoint front end web development or desktop application development, a preliminary section for setting up the required tools is included.

Image 2

Setting Up the Environment

Obtaining an SDK Compatible with Your Card

Because the most daunting part of the whole process is programmatically signing your document, as it involves an USB card reader, certificates, security pins, time stamp servers, etc.. The overall difficulty of the project is roughly the difficulty of getting an SDK for your card.

Do not be discouraged if the web page that contains the software used to manually sign does not make any reference to an SDK or API, a web search might reveal it as it did for me [Manual do SDK – Middleware do Cartão de Cidadão].

If you can’t find something that allows you to programmatically interact with your card out of the box, another option may be the European standard DSS library.

Setting Up a Sharepoint Development Environment

This solution was made with SharePoint framework (SPFx), but the same functionalities could probably be achieved with a Sharepoint add-in UI command that extend ribbons and menus.

In choosing SPFx, setting up a development environment becomes necessary. It’s not hard but I recommend the YouTube videos as they include comments by the narrator not included in the text version and show that having a lot of warnings during the installation process is to be expected.

Without permission to deploy apps to your SharePoint tenant, setting up a Microsoft 365 tenant for development will allow you to perform all the testing and deployment [SharePoint Framework Tutorial - Setup your Microsoft 365 tenant for development].

And then the actual Setting up your SharePoint Framework (SPFx) development environment [Set up your SharePoint Framework (SPFx) development environment].

Setting Up a Local Development Environment

The second half of the solution is making a wrapper for the C# SDK in the form of a Windows application so Visual Studio was used with the .NET desktop development workload selected[Install Visual Studio]. For an SDK in a different language, in a different operating system or depending on preferences, a different code editor may offer a better experience.

SPFx Extension

Image 3

Generating the Basic SPFx Extension Scaffolding

With the goal of adding new configurable button to a Sharepoint Online document library “Build your first ListView Command Set extension”[Build your first ListView Command Set extension] provides a solid foundation with the following caveats:

In ./src/extensions/helloWorld/HelloWorldCommandSet.manifest.json, if the yeoman fails to create the images referenced, to prevent build error, an expedite solution is to simply comment them out.

JavaScript
"items": {
    […]
    //"iconImageUrl": "icons/request.png",
    […]
    //"iconImageUrl": "icons/cancel.png",

In ./sharepoint/assets/element.xml, we can define that once deployed to the server, the new buttons will appear in document libraries rather than on general lists by specifying the appropriate RegistrationId[Build your first ListView Command Set extension].

XML
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
    <CustomAction
        […]
        RegistrationId="101"

The default and optional pages defined in ./config/serve.json are only for local testing once deployed to the server, the new buttons will appear in all document libraries.

Adding Web Services for File IO

Leveraging locally hosted web services, moving data outside the context of the browser can be achieved without having to resort to browser add-ons.

Although the PnP/PnPjs library offers many advantages, for simplicity, this solution uses only the basic SPFx functionalities [SharePoint Framework Reference] and the SharePoint REST service [Get to know the SharePoint REST service].

In ./src/extensions/helloWorld/HelloWorldCommandSet.ts:

Adding references for interacting with the SharePont webservies (SPHttp*) exposes the document libraries and for interacting with common (Http*) exposes the wrapped card reader.

TypeScript
import {
  SPHttpClient,
  SPHttpClientResponse,
  ISPHttpClientOptions,
  HttpClient,
  HttpClientResponse,
  IHttpClientOptions
} from '@microsoft/sp-http';

Extending the interface allows convenient storing of properties including the file being manipulated as a blob.

TypeScript
export interface IAssinarDocumentoCommandSetProperties {
  // This is an example; replace with your own properties
  sampleTextOne: string;
  sampleTextTwo: string;
  selectedfilename: string;
  selectefiledId: string;
  siteurl: string;
  siterelativeurl: string;
  libraryguid: string;
  libraryrelativurl: string;
  librarytitle: string;
  fileblob: Blob;
}

Some properties are specific to the web page and can be set during the onInit as they will be reset every time the user navigates to a different library:

TypeScript
public onInit(): Promise<void> {
  Log.info(LOG_SOURCE, 'Initialized AssinarDocumentoCommandSet');

  // initial state of the command's visibility
  const compareOneCommand: Command = this.tryGetCommand('COMMAND_1');
  compareOneCommand.visible = false;

  this.context.listView.listViewStateChangedEvent.add
                        (this, this._onListViewStateChanged);
  this.properties.siteurl = this.context.pageContext.web.absoluteUrl;
  this.properties.siterelativeurl = this.context.pageContext.web.serverRelativeUrl;
  this.properties.librarytitle = this.context.listView.list.title;
  this.properties.libraryrelativurl = this.context.listView.list.serverRelativeUrl;
  this.properties.libraryguid = this.context.listView.list.guid.toString();
  return Promise.resolve();
}

Other properties are specific to the item selected in the library list and must be reset for every change in the selection of items:

TypeScript
private _onListViewStateChanged = (args: ListViewStateChangedEventArgs): void => {
    […]

    // TODO: Add your logic here
    // You can call this.raiseOnChage() to update the command bar
    this.raiseOnChange();

    this.properties.selectedfilename = 
         this.context.listView.selectedRows[0].getValueByName('FileLeafRef');
    this.properties.selectefiledId = 
         this.context.listView.selectedRows[0].getValueByName('UniqueId');
  }

The three indispensable functionalities are:

  1. Getting the document from the library as a blob:
    tscript
    private _getFileData(libraryrelativurl: string, filename: string): Promise<Blob> {
      return this.context.spHttpClient.get(this.properties.siteurl +
      "/_api/web/GetFileByServerRelativeUrl('" + libraryrelativurl +
      "/" + filename + "')/$value", SPHttpClient.configurations.v1)
        .then((response: SPHttpClientResponse) => {
          return response.blob();
        });
    }
    
  2. Posting the document to the card reader SDK wrapper as a blob and receiving it signed. Although named pipes would be a preferred approach, using a localhost http web service is an unsafe but expedient alternative.
    tscript
    private _postFileData(blob: Blob, filename: string): Promise<Blob> {
    
      const httpClientOptions: IHttpClientOptions = {
        body: blob,
      };
    
      return this.context.httpClient.post
      ("http://localhost:81/SignFile?filename=" + filename,
      HttpClient.configurations.v1, httpClientOptions)
        .then((response: HttpClientResponse) => {
          return response.blob();
        });
    }
    
  3. Storing it back in the SharePoint Online library with the same name, generating a new version:
    tscript
    private _storeFileData(blob: Blob, librarytitle: string,
                           filename: string): Promise<string> {
    
      const sphttpClientOptions: ISPHttpClientOptions = {
        body: blob,
      };
    
      return this.context.spHttpClient.post(this.properties.siteurl +
      "/_api/Web/Lists/getByTitle('" + librarytitle + "')/RootFolder/Files/Add
      (url='" + filename + "', overwrite=true)", SPHttpClient.configurations.v1,
      sphttpClientOptions)
        .then((response: SPHttpClientResponse) => {
          return response.statusText;
        });
    }
    

Having a function to test communication with the card reader is convenient but not indispensable:

tscript
private _testLocalHost(): Promise<string> {
  return this.context.httpClient.get("http://localhost:81/TestAssinador",
                                      HttpClient.configurations.v1)
    .then((response: HttpClientResponse) => {
      return response.text();
    });
}

Those functionalities can then be called on every button press, chained with .then.
Adding .catch to handle exceptions in every asynchronous call will be required later during deployment.

tscript
public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
    switch (event.itemId) {
      case 'COMMAND_1':
        const filename: string = this.properties.selectedfilename;
        this._getFileData(this.properties.libraryrelativurl, filename)
          .then((response) => {
            this._postFileData(response, filename)
              .then((response) => {
                if (response.size === 0) {
                  Dialog.alert("Erro na tantativa de assinatura").catch(() => { });
                }
                else {
                  this._storeFileData
                    (response, this.properties.librarytitle, filename)
                    .then((response) => {
                      this.properties.sampleTextOne = response;
                      Dialog.alert("Pdf assinado com sucesso").catch(() => { });
                    })
                    .catch((reason) => {
                      Dialog.alert(reason.toString()).catch(() => { });
                    })
                }
              })
              .catch((reason) => {
                Dialog.alert("Erro na tantativa de assinatura").catch(() => { });
              })
          })
          .catch(() => {});
        break;
      case 'COMMAND_2':
        this._testLocalHost()
          .then((response) => {
            Dialog.alert(response).catch(() => { });
          })
          .catch(() => { });
        break;
      default:
        throw new Error('Unknown command');
    }
  }

At this stage, if the _postFileData section is omitted, the code can be tested to show a new identical version of the file being added to the document library.

Because there is no competition for fulfilling the promise to obtain an answer, if a call is made to the local host either sending a file or calling the test address, nothing will happen as the brower will simply wait indefinitely.

The same document referenced previously on how to scaffold a basic SharePoint extension, also contains basic information on how to test and deploy [Build your first ListView Command Set extension].

In ./config/package-solution.json, removing the entry for clientsideinstance.xml under elementManifests may be required to allow limiting the extension to a particular site rather than all the sites in a tenant. [Deploy your extension to SharePoint (Hello World part 3)].

Card API Wrapper

Image 4

The Portuguese Citizen Card API [Manual do SDK – Middleware do Cartão de Cidadão] was used. Because it does not provide out of the box a possibility to sign a PDF file located in memory, some extra HDD IO functionalities were added to the wrapper rather than forking the API.

Although the wrapper does not add any user interface, because the API itself raises a windows for the user to insert a PIN, the wrapper will be a Windows application discreetly residing in the Tasktray rather than a service [Session 0 isolation].

Making a windowless application from a .NET Windows Forms application can be done simply by having it run an application context rather than a form [Creating a Tasktray Application]. Because the only user interface is the option to exit on the icon menu, there was little reason to use a more modern UI framework

The application hosted webservice was made with WCF[How to: Host a WCF service in a managed app]. If opting for a more modern solution like gRPC, consider that because the JavaScript in the SharePoint page will have to call a different origin when sending the document to be signed (http://localhost:81) the webservice at that endpoint will have to support CORS.

Because the size of the files to sign exceeded the default, defining a message size was necessary:

C#
webHttpBinding.MaxReceivedMessageSize = 100000000;

The implementation of CORS in WCF is well documented [CORS on WCF] and the provided code can be used with only minor changes. To account for the absence of a web.config file, the endpoint behavior is added in C#:

C#
ServiceEndpoint ep = 
  serviceHost.AddServiceEndpoint(typeof(IAssinador), webHttpBinding, "");
ep.Behaviors.Add(new EnableCrossOriginResourceSharingBehavior());

The resulting ApplicationContext becomes:

C#
public class AssinadorApplicationContext : ApplicationContext
{
    public WebServiceHost serviceHost = null;

    private NotifyIcon trayIcon;
    public AssinadorApplicationContext()
    {
        // Initialize Tray Icon
        trayIcon = new NotifyIcon()
        {
            Icon = Resources.AppIcon,
            ContextMenu = new ContextMenu(new MenuItem[] {
            new MenuItem("Exit", Exit)
        }),
            Visible = true
        };

        //Initialize Service
        StartService();
    }
    protected void StartService()
    {
        if (serviceHost != null)
        {
            serviceHost.Close();
        }
        // Create a ServiceHost and provide the base address.
        serviceHost = new WebServiceHost(typeof(AssinadorService),
                      new Uri("http://localhost:81"));
        try
        {
            WebHttpBinding webHttpBinding = new WebHttpBinding();
            webHttpBinding.MaxReceivedMessageSize = 100000000;
            ServiceEndpoint ep = serviceHost.AddServiceEndpoint(typeof(IAssinador),
                                 webHttpBinding, "");
            ep.Behaviors.Add(new EnableCrossOriginResourceSharingBehavior());
            // Open the ServiceHostBase to create listeners and start
            // listening for messages.
            serviceHost.Open();
        }
        catch (Exception)
        {
            serviceHost.Abort();
            throw;
        }
    }
    protected void Exit(object sender, EventArgs e)
    {
        if (serviceHost != null)
        {
            serviceHost.Close();
            serviceHost = null;
        }
        // Hide tray icon, otherwise it will remain shown until user mouses over it
        trayIcon.Visible = false;
        Application.Exit();
    }
}

Again because of the absence of a web.config, EnableCrossOriginResourceSharingBehavior can be trimmed down[CORS on WCF].

C#
public class EnableCrossOriginResourceSharingBehavior : IEndpointBehavior
{
    public void AddBindingParameters(ServiceEndpoint endpoint, 
    System.ServiceModel.Channels.BindingParameterCollection bindingParameters) { }

    public void ApplyClientBehavior(ServiceEndpoint endpoint, 
    System.ServiceModel.Dispatcher.ClientRuntime clientRuntime) { }

    public void ApplyDispatchBehavior(ServiceEndpoint endpoint, 
    System.ServiceModel.Dispatcher.EndpointDispatcher endpointDispatcher)
    {
        var requiredHeaders = new Dictionary<string, string>();

        requiredHeaders.Add("Access-Control-Allow-Origin", "*");
        requiredHeaders.Add("Access-Control-Request-Method", 
                            "POST,GET,PUT,DELETE,OPTIONS");
        requiredHeaders.Add("Access-Control-Allow-Headers", 
                            "X-Requested-With,Content-Type");

        endpointDispatcher.DispatchRuntime.MessageInspectors.Add
                (new CustomHeaderMessageInspector(requiredHeaders));
    }

    public void Validate(ServiceEndpoint endpoint) { }
}

For the service contract, in addition to the test and Sign service, a third operation is defined to handle the option requests and prevent the service from returning an error:

C#
[ServiceContract]
public interface IAssinador
{
    [OperationContract]
    [WebGet(ResponseFormat = WebMessageFormat.Json)]
    string TestAssinador();

    [OperationContract]
    [WebInvoke(ResponseFormat = WebMessageFormat.Json, 
               UriTemplate = "SignFile?filename={filename}")]
    Stream SignFile(Stream data, string filename);

    [OperationContract]
    [WebInvoke(Method = "OPTIONS", UriTemplate = "*")]
    void PostOptions();
}

The behavior of the service is just calling the card's SDK with the extra features mentioned before.

For HDD IO:

C#
private void TempStoreFile(Stream data, string filepath)
{
    using (var fileStream = File.Create(filepath))
    {
        data.CopyTo(fileStream);
    }
}
private Stream TempPullFile(string filepath)
{
    MemoryStream memstream = new MemoryStream(File.ReadAllBytes(filepath));
    File.Delete(filepath);
    return memstream;
}

For testing the status of the service, card reader and card:

C#
public string TestAssinador()
{
    return Test();
}
private string Test()
{
    StringBuilder result = new StringBuilder();
    try
    {
        PTEID_ReaderSet.initSDK();
        PTEID_ReaderSet readerSet = PTEID_ReaderSet.instance();
        result.Append("Iniciação OK; ");
        result.Append(readerSet.readerCount());
        result.Append(" leitor(es) encontrado(s); ");
        for (uint i = 0; i < readerSet.readerCount(); i++)
        {                     
            PTEID_ReaderContext context = readerSet.getReaderByNum(i);
            result.Append(" leitor ");
            result.Append(i);
            result.Append(" presente; ");
            if (context.isCardPresent())
            {                         
                PTEID_EIDCard card = context.getEIDCard();
                result.Append(" Cartão encontrado no leitor ");
                result.Append(i);
                result.Append("; ");
                //Identificação
                PTEID_EId eid = card.getID();
                result.Append("Dados encontrado no cartão, Nome: ");
                result.Append(eid.getGivenName());
            }                     
            else                     
            {                         
                result.Append("Cartão não encontrado no leitor ");
                result.Append(i);
            }
            result.Append("; ");
        }
    }
    catch (Exception)
    {                 
        result.Append("Falha do SDK");
    }
    finally
    {                 
        PTEID_ReaderSet.releaseSDK();
    }             
    return result.ToString();
}

And finally, combining them with the actual signing:

C#
    public class AssinadorService : IAssinador
    {
        public string TestAssinador()
        {
            [...]
        }
        public Stream SignFile(Stream data, string filename)
        {
            if (filename.Substring(filename.LastIndexOf(".")) == ".pdf")
            {
                string filepath = Path.Combine("C:\\TesteAssPdf", filename);

                if (File.Exists(filepath))
                {
                    File.Delete(filepath);
                }
                TempStoreFile(data, filepath);
                if (Assinar(filepath, 1, 0, 0))
                {
                    return TempPullFile(filepath);
                }
            }
            return null;
        }
        public void PostOptions() { }
        private string Test()
        {
            [...]
        }
        private bool Assinar(string filepath, int pagina, double fraX, double fraY)
        {
            bool assinado = false;

            try
            {
                PTEID_ReaderSet.initSDK();
                PTEID_ReaderSet readerSet = PTEID_ReaderSet.instance();
                for (uint i = 0; i < readerSet.readerCount(); i++)
                {
                    PTEID_ReaderContext context = readerSet.getReaderByNum(i);
                    if (context.isCardPresent())
                    {
                        PTEID_EIDCard card = context.getEIDCard();
                        // sign only one document
                        PTEID_PDFSignature signature = new PTEID_PDFSignature(filepath);
                        signature.setSignatureLevel
                                  (PTEID_SignatureLevel.PTEID_LEVEL_BASIC);
                        String output = filepath;

                        // Perform the actual signature
                        int returnCode = card.SignPDF
                            (signature, pagina, fraX, fraY, "", "", output);
                        assinado = true;
                    }
                }
            }
            catch (Exception)
            {
                assinado = false;
                if (File.Exists(filepath))
                {
                    File.Delete(filepath);
                }
            }
            finally
            {
                PTEID_ReaderSet.releaseSDK();
            }
            return assinado;
        }
        private void TempStoreFile(Stream data, string filepath)
        {
            [...]
        }
        private Stream TempPullFile(string filepath)
        {
            [...]
        }
    }
}

At this point, the desktop application can be compiled and running the .exe file located in ./bin/Debug should display an icon on the tasktray.

Points of Interest

History

  • 3rd August, 2022: Submitted for publishing

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)