Saltar a contenido

Código AL: codeunit de firma completo

Crea src/PdfSigning/PdfSigning.Codeunit.al dentro de tu extensión existente y pega este bloque entero. Es el codeunit 50100 BC PDF Signing real: incluye los tres overloads de FirmarPdf, resolución del certificado, firma, HTTP, reintentos y descarga.

Antes de compilar, añade los objetos auxiliares completos. El recorrido de integración manual explica cómo adaptar IDs, permisos y configuración.

PdfSigning.Codeunit.al

namespace Baltoya.PdfSigning;

using System.Security.Encryption;
using System.Text;
using System.Utilities;

codeunit 50100 "BC PDF Signing"
{
    /// <summary>Signs a PDF as the current user. Use the OperationId overload for durable retries.</summary>
    procedure FirmarPdf(Pdf: InStream; CertificateAlias: Code[100]; var SignedPdf: Codeunit "Temp Blob")
    begin
        FirmarPdf(Pdf, CertificateAlias, CreateGuid(), SignedPdf);
    end;

    /// <summary>Reuse OperationId only with exactly the same PDF, alias, company, user and certificate.</summary>
    [NonDebuggable]
    procedure FirmarPdf(Pdf: InStream; CertificateAlias: Code[100]; OperationId: Guid; var SignedPdf: Codeunit "Temp Blob")
    var
        NoPlacement: JsonObject;
    begin
        FirmarPdf(Pdf, CertificateAlias, OperationId, NoPlacement, '', SignedPdf);
    end;

    /// <summary>Signs with an optional visible area. Freeze placement and certificate thumbprint for retries.</summary>
    [NonDebuggable]
    procedure FirmarPdf(Pdf: InStream; CertificateAlias: Code[100]; OperationId: Guid; VisibleSignature: JsonObject; ExpectedThumbprint: Text; var SignedPdf: Codeunit "Temp Blob")
    var
        Setup: Record "BC PDF Signing Setup";
        Certificate: Record "Isolated Certificate";
        CertificateManagement: Codeunit "Certificate Management";
        Base64Convert: Codeunit "Base64 Convert";
        Request: JsonObject;
        Prepared: JsonObject;
        Completed: JsonObject;
        RequestId: Guid;
        IdempotencyKey: Text;
        Payload: Text;
        Status: Text;
        RequestPath: Text;
    begin
        Clear(SignedPdf);
        if IsNullGuid(OperationId) then
            Error('An operation ID is required.');
        Setup.Get('');
        Setup.ValidateServiceUrl();
        Setup.TestField("Timeout Seconds");
        ResolveCertificate(CertificateAlias, Certificate);
        if (ExpectedThumbprint <> '') and (ExpectedThumbprint <> Certificate.ThumbPrint) then
            Error('The certificate changed during this operation. Restore the original certificate or choose New Operation.');

        // Include company and caller in the key; a service token does not identify individual BC users.
        IdempotencyKey := BuildIdempotencyKey(OperationId);
        Request.Add('pdfBase64', Base64Convert.ToBase64(Pdf));
        Request.Add('externalRef', GuidText(OperationId));
        Request.Add('certificateAlias', CertificateAlias);
        Request.Add('certificateBase64', CertificateManagement.GetRawCertDataAsBase64String(Certificate));
        Request.Add('documentName', 'signed.pdf');
        if VisibleSignature.Keys().Count() > 0 then
            Request.Add('visibleSignature', VisibleSignature);
        Request.WriteTo(Payload);
        SendJson(Setup, 'POST', '/v1/pades-signature-requests/prepare', Payload, IdempotencyKey, OperationId, Prepared);
        if not Evaluate(RequestId, RequiredText(Prepared, 'requestId')) then
            Error('The PDF service returned an invalid request identifier. Operation: %1.', OperationId);
        CheckPreparedCertificate(Prepared, Certificate);
        RequestPath := '/v1/pades-signature-requests/' + GuidText(RequestId);
        Status := RequiredText(Prepared, 'status');
        if Status = 'completing' then
            Status := WaitForCompletion(Setup, RequestPath, OperationId);
        if Status = 'prepared' then begin
            Clear(Request);
            Request.Add('signatureValueBase64', SignPreparedData(Prepared, Certificate));
            Request.WriteTo(Payload);
            // Payload is produced once. Every transport retry sends the identical signature.
            SendJson(Setup, 'POST', RequestPath + '/complete', Payload, '', OperationId, Completed);
            Status := RequiredText(Completed, 'status');
            if Status = 'completing' then
                Status := WaitForCompletion(Setup, RequestPath, OperationId);
        end;
        if Status <> 'signed' then
            Error('The PDF could not be completed (state: %1). Retry with the same operation ID: %2.', SafeState(Status), OperationId);
        DownloadResult(Setup, RequestPath + '/result', OperationId, SignedPdf);
    end;

    procedure CertificateThumbprint(CertificateAlias: Code[100]): Text
    var
        Certificate: Record "Isolated Certificate";
    begin
        ResolveCertificate(CertificateAlias, Certificate);
        exit(Certificate.ThumbPrint);
    end;

    local procedure ResolveCertificate(CertificateAlias: Code[100]; var Certificate: Record "Isolated Certificate")
    var
        Mapping: Record "BC PDF Certificate Alias";
    begin
        if not Mapping.Get(UserSecurityId(), CertificateAlias) then
            Error('The selected certificate alias is not authorized for the current user and company.');
        Mapping.TestField(Enabled, true);
        Mapping.ValidateAlias();
        Mapping.TestField("Certificate Code");
        Certificate.Get(Mapping."Certificate Code");
        // The standard Certificate Management codeunit resolves its own isolated-storage scope.
        // Never attempt to read its certificate/password from this extension's isolated storage.
        if (Certificate."User ID" <> '') and (Certificate."User ID" <> UserId()) then
            Error('The certificate belongs to another Business Central user.');
        Certificate.TestField("Has Private Key", true);
        if Certificate."Expiry Date" <= CurrentDateTime() then
            Error('The selected certificate has expired.');
    end;

    local procedure CheckPreparedCertificate(Prepared: JsonObject; Certificate: Record "Isolated Certificate")
    begin
        if RequiredText(Prepared, 'digestAlgorithm') <> 'SHA256' then
            Error('The PDF service requested an unsupported digest algorithm.');
        if RequiredText(Prepared, 'signatureAlgorithm') <> 'RS256' then
            Error('The PDF service requested an unsupported signature algorithm.');
        if UpperCase(DelChr(RequiredText(Prepared, 'certificateThumbprint'), '=', ' :')) <>
           UpperCase(DelChr(Certificate.ThumbPrint, '=', ' :'))
        then
            Error('The prepared request uses a different certificate. Do not change certificates while retrying an operation.');
    end;

    [NonDebuggable]
    local procedure SignPreparedData(Prepared: JsonObject; Certificate: Record "Isolated Certificate"): Text
    var
        CertificateManagement: Codeunit "Certificate Management";
        CryptographyManagement: Codeunit "Cryptography Management";
        SignatureKey: Codeunit "Signature Key";
        Base64Convert: Codeunit "Base64 Convert";
        DataBlob: Codeunit "Temp Blob";
        SignatureBlob: Codeunit "Temp Blob";
        DataIn: InStream;
        SignatureIn: InStream;
        DataOut: OutStream;
        SignatureOut: OutStream;
    begin
        DataBlob.CreateOutStream(DataOut);
        Base64Convert.FromBase64(RequiredText(Prepared, 'toBeSignedBase64'), DataOut);
        DataBlob.CreateInStream(DataIn);
        SignatureBlob.CreateOutStream(SignatureOut);
        CertificateManagement.GetCertPrivateKey(Certificate, SignatureKey);
        // Sign exact DER signed-attributes bytes. SignData hashes them once with SHA-256.
        CryptographyManagement.SignData(DataIn, SignatureKey, Enum::"Hash Algorithm"::SHA256, SignatureOut);
        Clear(SignatureKey);
        SignatureBlob.CreateInStream(SignatureIn);
        exit(Base64Convert.ToBase64(SignatureIn));
    end;

    local procedure WaitForCompletion(Setup: Record "BC PDF Signing Setup"; RequestPath: Text; OperationId: Guid): Text
    var
        Reply: JsonObject;
        Attempt: Integer;
        Status: Text;
    begin
        for Attempt := 1 to 3 do begin
            Sleep(1000);
            SendJson(Setup, 'GET', RequestPath, '', '', OperationId, Reply);
            Status := RequiredText(Reply, 'status');
            if Status <> 'completing' then
                exit(Status);
        end;
        Error('The PDF is still being signed. Retry with the same operation ID: %1.', OperationId);
    end;

    [NonDebuggable]
    local procedure SendJson(Setup: Record "BC PDF Signing Setup"; Method: Text; Path: Text; Payload: Text; IdempotencyKey: Text; OperationId: Guid; var Reply: JsonObject)
    var
        Response: HttpResponseMessage;
        ResponseText: Text;
    begin
        Send(Setup, Method, Path, Payload, IdempotencyKey, OperationId, Response);
        Response.Content.ReadAs(ResponseText);
        Clear(Reply);
        if not Reply.ReadFrom(ResponseText) then
            Error('The PDF service returned invalid JSON. Operation: %1.', OperationId);
    end;

    [NonDebuggable]
    local procedure Send(Setup: Record "BC PDF Signing Setup"; Method: Text; Path: Text; Payload: Text; IdempotencyKey: Text; OperationId: Guid; var Response: HttpResponseMessage)
    var
        Secrets: Codeunit "BC PDF Signing Secrets";
        Client: HttpClient;
        Request: HttpRequestMessage;
        Content: HttpContent;
        Headers: HttpHeaders;
        Token: SecretText;
        Attempt: Integer;
        Sent: Boolean;
    begin
        Token := Secrets.GetToken();
        Client.Timeout(Setup."Timeout Seconds" * 1000);
        for Attempt := 1 to 3 do begin
            Clear(Request);
            Clear(Response);
            Request.Method := Method;
            Request.SetRequestUri(Setup."Service URL" + Path);
            Request.GetHeaders(Headers);
            Headers.Add('Authorization', SecretStrSubstNo('Bearer %1', Token));
            if IdempotencyKey <> '' then
                Headers.Add('Idempotency-Key', IdempotencyKey);
            if Method = 'POST' then begin
                Clear(Content);
                Content.WriteFrom(Payload);
                Content.GetHeaders(Headers);
                Headers.Remove('Content-Type');
                Headers.Add('Content-Type', 'application/json');
                Request.Content := Content;
            end;
            Sent := Client.Send(Request, Response);
            if Sent then begin
                if Response.IsSuccessStatusCode() then
                    exit;
                if Response.HttpStatusCode() < 500 then
                    RaiseHttpError(Response.HttpStatusCode(), OperationId);
            end;
            if Attempt < 3 then
                Sleep(1000 * Attempt);
        end;
        if Sent then
            RaiseHttpError(Response.HttpStatusCode(), OperationId);
        Error('The PDF service could not be reached. Check outgoing HTTP permissions, URL and connectivity. Retry with the same operation ID: %1.', OperationId);
    end;

    local procedure RaiseHttpError(StatusCode: Integer; OperationId: Guid)
    begin
        // Never display response bodies, reason phrases or payloads: these may contain confidential data.
        Error('PDF service HTTP error %1. Operation: %2. Check the service log using this operation reference before retrying.', StatusCode, OperationId);
    end;

    local procedure DownloadResult(Setup: Record "BC PDF Signing Setup"; Path: Text; OperationId: Guid; var SignedPdf: Codeunit "Temp Blob")
    var
        Response: HttpResponseMessage;
        Headers: HttpHeaders;
        ContentTypes: List of [Text];
        PdfIn: InStream;
        PdfOut: OutStream;
    begin
        Send(Setup, 'GET', Path, '', '', OperationId, Response);
        Response.Content.GetHeaders(Headers);
        if not Headers.GetValues('Content-Type', ContentTypes) then
            Error('The PDF result has no content type. Operation: %1.', OperationId);
        if ContentTypes.Count() <> 1 then
            Error('The PDF result has an invalid content type. Operation: %1.', OperationId);
        if LowerCase(ContentTypes.Get(1).Split(';').Get(1)) <> 'application/pdf' then
            Error('The PDF service did not return a PDF. Operation: %1.', OperationId);
        Response.Content.ReadAs(PdfIn);
        SignedPdf.CreateOutStream(PdfOut);
        CopyStream(PdfOut, PdfIn);
        if not SignedPdf.HasValue() then
            Error('The PDF service returned an empty PDF. Operation: %1.', OperationId);
    end;

    local procedure RequiredText(Reply: JsonObject; PropertyName: Text): Text
    var
        Token: JsonToken;
    begin
        if not Reply.Get(PropertyName, Token) then
            Error('The PDF service response is missing a required property: %1.', PropertyName);
        if not Token.IsValue() then
            Error('The PDF service returned an invalid property: %1.', PropertyName);
        if Token.AsValue().IsNull() then
            Error('The PDF service returned an empty property: %1.', PropertyName);
        exit(Token.AsValue().AsText());
    end;

    local procedure BuildIdempotencyKey(OperationId: Guid): Text
    var
        CryptographyManagement: Codeunit "Cryptography Management";
        HashAlgorithm: Option MD5,SHA1,SHA256,SHA384,SHA512;
    begin
        exit('bc-' + CryptographyManagement.GenerateHash(CompanyName() + '|' + GuidText(UserSecurityId()) + '|' + GuidText(OperationId), HashAlgorithm::SHA256));
    end;

    local procedure GuidText(Value: Guid): Text
    begin
        exit(LowerCase(DelChr(Format(Value), '=', '{}')));
    end;

    local procedure SafeState(Status: Text): Text
    begin
        case Status of
            'prepared', 'completing', 'signed', 'expired', 'error':
                exit(Status);
            else
                exit('unexpected');
        end;
    end;
}

Fuente y mantenimiento

El bloque es una copia literal de PdfSigning.Codeunit.al. Los captions, mensajes y comentarios se conservan como en la fuente. Cuando cambie el AL, sincroniza este bloque y los objetos auxiliares de la misma versión antes de copiar; el código del repositorio es la referencia.

Sigue con objetos auxiliares o vuelve a compilar y configurar tu extensión.