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.