Add Personality to Your Blog Posts with a Signature

In preparation for creating this blog I did some research on the web and listened to advices of many interesting authorities in this area. One particular tip really put me into thinking for a while: add personality to my blog posts by inserting a live signature.
I don’t think of myself as a character of strong personality, but in this modern era of everything digital made by AI I believe it’s good to demonstrate to the readers that there is a person behind those walls of texts putting some serious thinking ahead of the words and some good will behind the outputs of the website build pipeline in the good old-fashioned way of writing blog posts. So I decided to give it a try.
I’m a geek, though. I couldn’t resist to the temptation of making a "smart" joke, so, instead of adding a nice picture of my handwritten live signature, I thought: why not adding an OpenPGP signature instead? Sounds funny, doesn’t it? For some definition of fun, at least. You might be thinking: is that worthy? Well, I’m already used to using OpenPGP via GNU Privacy Guard (GPG) for lots of other things, so it wouldn’t be a hassle to do that in my computer. I don’t consider for now to build this website in a CI environment, like GitHub actions for example. It would be more complicated as I’d have to share my private key with the CI runners. Also, I think having some manual control over this publication is a feature, not a hindrance.
So, let me guide you through the tour of signing my blog posts and displaying the signature. This might be a bit entertaining and maybe even informative. It all starts with a Makefile.
Signing the posts "automatically" with GPG
As you may know already, I use Hugo to create this blog, with a custom theme I’m putting together by forking a nice reimplementation of the Antora default UI because I wanted to write my blog using the AsciiDoc markup language. Also, as I said before, I also have a working OpenPGP setup with GPG. I’ll skip describing it here. You can refer to this awesome guide about OpenPGP key management or comment in this post if you’d like to see a complete description of how I set up my OpenPGP keys.
There must certainly be a better way to do this with Hugo, but I found straightforward enough to just add an
intermediary step invoking gpg --detach-sign between building the website and publishing it. Here’s how it
looks like:
GPG_KEYID="C8B952808BDB6325" (1)
GPG_CMD="gpg --batch --yes --local-user " (2)
run:
hugo serve --buildDrafts
build:
hugo --gc --minify
sign: (3)
$(foreach f, $(shell find public/posts/ public/about/ -name '*.html'), \
$(shell ${GPG_CMD) $(GPG_KEYID) --armor --detach-sign $(f)))| 1 | The ID of the OpenPGP key with which I sign the posts. That’s the same you’ll see throughout this website. That’s not a secret piece of information. |
| 2 | Using --batch and --yes to avoid prompting, as this will be done multiple times as invoked by make. |
| 3 | The sign target is the one in charge of finding the HTML files and generating the signatures. When
invoked, such as via make sign, it will find all HTML files inside the subdirectories of public\posts and
public\about and pass that to gpg --detach-sign, what effectively creates a matching *.html.asc file. |
I opted for a detached signature because it’s easier to ensure integrity this way, even though it’s a little bit harder for readers to verify the signature (as you’ll see later, there are some extra measures to compensate for the extra complexity on the reader’s end). An attacker would have to take control of more than one file served from my hosting solution to be able to alter the signature and the page contents so they would match. The alternative of embedding the signature together with the page contents in a single file requires GPG to mark part of the file as the signed contents (not the file as a whole), thus an attacker could inject a malicious JavaScript outside of the HTML DOM area, browsers would load that happily and my signature mechanism wouldn’t be any helpful in detecting the integrity violation of the content being shared under my name. But, although I take confidentiality and integrity seriously, I’m mostly making this for the sake of fun ;) This is a content website, not an application.
Later on, in the same Makefile I have the "deploy" target, which depends on both build and sign (
deploy: build sign) so I don’t have to invoke any of these targets if I don’t want to. Since everything
under the public\ directory is deployed to my hosting solution, the detached signatures can now be served to
you, the reader.
If I later decide to change the key used to sign those posts, with that Makefile I’ll do one change and all pages will be re-signed with the new key.
Those readers familiar with OpenPGP and GPG, now armed with all that information, can figure out how to determine if the contents they are receiving are the same I intended to publish:
$ gpg --keyserver keyserver.ubuntu.com --recv-keys C8B952808BDB6325
gpg: key C8B952808BDB6325: "Carlos Nihelton <cnihelton@ubuntu.com>" not changed
gpg: Total number processed: 1
gpg: unchanged: 1
$ POST_URL="http://cn.olivec.dev/blog/posts/sign-posts/"
$ gpg --verify <(curl -sS "${POST_URL}index.html.asc") <(curl -sS "${POST_URL}")
gpg: Signature made Mon Feb 17 07:01:47 2025 -03
gpg: using EDDSA key 23AD2E20E5D3AD3C156AE1CE828484BB4EBBFF43
gpg: Good signature from "Carlos Nihelton <cnihelton@ubuntu.com>" [ultimate]
gpg: aka "Carlos Nihelton <cn@olivec.dev>" [ultimate]gpg: key C8B952808BDB6325: "Carlos Nihelton <cnihelton@ubuntu.com>" not changed
gpg: Total number processed: 1
gpg: unchanged: 1
gpg: Signature made Mon Feb 17 07:01:44 2025 -03
gpg: using EDDSA key 23AD2E20E5D3AD3C156AE1CE828484BB4EBBFF43
gpg: BAD signature from "Carlos Nihelton <cnihelton@ubuntu.com>" [ultimate]But not everybody will read this post first. How would you otherwise know that I went through the roofs to to assure you the integrity of the content being claimed as written by me by gpg-signing my posts?
I need to present you that information in the posts themselves in a nice way.
Presenting the signatures in the website.
Presenting the signature must not alter the HTML page being served, otherwise the signature would be invalid
from the get-go. Since the signatures are in separate files with a predictable name, we can easily leverage
the HTML iframe tag to load their contents. To modify my theme accordingly I just needed a new partial being
called out in the single.html layout (the one rendering the individual posts) handling this iframe:
diff --git a/layouts/_default/single.html b/layouts/_default/single.html
index 6d2cdf5..debda26 100644
--- a/layouts/_default/single.html
+++ b/layouts/_default/single.html
@@ -6,6 +6,7 @@
{{ partial "toc.html" . }}
{{ end }}
{{ .Content }}
+ {{ partial "post-signature" . }}
{{ partial "pagination" . }}
{{ partial "social-share" . }}
{{ with .Site.Params.giscuss }}To make it Hugo-idiomatic, let’s imagine that the client site would be configured with the following parameters related to OpenPGP:
[openPGP]
keyserver="keyserver.ubuntu.com"
keyid="C8B952808BDB6325"That assumes all the pages will be signed with the same key, which is my case for now. If I decide to change the signing key this configuration and the Makefile are the only places I need to touch.
So the partial needs to enclose the HTML content in a {{ with .Site.Params.openPGP }}/ {{ end }} pair,
such that if there is no configuration related to OpenPGP, nothing is added to the rendered page.
The iframe itself would look like (assuming "." is the page context):
{{ $page := . }}
{{ $signature := print .Permalink "index.html.asc"}}
{{ with .Site.Params.openPGP }}
<div id="signature-wrapper">
<div class="signature">
<iframe id="signature-frame" frameBorder="0" type="text/plain" src="{{ $signature }}"></iframe>
</div>
[...] (1)
</div>
[...] (2)| Notice the predictable file name I mentioned before. Because the HTML files being signed are always "index.html", the signatures must be "index.html.asc". |
| 1 | To make the information useful to readers not so experienced with GNU PG, let’s add an informative div telling them how to verify the signature of the post (expand the first ellipsis): |
{{ $info := i18n "signatureInfo" }}
<!-- This snippet produces a markdown syntax, but asciidoc also works with that without warnings. -->
{{ $command0 := print "> ```\n> gpg --keyserver " .keyserver " --recv-keys " .keyid "\n" }}
{{ $command1 := print "> POST_URL=\"" $page.Permalink "\"\n" }}
{{ $command2 := print "> gpg --verify <(curl -sS \"${POST_URL}index.html.asc\") <(curl -sS \"${POST_URL}\")\n> ```" }}
{{ $more := i18n "signatureDetails" }}
<div class="signature-info">{{ print "> " $info "\n" $command0 $command1 $command2 "\n> " $more "." | $page.RenderString }}
</div>Notice that we are composing a code block inside a quote block in the Markdown syntax. It contains the
commands to receive my key from the keyserver as configured via the site parameters. Finally the composed
string is rendered with the Page.RenderString Hugo method.
In this snippet signatureInfo and signatureDetails and translatable text resources declared inside the i18n\<lang>.yaml
files. In English they are:
- id: signatureInfo
translation: "This post is signed. To verify it's signature, run the following commands on a Bash-like shell:"
- id: signatureDetails
translation: "Those commands will import my public key into your gpg keyring and use it to verify the signature of this post"Good, but when writing a new post I won’t have a signature file right away, so the iframe would look horrible with a 404. I’d rather not display it at all. For that we need some JavaScript. <2> Let’s expand the second ellipsis:
<script type="text/javascript">
const iframe = document.getElementById('signature-frame');
const wrapper = document.getElementById('signature-wrapper');
iframe.onload = () => {
if (iframe.contentDocument) {
const ifTitle = iframe.contentDocument.title;
if (ifTitle.includes("404")) {
// Handle 404 case
wrapper.style.display = 'none';
}
} else {
// Handle case where iframe didn't load
wrapper.style.display = 'none';
}
};
</script>
{{ end }}With that in place, when the iframe finishes loading with 404 or if it fails to load at all, we just hide the wrapper div gracefully. Finally some CSS to make this thing look a little bit nicer:
.signature {
display: block;
position: relative;
width: 100%;
}
@media (max-aspect-ratio: 370/834) {
.signature {
aspect-ratio: 1/2;
}
}
@media (min-aspect-ratio: 370.001/834) and (max-aspect-ratio: 600/834){
.signature {
aspect-ratio: 1/1;
}
}
@media (min-aspect-ratio: 600.001/834) and (max-aspect-ratio: 1020/834){
.signature {
aspect-ratio: 3/1;
}
}
@media (min-aspect-ratio:1020.001/834){
.signature {
aspect-ratio: 4/1;
}
}
.signature iframe {
position: absolute;
width: 100%;
height: 100%;
margin: 1rem 0 1rem 0;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.signature-info {
width: 100%;
margin-top: 1rem;
}We style the iframe child of the signature class to take the entire height and width of the parent, aiming to avoid scrolling. The media queries define some breakpoints to change the aspect ratio of the enclosing div, making the whole arrangement more responsive and mobile-friendly. If I was using CSS frameworks like Tailwind CSS, that kind of thing would be handled by the framework.
Once again, the entire post-signature.html partial for contemplation:
{{ $page := . }}
{{ $signature := print .Permalink "index.html.asc"}}
{{ with .Site.Params.openPGP }}
<div id="signature-wrapper">
<div class="signature">
<iframe id="signature-frame" frameBorder="0" type="text/plain" src="{{ $signature }}"></iframe>
</div>
{{ $info := i18n "signatureInfo" }}
<!-- This snippet produces a markdown syntax, but asciidoc also works with that without warnings. -->
{{ $command0 := print "> ```\n> gpg --keyserver " .keyserver " --recv-keys " .keyid "\n" }}
{{ $command1 := print "> POST_URL=\"" $page.Permalink "\"\n" }}
{{ $command2 := print "> gpg --verify <(curl -sS \"${POST_URL}index.html.asc\") <(curl -sS \"${POST_URL}\")\n> ```" }}
{{ $more := i18n "signatureDetails" }}
<div class="signature-info">{{ print "> " $info "\n" $command0 $command1 $command2 "\n> " $more "." |
$page.RenderString }}</div>
</div>
<script type="text/javascript">
const iframe = document.getElementById('signature-frame');
const wrapper = document.getElementById('signature-wrapper');
iframe.onload = () => {
if (iframe.contentDocument) {
const ifTitle = iframe.contentDocument.title;
if (ifTitle.includes("404")) {
// Handle 404 case
wrapper.style.display = 'none';
}
} else {
// Handle case where iframe didn't load
wrapper.style.display = 'none';
}
};
</script>
{{ end }}It was all great! With all of that in place, instead of doing this:

I can do this:

A final caveat
Depending on how your hosting solution is configured, it’s possible that the iframe would download the
*.html.asc files instead of rendering them in place. To solve it we must ensure that those files will be
served with the Content-Type=text/plain and Content-Disposition=inline.
That’s how I do with Firebase Hosting, via editing firebase.json:
"headers": [
{
"source": "**/index.html.asc",
"headers": [
{
"key": "Content-Disposition",
"value": "inline"
},
{
"key": "Content-Type",
"value": "text/plain"
}
]
}
],If you also work with Firebase Hosting and need to do something similar, refer to the full hosting configuration docs.
Conclusion.
Was all that effort worth it? If you consider the amount of users that would go care to run those commands and validate my post signature, I’d definitely say that’s not worth it. I wish people took OpenPGP more seriously and use it more often in digital communications. It’s a great way in theory to avoid fake news, automated spammy content and what not when you have well established web of trust. It’s usefulness is dimmed by the lack of adoption, though, which is unfortunate.
But, as I said in the beginning, it was more for the sake of the joke than the actual usefulness, and I learned a few tricks about HTML, CSS and even JavaScript when doing all of this, so it was definitely fun and worth it. Maybe this becomes a trend and everybody starts signing and checking signatures of blog posts? Who knows ;) Or maybe storage becomes a concern and I decide to remove all the signature files in the future. Who knows. What matters is the fun along the way.
I’m not a web guy and don’t intend to be, but it’s good to learn a few tricks every now and then.
I hope you enjoyed the ride! Cheers!
This post is signed. To verify it’s signature, run the following commands on a Bash-like shell:
gpg --keyserver keyserver.ubuntu.com --recv-keys C8B952808BDB6325 POST_URL="https://cn.olivec.dev/blog/posts/sign-posts/" gpg --verify <(curl -sS "${POST_URL}index.html.asc") <(curl -sS "${POST_URL}")Those commands will import my public key into your gpg keyring and use it to verify the signature of this post.