ogmake

Signing

GET /i/{keyId}/{sig}?...params renders on first request and serves from cache after that — put it straight in <meta property="og:image">, no server round trip needed. sig is an HMAC-SHA256 over the request's own canonical query string, using your key's signing secret.

The canonical string: drop sig and debug, sort remaining keys by codepoint, RFC3986-encode each key=value, join with &.

Every snippet below is a real, interpreter-run file (not hand-copied) and reproduces the same fixed vector: template=basic&title=Hello World&format=png signed with secret AAEC...Hh8=W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8.

Node
// docs/snippets/sign.mjs — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Builds the canonical query string for a signed `GET /i/{keyId}/{sig}` URL
// and signs it (HMAC-SHA256, base64url). Prints the canonical string, then
// the signature, one per line.
//
// This is the fixed test vector shared with packages/core/src/signing.test.ts
// and tooling/src/snippets.test.ts — running this script must print exactly:
//   format=png&template=basic&title=Hello%20World
//   W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
import { webcrypto } from "node:crypto";

const { subtle } = webcrypto;

// Your key's signing secret, base64 (shown once when the key is issued).
const SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
const PARAMS = { template: "basic", title: "Hello World", format: "png" };

function base64UrlEncode(bytes) {
  return Buffer.from(bytes)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

// RFC3986 encoding: encodeURIComponent leaves !'()* unescaped; escape those too.
function rfc3986Encode(value) {
  return encodeURIComponent(value).replace(
    /[!'()*]/g,
    (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
  );
}

// Keys excluded from the canonical query (must match packages/core/src/signing.ts).
const CANONICAL_EXCLUDED_KEYS = new Set(["sig", "debug"]);

// Drops `sig`/`debug`, sorts params by key codepoint, RFC3986-encodes each
// "key=value", joins with "&".
function canonicalQuery(params) {
  return Object.keys(params)
    .filter((key) => !CANONICAL_EXCLUDED_KEYS.has(key))
    .sort()
    .map((key) => `${rfc3986Encode(key)}=${rfc3986Encode(params[key])}`)
    .join("&");
}

async function signQuery(secretBase64, canonical) {
  const secret = Buffer.from(secretBase64, "base64");
  const key = await subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
  ]);
  const mac = await subtle.sign("HMAC", key, new TextEncoder().encode(canonical));
  return base64UrlEncode(new Uint8Array(mac));
}

const canonical = canonicalQuery(PARAMS);
console.log(canonical);
console.log(await signQuery(SIGNING_SECRET_BASE64, canonical));
Python
#!/usr/bin/env python3
"""docs/snippets/sign.py — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).

Same fixed test vector as sign.mjs; run with `python3 sign.py`, prints:
    format=png&template=basic&title=Hello%20World
    W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
"""
import base64
import hashlib
import hmac
import urllib.parse

# Your key's signing secret, base64 (shown once when the key is issued).
SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
PARAMS = {"template": "basic", "title": "Hello World", "format": "png"}


def rfc3986_encode(value: str) -> str:
    # safe="" — Python's `quote` always leaves unreserved chars (letters,
    # digits, "_.-~") alone; nothing else should be left unescaped.
    return urllib.parse.quote(value, safe="")


# Keys excluded from the canonical query (must match packages/core/src/signing.ts).
CANONICAL_EXCLUDED_KEYS = {"sig", "debug"}


def _utf16_key(value: str):
    # Sorts by UTF-16 code unit, the way JS's default string comparison sorts
    # signing.ts's keys — NOT Python's `sorted()`, which compares strings by
    # Unicode code point. The two orders agree for BMP-only keys but diverge
    # for an astral-plane key (e.g. an emoji, encoded in UTF-16 as a
    # surrogate pair starting at 0xD800-0xDBFF) compared against a BMP
    # private-use key (e.g. U+E000): UTF-16 order puts the astral key first,
    # code point order puts it last. Using plain `sorted()` here would build
    # a different canonical string than the server and produce a bad
    # signature for such keys.
    units = []
    for ch in value:
        cp = ord(ch)
        if cp > 0xFFFF:
            cp -= 0x10000
            units.append(0xD800 + (cp >> 10))
            units.append(0xDC00 + (cp & 0x3FF))
        else:
            units.append(cp)
    return units


def canonical_query(params: dict) -> str:
    filtered = {k: v for k, v in params.items() if k not in CANONICAL_EXCLUDED_KEYS}
    return "&".join(
        f"{rfc3986_encode(k)}={rfc3986_encode(v)}"
        for k, v in sorted(filtered.items(), key=lambda kv: _utf16_key(kv[0]))
    )


def base64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def sign_query(secret_base64: str, canonical: str) -> str:
    secret = base64.b64decode(secret_base64)
    mac = hmac.new(secret, canonical.encode(), hashlib.sha256).digest()
    return base64url_encode(mac)


if __name__ == "__main__":
    canonical = canonical_query(PARAMS)
    print(canonical)
    print(sign_query(SIGNING_SECRET_BASE64, canonical))
Ruby
#!/usr/bin/env ruby
# docs/snippets/sign.rb — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
# Same fixed test vector as sign.mjs; run with `ruby sign.rb`, prints:
#   format=png&template=basic&title=Hello%20World
#   W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
require "openssl"
require "base64"
require "set"

# Your key's signing secret, base64 (shown once when the key is issued).
SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
PARAMS = { "template" => "basic", "title" => "Hello World", "format" => "png" }.freeze

UNRESERVED = /[A-Za-z0-9\-_.~]/.freeze

def rfc3986_encode(value)
  value.to_s.b.each_byte.map do |byte|
    char = byte.chr
    UNRESERVED.match?(char) ? char : format("%%%02X", byte)
  end.join
end

# Keys excluded from the canonical query (must match packages/core/src/signing.ts).
CANONICAL_EXCLUDED_KEYS = Set["sig", "debug"].freeze

# Sorts by UTF-16 code unit, the way JS's default string comparison sorts
# signing.ts's keys — NOT Ruby's `Array#sort`, which compares strings
# byte-by-byte (UTF-8 byte order). The two orders agree for BMP-only keys but
# diverge for an astral-plane key (e.g. an emoji, encoded in UTF-16 as a
# surrogate pair starting at 0xD800-0xDBFF) compared against a BMP
# private-use key (e.g. U+E000): UTF-16 order puts the astral key first,
# byte order puts it last. Using plain `.sort` here would build a different
# canonical string than the server and produce a bad signature for such keys.
def utf16_key(str)
  units = []
  str.each_codepoint do |cp|
    if cp > 0xFFFF
      cp -= 0x10000
      units << (0xD800 + (cp >> 10))
      units << (0xDC00 + (cp & 0x3FF))
    else
      units << cp
    end
  end
  units
end

def canonical_query(params)
  params.reject { |k, _| CANONICAL_EXCLUDED_KEYS.include?(k) }
    .sort_by { |k, _| utf16_key(k) }
    .map { |k, v| "#{rfc3986_encode(k)}=#{rfc3986_encode(v)}" }.join("&")
end

def base64url_encode(bytes)
  Base64.strict_encode64(bytes).tr("+/", "-_").delete("=")
end

def sign_query(secret_base64, canonical)
  secret = Base64.strict_decode64(secret_base64)
  mac = OpenSSL::HMAC.digest("SHA256", secret, canonical)
  base64url_encode(mac)
end

canonical = canonical_query(PARAMS)
puts canonical
puts sign_query(SIGNING_SECRET_BASE64, canonical)
PHP
<?php
// docs/snippets/sign.php — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Same fixed test vector as sign.mjs; run with `php sign.php`, prints:
//   format=png&template=basic&title=Hello%20World
//   W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
//
// Keys are sorted by UTF-16 code unit (see utf16_code_units() below), the way
// JS's default string comparison sorts them in packages/core/src/signing.ts
// — NOT by PHP's native byte order. The two orders agree for ASCII/BMP keys
// but diverge for an astral-plane key (e.g. an emoji) compared against a BMP
// private-use key; using PHP's native ksort()/SORT_STRING there would build
// a different canonical string than the server and produce a bad signature.

// Your key's signing secret, base64 (shown once when the key is issued).
$SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
$PARAMS = ["template" => "basic", "title" => "Hello World", "format" => "png"];

function rfc3986_encode(string $value): string
{
    // rawurlencode() is RFC3986: everything except A-Za-z0-9-_.~ is escaped.
    return rawurlencode($value);
}

// Keys excluded from the canonical query (must match packages/core/src/signing.ts).
const CANONICAL_EXCLUDED_KEYS = ["sig", "debug"];

function canonical_query(array $params): string
{
    $params = array_diff_key($params, array_flip(CANONICAL_EXCLUDED_KEYS));
    uksort($params, "compare_utf16_keys");
    $parts = [];
    foreach ($params as $key => $value) {
        $parts[] = rfc3986_encode($key) . "=" . rfc3986_encode($value);
    }
    return implode("&", $parts);
}

// Compares two keys by UTF-16 code unit, like JS's default string
// comparison (see the file header comment).
function compare_utf16_keys(string $a, string $b): int
{
    $unitsA = utf16_code_units($a);
    $unitsB = utf16_code_units($b);
    $len = min(count($unitsA), count($unitsB));
    for ($i = 0; $i < $len; $i++) {
        if ($unitsA[$i] !== $unitsB[$i]) {
            return $unitsA[$i] <=> $unitsB[$i];
        }
    }
    return count($unitsA) <=> count($unitsB);
}

// Decodes a UTF-8 string into UTF-16 code units (astral codepoints become a
// surrogate pair), matching how JS represents strings internally.
function utf16_code_units(string $value): array
{
    $units = [];
    $len = strlen($value);
    $i = 0;
    while ($i < $len) {
        $byte = ord($value[$i]);
        if ($byte < 0x80) {
            $codepoint = $byte;
            $i += 1;
        } elseif (($byte & 0xE0) === 0xC0 && $i + 1 < $len) {
            $codepoint = (($byte & 0x1F) << 6) | (ord($value[$i + 1]) & 0x3F);
            $i += 2;
        } elseif (($byte & 0xF0) === 0xE0 && $i + 2 < $len) {
            $codepoint = (($byte & 0x0F) << 12)
                | ((ord($value[$i + 1]) & 0x3F) << 6)
                | (ord($value[$i + 2]) & 0x3F);
            $i += 3;
        } elseif (($byte & 0xF8) === 0xF0 && $i + 3 < $len) {
            $codepoint = (($byte & 0x07) << 18)
                | ((ord($value[$i + 1]) & 0x3F) << 12)
                | ((ord($value[$i + 2]) & 0x3F) << 6)
                | (ord($value[$i + 3]) & 0x3F);
            $i += 4;
        } else {
            $codepoint = $byte;
            $i += 1;
        }
        if ($codepoint > 0xFFFF) {
            $codepoint -= 0x10000;
            $units[] = 0xD800 + ($codepoint >> 10);
            $units[] = 0xDC00 + ($codepoint & 0x3FF);
        } else {
            $units[] = $codepoint;
        }
    }
    return $units;
}

function base64url_encode(string $bytes): string
{
    return rtrim(strtr(base64_encode($bytes), "+/", "-_"), "=");
}

function sign_query(string $secretBase64, string $canonical): string
{
    $secret = base64_decode($secretBase64);
    $mac = hash_hmac("sha256", $canonical, $secret, true);
    return base64url_encode($mac);
}

$canonical = canonical_query($PARAMS);
echo $canonical . "\n";
echo sign_query($SIGNING_SECRET_BASE64, $canonical) . "\n";
Go
// docs/snippets/sign.go — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Same fixed test vector as sign.mjs; run with `go run sign.go`, prints:
//
//	format=png&template=basic&title=Hello%20World
//	W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"sort"
	"strings"
	"unicode/utf16"
)

// Your key's signing secret, base64 (shown once when the key is issued).
const signingSecretBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="

var unreserved = func() [256]bool {
	var table [256]bool
	for c := 'A'; c <= 'Z'; c++ {
		table[c] = true
	}
	for c := 'a'; c <= 'z'; c++ {
		table[c] = true
	}
	for c := '0'; c <= '9'; c++ {
		table[c] = true
	}
	for _, c := range "-_.~" {
		table[c] = true
	}
	return table
}()

// rfc3986Encode escapes every byte except the RFC3986 unreserved set.
func rfc3986Encode(value string) string {
	var b strings.Builder
	for i := 0; i < len(value); i++ {
		c := value[i]
		if unreserved[c] {
			b.WriteByte(c)
		} else {
			fmt.Fprintf(&b, "%%%02X", c)
		}
	}
	return b.String()
}

// canonicalExcludedKeys must match packages/core/src/signing.ts.
var canonicalExcludedKeys = map[string]bool{"sig": true, "debug": true}

// utf16Less reports whether a sorts before b by UTF-16 code unit — the way
// JS's default string comparison sorts signing.ts's keys — NOT Go's
// sort.Strings, which compares strings byte-by-byte (UTF-8 byte order). The
// two orders agree for BMP-only keys but diverge for an astral-plane key
// (e.g. an emoji, encoded in UTF-16 as a surrogate pair starting at
// 0xD800-0xDBFF) compared against a BMP private-use key (e.g. U+E000):
// UTF-16 order puts the astral key first, byte order puts it last. Using
// sort.Strings here would build a different canonical string than the
// server and produce a bad signature for such keys.
func utf16Less(a, b string) bool {
	ua := utf16.Encode([]rune(a))
	ub := utf16.Encode([]rune(b))
	for i := 0; i < len(ua) && i < len(ub); i++ {
		if ua[i] != ub[i] {
			return ua[i] < ub[i]
		}
	}
	return len(ua) < len(ub)
}

func canonicalQuery(params map[string]string) string {
	keys := make([]string, 0, len(params))
	for k := range params {
		if canonicalExcludedKeys[k] {
			continue
		}
		keys = append(keys, k)
	}
	sort.Slice(keys, func(i, j int) bool { return utf16Less(keys[i], keys[j]) })
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, rfc3986Encode(k)+"="+rfc3986Encode(params[k]))
	}
	return strings.Join(parts, "&")
}

func signQuery(secretBase64, canonical string) (string, error) {
	secret, err := base64.StdEncoding.DecodeString(secretBase64)
	if err != nil {
		return "", err
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(canonical))
	return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}

func main() {
	params := map[string]string{"template": "basic", "title": "Hello World", "format": "png"}
	canonical := canonicalQuery(params)
	fmt.Println(canonical)
	sig, err := signQuery(signingSecretBase64, canonical)
	if err != nil {
		panic(err)
	}
	fmt.Println(sig)
}