# pubkey.chat — service, CLI, protocol and deployment specification

**Version:** v0.1 design and implementation draft  
**As of:** 2026-11-05  
**Status:** Prototype blueprint, **not a security-audited or deployed release**. This document is intentionally self-contained: the complete example Perl CLI, TypeScript Workers, Terraform, Wrangler settings, website, and a smoke test are embedded below. The code blocks are individually labeled by file path for extraction. All `pubkey.chat` hostnames are illustrative until the domain is registered and deployed.

## 1. Intent and scope

pubkey.chat is a **public, temporary delivery service** for **encrypted, SSH-signed messages**, called **skeets**. There is **no pubkey.chat signup, session, password or OAuth login**. Identity is a username at GitHub or GitLab, e.g. `davismain505@github.com`; account renames are explicitly out of scope. No topics, no allowlists, no groups, no browser messaging client, no hidden sender/recipient metadata, and no permanent message archive.

The user interface is a **single-file Perl CLI**. It uses core Perl modules and executable tools reasonably expected on developer workstations: `ssh-keygen`, `openssl` (OpenSSL or LibreSSL with CMS), and an HTTPS transport (`curl` when available; OpenBSD's `ftp` for GET and `nc -c` for POST). There is **no `jq`, CPAN, Node or browser cryptography on the user's machine**. `ssh-keygen -Y` requires a recent OpenSSH. The initial version supports **Ed25519 SSH signatures only**, though the encryption key is RSA.

The **website at `https://pubkey.chat/` is a static, browser-facing instruction and download page**; it does not accept or decrypt skeets. It offers the Perl executable as a plain downloadable text file, documents prerequisites and caveats, and links to this specification. Terraform and Wrangler are deployment-side dependencies, not client dependencies.

### Non-goals and explicit tradeoffs

* No pseudonymous or anonymous delivery. Sender identity, recipient identity, published skeet hashes, inbox links, and timings may be public. A compromised operator can observe IPs and all metadata reaching the Workers.
* Content confidentiality comes from local encryption and local private keys, **not from pubkey.chat**. Anyone can download ciphertext.
* Only the recipient can decrypt, barring key compromise or cryptographic failure. Long-lived RSA encryption keys **do not provide forward secrecy**.
* Skeets and publication records expire approximately **30 days** after writing. A missing predecessor means the reader stops; it need not diagnose why an object disappeared.
* At-least-once delivery is fine. Repeated references to the same skeet are deduplicated by local skeet hash. The service can delay, reorder, duplicate, suppress, or lose deliveries; it is not an official record.
* Sender display names obtained from publication metadata are *untrusted hints* until the corresponding skeet has been downloaded and its SSH signature independently verified.
* Signed envelopes are not intended as a separate, transport-independent product. Their encoding is chosen to make this particular service implementable.

## 2. Identity and cryptography

**Addresses:** lowercase `username@github.com` or `username@gitlab.com`, used literally. We deliberately ignore account renames and stable numeric provider IDs. The Worker and CLI obtain SSH keys from `https://github.com/<username>.keys` or `https://gitlab.com/<username>.keys`. The Worker **must actually verify** an SSHSIG against a currently listed public key before accepting a submission; clients independently perform the same check for downloaded skeets and signed encryption profiles. If a key disappears from the provider, previously created skeets may stop verifying against *current* keys. Provider requests need caching/rate limits in production.

**Signing:** OpenSSH SSHSIG v1, with `profile@pubkey.chat` for encryption-key profiles and `skeet@pubkey.chat` for messages. The caller signs the **exact UTF-8 bytes** of a JSON payload. In this draft, both payload and armored OpenSSH signature are separately base64-encoded in a JSON transport envelope. This avoids requiring independent implementations to reconstruct identical JSON bytes for signature verification. For the Worker, Ed25519 verification uses Web Crypto and the SSHSIG framing specified by OpenSSH. It must reject unsupported key algorithms, wrong namespace, malformed encoding, and keys missing from the claimed provider account.

**Encryption:** The recipient locally generates a 3072-bit RSA key pair using `openssl genpkey`, with an encrypted private key stored under `~/.pubkey-chat/`. For compatibility with the OpenSSL/LibreSSL `cms` command, the public encryption identity is a **self-signed X.509 certificate**, containing the RSA public key. The certificate is **not trusted through the CA system**: its exact bytes are carried in a recipient-signed encryption-key profile and the sender verifies that SSH signature. The CLI encrypts each message with `openssl cms -encrypt -aes256 -binary -outform DER`; CMS performs hybrid encryption (random symmetric content key, RSA key transport). The author signs the encrypted CMS bytes plus plaintext metadata. Signature verification precedes decryption on receiving.

**Security point:** CMS AES-CBC is not an independently authenticated encryption mode. Here, an authentic SSH signature binds the entire ciphertext envelope; clients **must never decrypt an unverified skeet**. Future protocol versions may adopt a more modern AEAD construction after checking CLI compatibility.

### Signed-envelope transport

```json
{
  "payload_b64": "BASE64_OF_EXACT_UTF8_JSON_PAYLOAD",
  "signature_b64": "BASE64_OF_ARMORED_OPENSSH_SSHSIG"
}
```

A **recipient encryption-key payload** (signed by its `recipient` using the profile namespace):

```json
{
  "v": 1,
  "type": "encryption-key",
  "recipient": "davismain505@github.com",
  "cert_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
}
```

A **skeet payload** (signed by its `sender` using the skeet namespace):

```json
{
  "v": 1,
  "type": "skeet",
  "sender": "alice@github.com",
  "recipient": "davismain505@github.com",
  "recipient_key_sha256": "HEX_SHA256_OF_EXACT_SIGNED_PROFILE_ENVELOPE_BYTES",
  "ciphertext_b64": "BASE64_OF_DER_CMS_ENVELOPED_DATA"
}
```

The hash binding prevents a sender from accidentally encrypting against a different recipient profile than the server currently recognizes. The profile is a signed object published *using the same envelope mechanism* as skeets, but is not delivered into an inbox.

## 3. Data plane and public URLs

| Component | Responsibility | Billing priority |
|---|---|---|
| API Worker at `api.pubkey.chat` | Validate signed encryption profiles/skeets and write immutable or create-once objects | Paid Worker execution only for writes |
| R2 Standard at `data.pubkey.chat` | Public reads; immutable skeets/publication records, mutable inbox HEADs, permanent encryption profiles | Free Internet egress, inexpensive GETs; CDN cached JSON |
| One Cloudflare Queue | R2 `PutObject` events on `skeets/` only | Fixed queue count, per-notification processing |
| Publisher Worker | Consume events, link skeet IDs to inbox HEAD with conditional ETags | No client-facing reads |
| Cloudflare Pages at `pubkey.chat` | Static setup/download website | No browser messaging code |

No D1, KV, Durable Objects, topics, per-user Queues, user table, or authentication token service is required by the MVP.

Objects in the single public R2 bucket:

```text
profiles/davismain505@github.com/encryption-key.json  # permanent, immutable in v1
inboxes/davismain505@github.com/head.json              # permanent, mutable
skeets/<sha256-of-stored-envelope-bytes>.json           # expires after 30 days
publications/<sha256-of-record-bytes>.json              # expires after 30 days
```

The content-derived key **always hashes the exact stored bytes**, not a reconstructed JSON value. Skeets and publication records are written create-only (`If-None-Match: *`). The `HEAD` object is the one mutable object per inbox and contains:

```json
{"v":1,"latest":"64_CHARACTER_HEX_PUBLICATION_ID"}
```

A publication record has the following structure (its own object name is the SHA-256 of these exact stored bytes):

```json
{
  "v": 1,
  "skeet": "64_CHARACTER_HEX_SKEET_ID",
  "sender": "alice@github.com",
  "recipient": "davismain505@github.com",
  "parent": "PREVIOUS_PUBLICATION_HASH_OR_NULL"
}
```

The publisher creates one record per queue event and attaches it to the current inbox head. For multiple pending skeets for one recipient, it can chain several publication records and advance that recipient's HEAD **once**. R2's atomic conditional `put` with `etagMatches` prevents a concurrent publisher from silently overwriting an advance. On conflict, rebuild the chain against the new HEAD; orphan records expire automatically. A queue redelivery may append the same skeet again, and that is fine.

**Read path:** the CLI fetches the recipient's HEAD, walks publication hashes backward until its saved local cursor or the first 404, records newly found `(sender, skeet-ID)` hints locally, and displays sender counts **without downloading the skeet bodies**. `pubkey read <sender>` fetches only that sender's selected skeets, checks each downloaded object's hash, independently verifies the SSH signature against GitHub/GitLab, then locally decrypts. Repeated skeet IDs are deduplicated locally. The service has no concept of read/unread or per-user inbox state.

**Write path:** CLI verifies recipient's signed encryption profile directly, encrypts content locally, signs the skeet; Worker validates the sender's signature and matching recipient key; Worker stores the skeet in R2; R2 emits a `PutObject` notification; publisher links the reference into the inbox. The API's `202 Accepted` means **accepted for asynchronous publication**, not guaranteed delivered to the recipient.

**Retention and cache:** lifecycle rules delete `skeets/` and `publications/` after 2,592,000 seconds (about 30 days); profiles and HEADs are retained. JSON on the data hostname must be explicitly eligible for Cloudflare caching. The example sets object Cache-Control to 30 s for HEAD, 300 s for profiles and 24 h for immutable data; stale cached results and object deletion delays mean this is not strict global erasure or real-time delivery.

## 4. HTTP contract

| Method | URL | Authentication/verification | Response |
|---|---|---|---|
| POST | `https://api.pubkey.chat/v1/encryption-keys` | Recipient's SSHSIG; v1 create-only | `201` or `409` |
| POST | `https://api.pubkey.chat/v1/skeets` | Sender's SSHSIG; verify recipient profile reference | `202`, existing ID `200`, or error |
| GET | `https://data.pubkey.chat/profiles/<recipient>/encryption-key.json` | None; client verifies signed profile | JSON envelope |
| GET | `https://data.pubkey.chat/inboxes/<recipient>/head.json` | None | HEAD or `404` |
| GET | `https://data.pubkey.chat/publications/<sha>.json` | None | Record or `404` |
| GET | `https://data.pubkey.chat/skeets/<sha>.json` | None; client validates hash/signature before decryption | JSON envelope or `404` |

Valid addresses in this draft are lowercase ASCII and cannot contain slashes, percent escapes, spaces, or path components. Clients use HTTPS with certificate verification; never disable TLS verification. No URL given by a remote skeet is followed automatically.

## 5. Example CLI behavior

```sh
# Publish your SSH public key on GitHub/GitLab beforehand.
chmod +x pubkey
./pubkey init davismain505@github.com --ssh-key ~/.ssh/id_ed25519
printf 'Hello!\n' | ./pubkey send alice@gitlab.com
./pubkey inbox
# New senders (unverified until skeets are fetched):
# alice@gitlab.com                      2 skeet(s)
./pubkey read alice@gitlab.com
```

`init` publishes one long-lived recipient certificate in v1; **rotation and recovery after losing the private RSA key are NOT implemented yet**. Do not destroy or replace this key casually. The local CLI stores `~/.pubkey-chat/config.json`, the encrypted RSA private key, the public certificate, and a local inbox cursor/pending index. Local read progress is not sent to the service.

## 6. Embedded implementation sources

**These are source drafts, not a production release.** The Perl file passes `perl -c`; the OpenSSL CMS encrypt/decrypt operations have been exercised locally in this environment. `ssh-keygen`, Terraform, Cloudflare credentials, and live GitHub/GitLab deployment tests were not available here, so those integrations have not been end-to-end validated. Security-critical details of the SSHSIG parser and event/retry pipeline must be tested and reviewed before real users rely on them.

### `client/pubkey`

```perl
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use JSON::PP;
use MIME::Base64 qw(encode_base64 decode_base64);
use Digest::SHA qw(sha256_hex);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use File::Spec;
use Getopt::Long qw(GetOptionsFromArray);
use IPC::Open3;
use Symbol qw(gensym);

# pubkey.chat protocol v1: intentionally narrow, CLI only, no non-core Perl modules.
my $HOME = $ENV{HOME} || $ENV{USERPROFILE} || die "HOME not set\n";
my $DIR = "$HOME/.pubkey-chat";
my $CFG = "$DIR/config.json";
my $DATA = $ENV{PUBKEY_DATA_URL} || 'https://data.pubkey.chat';
my $API = $ENV{PUBKEY_API_URL} || 'https://api.pubkey.chat';
my $JSON = JSON::PP->new->canonical(1)->utf8(1);
sub loadfile {
  my ($path) = @_;
  open my $f, '<:raw', $path or die "read $path: $!\n";
  local $/; my $s = <$f>; close $f; return $s;
}
sub savefile {
  my ($path, $s) = @_;
  open my $f, '>:raw', $path or die "write $path: $!\n";
  print {$f} $s; close $f or die "close $path: $!\n";
}
sub json { $JSON->encode($_[0]) }
sub parse { $JSON->decode($_[0]) }
sub assert_addr {
  my ($a) = @_;
  die "address must be lowercase username\@github.com or username\@gitlab.com\n"
    unless defined($a) && $a =~ /^[a-z0-9][a-z0-9_.-]{0,62}\@(github|gitlab)\.com$/;
  return $a;
}
sub run { system(@_) == 0 or die "command failed: $_[0]\n" }
sub command_exists {
  my ($name) = @_;
  for my $d (File::Spec->path()) {
    for my $suffix ('', '.exe') {
      return 1 if -x File::Spec->catfile($d, $name . $suffix);
    }
  }
  return 0;
}
sub temporary { tempdir('pubkey-XXXXXX', TMPDIR => 1, CLEANUP => 1) }
sub ensure_dir { make_path($DIR, { mode => 0700 }) unless -d $DIR }
sub config { -f $CFG or die "run: pubkey init <your-address> --ssh-key <path>\n"; return parse(loadfile($CFG)) }
sub https_parts {
  my ($url) = @_;
  $url =~ m{^https://([A-Za-z0-9.-]+)(/[^\r\n ]*)$} or die "invalid HTTPS URL\n";
  return ($1, $2);
}
sub unchunk {
  my ($in) = @_;
  my $out = '';
  while (1) {
    $in =~ s/\A([^\r\n]*)\r\n// or die "bad chunk length\n";
    my ($size) = $1 =~ /^([0-9A-Fa-f]+)(?:;.*)?$/ or die "bad chunk header\n";
    my $n = hex($size);
    return $out if $n == 0;
    die "bad chunk body\n" if length($in) < $n + 2;
    $out .= substr($in, 0, $n, '');
    substr($in, 0, 2, '') eq "\r\n" or die "bad chunk terminator\n";
  }
}
# OpenBSD fallback: nc -c performs certificate/hostname validation by default.
# Uses HTTP/1.0, Connection: close; reject redirects and malformed responses.
sub nc_request {
  my ($method, $url, $body) = @_;
  my ($host, $path) = https_parts($url);
  my $err = gensym;
  my $pid = open3(my $w, my $r, $err, 'nc', '-c', '-N', '-w', '20', $host, '443');
  binmode($w); binmode($r);
  my $request = "$method $path HTTP/1.0\r\nHost: $host\r\nConnection: close\r\nAccept-Encoding: identity\r\n";
  if ($method eq 'POST') {
    $request .= 'Content-Type: application/json' . "\r\n";
    $request .= 'Content-Length: ' . length($body) . "\r\n";
  }
  print {$w} $request, "\r\n", ($method eq 'POST' ? $body : ''); close $w;
  local $/; my $raw = <$r>; close $r; waitpid($pid, 0);
  die "TLS connection failed\n" if $? != 0;
  defined $raw && $raw =~ s/\AHTTP\/1\.[01] (\d{3})[^\r\n]*\r\n(.*?)\r\n\r\n//s
    or die "bad HTTP response\n";
  my ($code, $headers) = ($1, $2);
  $raw = unchunk($raw) if $headers =~ /^Transfer-Encoding:\s*chunked\s*$/im;
  return ($code + 0, $raw);
}
sub http {
  my ($method, $url, $body) = @_;
  https_parts($url);
  if (command_exists('curl')) {
    my $tmp = temporary();
    my $out = "$tmp/out"; my $code = "$tmp/code";
    my @args = ('curl', '--silent', '--show-error', '--max-time', '20',
      '--proto', '=https', '--proto-redir', '=https', '-o', $out, '-w', '%{http_code}');
    if ($method eq 'POST') {
      savefile("$tmp/body", $body);
      push @args, '-X', 'POST', '-H', 'Content-Type: application/json', '--data-binary', "\@$tmp/body";
    } else { push @args, '-L'; }
    push @args, $url;
    open my $saved, '>&', \*STDOUT or die $!;
    open STDOUT, '>:raw', $code or die $!;
    my $rc = system(@args);
    open STDOUT, '>&', $saved or die $!;
    close $saved;
    die "curl failed: $url\n" if $rc != 0;
    return (0 + loadfile($code), loadfile($out));
  }
  if ($method eq 'GET' && command_exists('ftp')) {
    my $tmp = temporary(); my $out = "$tmp/out";
    my $rc = system('ftp', '-o', $out, $url);
    # OpenBSD ftp doesn't reliably expose HTTP status for missing objects here.
    # Only use missing=404 for inbox/publication discovery, never for key lookup.
    return ($rc == 0 ? 200 : 404, $rc == 0 ? loadfile($out) : '');
  }
  return nc_request($method, $url, $body) if command_exists('nc');
  die "No HTTPS transport: need curl, or OpenBSD ftp (GET) and nc -c (POST)\n";
}
sub get {
  my ($url, $missing_ok) = @_;
  my ($code, $body) = http('GET', $url);
  return undef if $code == 404 && $missing_ok;
  die "GET $url: HTTP $code\n" if $code != 200;
  return $body;
}
sub post {
  my ($path, $body) = @_;
  my ($code, $resp) = http('POST', "$API$path", $body);
  die "POST $path: HTTP $code: $resp\n" unless $code >= 200 && $code < 300;
  return parse($resp);
}
sub sign {
  my ($payload, $namespace, $sshkey) = @_;
  my $tmp = temporary();
  savefile("$tmp/payload", json($payload));
  run('ssh-keygen', '-Y', 'sign', '-n', $namespace, '-f', $sshkey, "$tmp/payload");
  return json({ payload_b64 => encode_base64(loadfile("$tmp/payload"), ''),
                signature_b64 => encode_base64(loadfile("$tmp/payload.sig"), '') });
}
sub verify {
  my ($outer_bytes, $who, $namespace) = @_;
  assert_addr($who);
  my $e = parse($outer_bytes);
  my $payload_raw = decode_base64($e->{payload_b64});
  my $payload = parse($payload_raw);
  my ($u, $provider) = split /\@/, $who;
  my $keys = get("https://$provider/$u.keys", 0);
  my $allowed = join '', map {
    my ($kind, $key) = split /\s+/, $_;
    defined($key) && $kind eq 'ssh-ed25519' ? "$who $kind $key\n" : '';
  } split /\r?\n/, $keys;
  die "No supported Ed25519 keys for $who\n" unless length($allowed);
  my $tmp = temporary();
  savefile("$tmp/signers", $allowed);
  savefile("$tmp/payload", $payload_raw);
  savefile("$tmp/sig", decode_base64($e->{signature_b64}));
  open my $in, '<:raw', "$tmp/payload" or die $!;
  open my $saved, '<&', \*STDIN or die $!;
  open STDIN, '<&', $in or die $!;
  my $rc = system('ssh-keygen', '-Y', 'verify', '-n', $namespace,
                  '-f', "$tmp/signers", '-I', $who, '-s', "$tmp/sig");
  open STDIN, '<&', $saved or die $!;
  close $saved;
  die "Signature verification failed for $who\n" if $rc != 0;
  return $payload;
}
sub profile_url { "$DATA/profiles/$_[0]/encryption-key.json" }
sub state_path { "$DIR/inbox.json" }
sub read_state { -f state_path() ? parse(loadfile(state_path())) : { cursor => undef, pending => {}, read => {} } }
sub write_state { savefile(state_path(), json($_[0])) }
my @args = @ARGV;
my $cmd = shift(@args) // '';
if ($cmd eq 'init') {
  my $who = assert_addr(shift @args);
  my $sshkey = "$HOME/.ssh/id_ed25519";
  GetOptionsFromArray(\@args, 'ssh-key=s' => \$sshkey) or die "bad options\n";
  die "unexpected arguments\n" if @args;
  ensure_dir();
  die "$CFG exists; refusing to overwrite local private key\n" if -e $CFG;
  my $private = "$DIR/encryption-private.pem";
  my $cert = "$DIR/encryption-cert.pem";
  die "encryption key already exists\n" if -e $private || -e $cert;
  # Passphrase is entered interactively; key is never uploaded.
  run('openssl', 'genpkey', '-algorithm', 'RSA', '-pkeyopt', 'rsa_keygen_bits:3072',
      '-aes-256-cbc', '-out', $private);
  chmod 0600, $private;
  run('openssl', 'req', '-new', '-x509', '-sha256', '-days', '3650',
      '-key', $private, '-out', $cert, '-subj', '/CN=pubkey-chat');
  my $payload = { v => 1, type => 'encryption-key', recipient => $who,
                  cert_pem => loadfile($cert) };
  my $env = sign($payload, 'profile@pubkey.chat', $sshkey);
  my $resp = post('/v1/encryption-keys', $env);
  savefile($CFG, json({ identity => $who, ssh_key => $sshkey }));
  print "Published encryption profile: $resp->{profile}\n";
} elsif ($cmd eq 'send') {
  my $to = assert_addr(shift @args);
  my $file = '-';
  GetOptionsFromArray(\@args, 'file=s' => \$file) or die "bad options\n";
  die "unexpected arguments\n" if @args;
  my $cfg = config(); my $tmp = temporary();
  my $profile_bytes = get(profile_url($to), 0);
  my $profile = verify($profile_bytes, $to, 'profile@pubkey.chat');
  die "wrong profile recipient\n" if $profile->{recipient} ne $to || $profile->{type} ne 'encryption-key';
  savefile("$tmp/recipient.crt", $profile->{cert_pem});
  my $plaintext;
  if ($file eq '-') { binmode STDIN; local $/; $plaintext = <STDIN>; }
  else { $plaintext = loadfile($file); }
  die "plaintext too large\n" if length($plaintext) > 32768;
  savefile("$tmp/plaintext", $plaintext);
  run('openssl', 'cms', '-encrypt', '-binary', '-aes256', '-in', "$tmp/plaintext",
      '-outform', 'DER', '-out', "$tmp/cipher.der", "$tmp/recipient.crt");
  my $payload = { v => 1, type => 'skeet', sender => $cfg->{identity}, recipient => $to,
    recipient_key_sha256 => sha256_hex($profile_bytes),
    ciphertext_b64 => encode_base64(loadfile("$tmp/cipher.der"), '') };
  my $env = sign($payload, 'skeet@pubkey.chat', $cfg->{ssh_key});
  my $resp = post('/v1/skeets', $env);
  print "Accepted skeet: $resp->{id}\n";
} elsif ($cmd eq 'inbox') {
  die "unexpected arguments\n" if @args;
  my $cfg = config(); my $who = $cfg->{identity}; my $state = read_state();
  my $rawhead = get("$DATA/inboxes/$who/head.json", 1);
  if (defined $rawhead) {
    my $head = parse($rawhead);
    my $tip = $head->{latest};
    my $cur = $tip;
    my %walk;
    while ($cur && (!$state->{cursor} || $cur ne $state->{cursor})) {
      die "invalid publication ID\n" unless $cur =~ /^[0-9a-f]{64}$/;
      last if $walk{$cur}++;
      my $raw = get("$DATA/publications/$cur.json", 1);
      last unless defined $raw;  # 30-day retention boundary
      die "publication hash mismatch\n" if sha256_hex($raw) ne $cur;
      my $p = parse($raw);
      last unless $p->{recipient} eq $who;
      $state->{pending}{$p->{skeet}} ||= { sender => $p->{sender}, pub => $cur };
      $cur = $p->{parent};
    }
    $state->{cursor} = $tip if $tip;
  }
  for my $id (keys %{$state->{pending}}) {
    delete $state->{pending}{$id} if $state->{read}{$id};
  }
  write_state($state);
  my %counts;
  for my $p (values %{$state->{pending}}) { $counts{$p->{sender}}++; }
  print "New senders (unverified until skeets are fetched):\n";
  for my $who (sort keys %counts) { printf "%-36s %d skeet(s)\n", $who, $counts{$who}; }
  print "No pending skeets.\n" unless keys %counts;
} elsif ($cmd eq 'read') {
  my $wanted = assert_addr(shift @args);
  die "unexpected arguments\n" if @args;
  my $cfg = config(); my $state = read_state();
  for my $id (sort keys %{$state->{pending}}) {
    my $hint = $state->{pending}{$id};
    next unless $hint->{sender} eq $wanted;
    die "invalid skeet hash\n" unless $id =~ /^[0-9a-f]{64}$/;
    my $raw = get("$DATA/skeets/$id.json", 1);
    next unless defined $raw;  # expired object, leave pending until next cleanup
    die "skeet hash mismatch\n" if sha256_hex($raw) ne $id;
    my $p = verify($raw, $wanted, 'skeet@pubkey.chat');
    die "sender or recipient mismatch\n" unless $p->{sender} eq $wanted && $p->{recipient} eq $cfg->{identity};
    my $tmp = temporary();
    savefile("$tmp/cipher.der", decode_base64($p->{ciphertext_b64}));
    run('openssl', 'cms', '-decrypt', '-binary', '-inform', 'DER',
      '-in', "$tmp/cipher.der", '-recip', "$DIR/encryption-cert.pem",
      '-inkey', "$DIR/encryption-private.pem", '-out', "$tmp/plaintext");
    print "--- $wanted [$id] ---\n", loadfile("$tmp/plaintext"), "\n";
    $state->{read}{$id} = 1;
    delete $state->{pending}{$id};
    write_state($state);
  }
} else {
  print STDERR <<'USAGE';
Usage:
  pubkey init you@github.com --ssh-key ~/.ssh/id_ed25519
  echo 'hello' | pubkey send recipient@gitlab.com
  pubkey send recipient@github.com --file message.txt
  pubkey inbox
  pubkey read sender@github.com
USAGE
  exit 2;
}
```

### `workers/api/sshsig.ts`

```typescript
/** OpenSSH SSHSIG v1 / Ed25519 verifier. Does NOT support RSA/ECDSA/FIDO keys. */
const te = new TextEncoder();
const td = new TextDecoder('utf-8', { fatal: true });
export const bytes = (s: string) => te.encode(s);
export const utf8 = (b: Uint8Array) => td.decode(b);
export const hex = (b: Uint8Array) => Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
export async function sha256(b: Uint8Array): Promise<string> {
  return hex(new Uint8Array(await crypto.subtle.digest('SHA-256', b)));
}
export function base64(b: Uint8Array): string {
  let s = ''; for (let i = 0; i < b.length; i += 8192)
    s += String.fromCharCode(...b.slice(i, i + 8192));
  return btoa(s);
}
export function unbase64(s: string): Uint8Array {
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) throw Error('bad base64');
  return Uint8Array.from(atob(s), c => c.charCodeAt(0));
}
function concat(...parts: Uint8Array[]): Uint8Array {
  const out = new Uint8Array(parts.reduce((n, b) => n + b.length, 0));
  let i = 0; for (const p of parts) { out.set(p, i); i += p.length; }
  return out;
}
function sshstr(b: Uint8Array): Uint8Array {
  const prefix = new Uint8Array(4);
  new DataView(prefix.buffer).setUint32(0, b.length, false);
  return concat(prefix, b);
}
class Reader {
  p = 0;
  constructor(readonly b: Uint8Array) {}
  take(n: number): Uint8Array {
    if (n < 0 || this.p + n > this.b.length) throw Error('truncated SSH wire format');
    const part = this.b.slice(this.p, this.p + n); this.p += n; return part;
  }
  u32(): number { const b = this.take(4); return new DataView(b.buffer).getUint32(0, false); }
  str(): Uint8Array { return this.take(this.u32()); }
  end(): void { if (this.p !== this.b.length) throw Error('trailing SSH wire data'); }
}
export type Envelope = { payload_b64: string; signature_b64: string };
export function decodeEnvelope(body: Uint8Array): { envelope: Envelope; payload: any; signed: Uint8Array } {
  const outer = JSON.parse(utf8(body));
  if (!outer || typeof outer.payload_b64 !== 'string' || typeof outer.signature_b64 !== 'string') throw Error('bad envelope');
  const signed = unbase64(outer.payload_b64);
  if (signed.length > 65536) throw Error('payload too large');
  const payload = JSON.parse(utf8(signed));
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw Error('bad payload');
  return { envelope: outer, payload, signed };
}
export function validAddress(s: unknown): s is string {
  return typeof s === 'string' && s === s.toLowerCase() &&
    /^[a-z0-9][a-z0-9_.-]{0,62}@(github|gitlab)\.com$/.test(s);
}
function keyURL(address: string): string {
  const [user, domain] = address.split('@');
  if (!validAddress(address)) throw Error('invalid address');
  return `https://${domain}/${encodeURIComponent(user)}.keys`;
}
export async function verifyEnvelope(
  e: Envelope, signed: Uint8Array, principal: string, namespace: string
): Promise<void> {
  if (!validAddress(principal)) throw Error('invalid identity');
  const armor = utf8(unbase64(e.signature_b64));
  const match = /^-----BEGIN SSH SIGNATURE-----\r?\n([A-Za-z0-9+/=\r\n]+)-----END SSH SIGNATURE-----\r?\n?$/.exec(armor);
  if (!match) throw Error('invalid SSHSIG armor');
  const reader = new Reader(unbase64(match[1].replace(/\s+/g, '')));
  if (utf8(reader.take(6)) !== 'SSHSIG' || reader.u32() !== 1) throw Error('bad SSHSIG header');
  const pubblob = reader.str();
  if (utf8(reader.str()) !== namespace) throw Error('wrong signature namespace');
  const reserved = reader.str();
  if (reserved.length) throw Error('unsupported reserved SSHSIG field');
  const hashName = utf8(reader.str());
  if (hashName !== 'sha256' && hashName !== 'sha512') throw Error('unsupported message hash');
  const signatureBlob = reader.str(); reader.end();
  const keyReader = new Reader(pubblob);
  if (utf8(keyReader.str()) !== 'ssh-ed25519') throw Error('v1 requires ssh-ed25519 signing key');
  const rawKey = keyReader.str(); keyReader.end();
  if (rawKey.length !== 32) throw Error('invalid Ed25519 key');
  const sigReader = new Reader(signatureBlob);
  if (utf8(sigReader.str()) !== 'ssh-ed25519') throw Error('unsupported signature algorithm');
  const sig = sigReader.str(); sigReader.end();
  if (sig.length !== 64) throw Error('invalid Ed25519 signature');
  const response = await fetch(keyURL(principal), { redirect: 'error', signal: AbortSignal.timeout(5000) });
  if (!response.ok) throw Error('identity provider keys unavailable');
  const listed = await response.text();
  if (listed.length > 256000) throw Error('provider response too large');
  const recognized = listed.split(/\r?\n/).some(line => {
    const bits = line.trim().split(/\s+/);
    return bits[0] === 'ssh-ed25519' && bits.length >= 2 && bits[1] === base64(pubblob);
  });
  if (!recognized) throw Error('SSH key not present in provider profile');
  const hashed = new Uint8Array(await crypto.subtle.digest(hashName === 'sha512' ? 'SHA-512' : 'SHA-256', signed));
  const signedData = concat(bytes('SSHSIG'), sshstr(bytes(namespace)), sshstr(new Uint8Array()), sshstr(bytes(hashName)), sshstr(hashed));
  const key = await crypto.subtle.importKey('raw', rawKey, 'Ed25519', false, ['verify']);
  if (!(await crypto.subtle.verify('Ed25519', key, sig, signedData))) throw Error('invalid SSH signature');
}
```

### `workers/api/index.ts`

```typescript
import { bytes, decodeEnvelope, sha256, validAddress, verifyEnvelope } from './sshsig';
interface Env { DATA: R2Bucket }
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
  status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
});
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (req.method !== 'POST') return json({ error: 'POST required' }, 405);
    const path = new URL(req.url).pathname;
    if (path !== '/v1/skeets' && path !== '/v1/encryption-keys') return json({ error: 'not found' }, 404);
    if (Number(req.headers.get('content-length') || 0) > 120000) return json({ error: 'too large' }, 413);
    let body: Uint8Array;
    try { body = new Uint8Array(await req.arrayBuffer()); }
    catch { return json({ error: 'invalid body' }, 400); }
    if (body.length > 120000) return json({ error: 'too large' }, 413);
    try {
      const { envelope, payload, signed } = decodeEnvelope(body);
      if (payload.v !== 1) return json({ error: 'unsupported version' }, 400);
      if (path === '/v1/encryption-keys') {
        if (payload.type !== 'encryption-key' || !validAddress(payload.recipient) ||
            typeof payload.cert_pem !== 'string' || payload.cert_pem.length > 8192 ||
            !/^-----BEGIN CERTIFICATE-----\n[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----\n?$/.test(payload.cert_pem))
          return json({ error: 'invalid encryption-key profile' }, 400);
        await verifyEnvelope(envelope, signed, payload.recipient, 'profile@pubkey.chat');
        // v1: publish once; rotation protocol is deliberately deferred.
        const key = `profiles/${payload.recipient}/encryption-key.json`;
        const result = await env.DATA.put(key, body, {
          onlyIf: new Headers({ 'If-None-Match': '*' }),
          httpMetadata: { contentType: 'application/json', cacheControl: 'public, max-age=300' }
        });
        if (!result) return json({ error: 'encryption key already published (rotation not implemented)' }, 409);
        return json({ ok: true, profile: key }, 201);
      }
      if (payload.type !== 'skeet' || !validAddress(payload.sender) || !validAddress(payload.recipient) ||
          typeof payload.ciphertext_b64 !== 'string' || payload.ciphertext_b64.length > 93000 ||
          !/^[A-Za-z0-9+/=]+$/.test(payload.ciphertext_b64) ||
          typeof payload.recipient_key_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(payload.recipient_key_sha256))
        return json({ error: 'invalid skeet' }, 400);
      await verifyEnvelope(envelope, signed, payload.sender, 'skeet@pubkey.chat');
      const profile = await env.DATA.get(`profiles/${payload.recipient}/encryption-key.json`);
      if (!profile) return json({ error: 'recipient has no published encryption key' }, 404);
      const profileBody = new Uint8Array(await profile.arrayBuffer());
      // Fingerprint a verified *profile envelope*, not just the certificate field.
      if (await sha256(profileBody) !== payload.recipient_key_sha256)
        return json({ error: 'recipient profile changed; re-encrypt' }, 409);
      const id = await sha256(body);
      const result = await env.DATA.put(`skeets/${id}.json`, body, {
        onlyIf: new Headers({ 'If-None-Match': '*' }),
        httpMetadata: { contentType: 'application/json', cacheControl: 'public, max-age=86400' }
      });
      return json({ ok: true, id, accepted: true, duplicate: result === null }, result ? 202 : 200);
    } catch (e) {
      // Don't disclose provider payloads or secrets in errors.
      return json({ error: e instanceof Error ? e.message : 'validation failure' }, 400);
    }
  }
};
```

### `workers/publisher/index.ts`

```typescript
interface Env { DATA: R2Bucket }
type Notice = { action: string; object: { key: string } };
type Skeet = { sender: string; recipient: string; type: 'skeet' };
const text = new TextEncoder();
const decode = new TextDecoder();
async function hash(b: Uint8Array): Promise<string> {
  return [...new Uint8Array(await crypto.subtle.digest('SHA-256', b))]
    .map(x => x.toString(16).padStart(2, '0')).join('');
}
function extractSkeet(raw: string): Skeet {
  const outer = JSON.parse(raw);
  const p = JSON.parse(decode.decode(Uint8Array.from(atob(outer.payload_b64), c => c.charCodeAt(0))));
  if (p.type !== 'skeet' || typeof p.sender !== 'string' || typeof p.recipient !== 'string')
    throw Error('unexpected stored payload');
  return p;
}
async function append(env: Env, destination: string, inputs: { skeet: string; sender: string }[]): Promise<void> {
  const headKey = `inboxes/${destination}/head.json`;
  for (let attempt = 0; attempt < 8; attempt++) {
    const old = await env.DATA.get(headKey);
    const head = old ? JSON.parse(await old.text()) as { latest: string | null } : { latest: null };
    let parent: string | null = head.latest;
    for (const item of inputs) {
      const record = { v: 1, skeet: item.skeet, sender: item.sender, recipient: destination, parent };
      const raw = text.encode(JSON.stringify(record));
      const id = await hash(raw);
      const created = await env.DATA.put(`publications/${id}.json`, raw, {
        onlyIf: new Headers({ 'If-None-Match': '*' }),
        httpMetadata: { contentType: 'application/json', cacheControl: 'public, max-age=86400' }
      });
      // A pre-existing identical immutable publication is OK.
      void created;
      parent = id;
    }
    const next = JSON.stringify({ v: 1, latest: parent });
    const changed = await env.DATA.put(headKey, next, {
      onlyIf: old ? { etagMatches: old.etag } : new Headers({ 'If-None-Match': '*' }),
      httpMetadata: { contentType: 'application/json', cacheControl: 'public, max-age=30' }
    });
    if (changed) return;
    // An orphan publication chain is acceptable; try building atop the new HEAD.
  }
  throw Error('HEAD update contention; retry queue batch');
}
export default {
  async queue(batch: MessageBatch<Notice>, env: Env): Promise<void> {
    const groups = new Map<string, { messages: Message<Notice>[]; inputs: { skeet: string; sender: string }[] }>();
    for (const m of batch.messages) {
      try {
        const k = m.body?.object?.key || '';
        if (m.body.action !== 'PutObject' || !/^skeets\/[0-9a-f]{64}\.json$/.test(k)) {
          m.ack(); continue;
        }
        const o = await env.DATA.get(k);
        if (!o) { m.retry({ delaySeconds: 20 }); continue; }
        const payload = extractSkeet(await o.text());
        const g = groups.get(payload.recipient) || { messages: [], inputs: [] };
        g.messages.push(m);
        g.inputs.push({ skeet: k.slice('skeets/'.length, -'.json'.length), sender: payload.sender });
        groups.set(payload.recipient, g);
      } catch { m.retry({ delaySeconds: 20 }); }
    }
    // One consumer invocation, sequential groups, one HEAD update per destination.
    for (const [destination, group] of groups) {
      try {
        await append(env, destination, group.inputs);
        for (const m of group.messages) m.ack();
      } catch { for (const m of group.messages) m.retry({ delaySeconds: 20 }); }
    }
  }
};
```

### `workers/api/wrangler.jsonc`

```jsonc
{
  "name": "pubkey-api",
  "main": "index.ts",
  "compatibility_date": "2026-11-05",
  "r2_buckets": [{ "binding": "DATA", "bucket_name": "pubkey-chat-data" }]
}
```

### `workers/publisher/wrangler.jsonc`

```jsonc
{
  "name": "pubkey-publisher",
  "main": "index.ts",
  "compatibility_date": "2026-11-05",
  "r2_buckets": [{ "binding": "DATA", "bucket_name": "pubkey-chat-data" }]
}
```

## 7. Terraform: Cloudflare provisioning

Terraform uses provider v5.x to create the permanent infrastructure. **Terraform does not create per-user inboxes, encryption profiles or HEADs.** These are ordinary application objects. It also does **not** own the Worker source code: Wrangler deploys Worker code/bindings, and Terraform separately installs its Queue consumer trigger and API custom domain. Do not declare the same consumer in Wrangler.

Use a **private** remote Terraform state backend with state locking or serialize `apply` in CI. Do not expose state through the public R2 data bucket. Terraform will require scoped Cloudflare credentials with the permissions necessary for R2, Workers, Queues, Pages, DNS, and Cache Rules. Production and staging should use distinct resource names and buckets; the examples below show production names only.

**Bootstrap in two phases:** First create the R2 bucket, two Queues and Pages project (using a temporary targeted apply or a separate bootstrap module). Next deploy the API and publisher Workers with Wrangler. Finally apply the full configuration: consumer subscription, R2 event notification, domains and cache rule. An initial full apply may fail if the target Worker scripts do not exist yet. Enable R2 notifications **last** so that events do not arrive without a functioning consumer.

### `terraform/main.tf`

```hcl
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.24" }
  }
}
provider "cloudflare" {}

variable "account_id" {
  type = string
}
variable "zone_id" {
  type = string
}
variable "domain" {
  type = string
  default = "pubkey.chat"
}

resource "cloudflare_r2_bucket" "data" {
  account_id = var.account_id
  name = "pubkey-chat-data"
  storage_class = "Standard"
  lifecycle { prevent_destroy = true }
}
resource "cloudflare_r2_bucket_lifecycle" "retention" {
  account_id = var.account_id
  bucket_name = cloudflare_r2_bucket.data.name
  rules = [
    {
      id = "skeets-30d"
      enabled = true
      conditions = { prefix = "skeets/" }
      delete_objects_transition = { condition = { type = "Age", max_age = 2592000 } }
    },
    {
      id = "publications-30d"
      enabled = true
      conditions = { prefix = "publications/" }
      delete_objects_transition = { condition = { type = "Age", max_age = 2592000 } }
    }
  ]
}
resource "cloudflare_r2_custom_domain" "data" {
  account_id  = var.account_id
  bucket_name = cloudflare_r2_bucket.data.name
  domain      = "data.${var.domain}"
  zone_id     = var.zone_id
  enabled     = true
  min_tls     = "1.2"
}
resource "cloudflare_queue" "publish" {
  account_id = var.account_id
  queue_name = "pubkey-publications"
}
resource "cloudflare_queue" "dead" {
  account_id = var.account_id
  queue_name = "pubkey-dead-letters"
}
# Enable after the two Workers are deployed successfully.
resource "cloudflare_queue_consumer" "publisher" {
  account_id = var.account_id
  queue_id = cloudflare_queue.publish.queue_id
  type = "worker"
  script_name = "pubkey-publisher"
  dead_letter_queue = cloudflare_queue.dead.queue_name
  settings = {
    batch_size = 100
    max_concurrency = 1
    max_wait_time_ms = 5000
    max_retries = 10
    retry_delay = 20
  }
}
resource "cloudflare_r2_bucket_event_notification" "skeets" {
  account_id = var.account_id
  bucket_name = cloudflare_r2_bucket.data.name
  queue_id = cloudflare_queue.publish.queue_id
  rules = [{ actions = ["PutObject"], prefix = "skeets/", description = "Accepted skeet objects" }]
  depends_on = [cloudflare_queue_consumer.publisher]
}
resource "cloudflare_workers_custom_domain" "api" {
  account_id = var.account_id
  zone_id = var.zone_id
  hostname = "api.${var.domain}"
  service = "pubkey-api"
  enabled = true
}
# Static documentation and Perl executable download; no interactive web client.
resource "cloudflare_pages_project" "site" {
  account_id = var.account_id
  name = "pubkey-chat-site"
  production_branch = "main"
}
resource "cloudflare_pages_domain" "site" {
  account_id = var.account_id
  project_name = cloudflare_pages_project.site.name
  name = var.domain
}
# Pages custom domains do not create DNS records themselves.
resource "cloudflare_dns_record" "site" {
  zone_id = var.zone_id
  name = var.domain
  type = "CNAME"
  content = "pubkey-chat-site.pages.dev"
  proxied = true
  ttl = 1
}

# Rule applies to the data hostname only. Respect R2 Cache-Control
# (30s HEAD / 300s profiles / 24h immutable objects).
resource "cloudflare_ruleset" "public_data_cache" {
  zone_id = var.zone_id
  name = "pubkey data cache"
  description = "Cache public R2 JSON on data hostname"
  kind = "zone"
  phase = "http_request_cache_settings"
  rules = [{
    ref = "pubkey_cache_public_json"
    action = "set_cache_settings"
    expression = "(http.host eq \"data.${var.domain}\")"
    description = "Enable cache for R2 JSON responses"
    enabled = true
    action_parameters = {
      cache = true
      edge_ttl = { mode = "respect_origin" }
    }
  }]
}
```

## 8. Browser-facing website (instructions and CLI download only)

Deploy `site/index.html`, copy `client/pubkey` to `site/pubkey`, and copy this Markdown file to `site/spec.md` before uploading the static directory. Cloudflare Pages serves the assets, but **there is no sign/send/read workflow in HTML or JavaScript**. A compromised website could distribute a malicious Perl client, so a production release should publish independently verifiable checksums and a signed release manifest anchored outside the mutable website; this is outstanding work, not an existing guarantee.

### `site/index.html`

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pubkey.chat — SSH-verified encrypted messaging from your terminal</title>
<meta name="description" content="A public delivery service for locally encrypted, SSH-signed messages. No new account.">
<style>
:root {color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.55}
body {margin: 4rem auto; max-width: 48rem; padding: 0 1.25rem}
h1 {letter-spacing:-.04em; font-size: clamp(2.4rem, 6vw, 4rem)}
pre {padding: 1.2rem; border: 1px solid #8886; border-radius: .6rem; overflow:auto}
a {text-underline-offset: .2rem} footer {margin-top: 5rem; opacity:.75}
</style>
</head>
<body>
<main>
<h1>pubkey.chat</h1>
<p>Send encrypted messages with identities you already have on GitHub or GitLab. No service signup. No browser-based messaging.</p>
<p><a href="/pubkey" download="pubkey">Download the Perl CLI</a> · <a href="/spec.md">Read the protocol specification</a></p>
<h2>Requirements</h2>
<p>Perl with standard modules, OpenSSH with <code>ssh-keygen -Y</code>, OpenSSL/LibreSSL with CMS, and HTTPS transport (<code>curl</code>, or OpenBSD <code>ftp</code> plus <code>nc -c</code>).</p>
<h2>Getting started</h2>
<pre><code>chmod +x pubkey
./pubkey init you@github.com --ssh-key ~/.ssh/id_ed25519
echo 'Hello!' | ./pubkey send friend@gitlab.com
./pubkey inbox
./pubkey read friend@gitlab.com</code></pre>
<p>Publish your SSH public key to GitHub or GitLab before initializing. Initialization generates a separate, long-lived RSA decryption key on your machine and publishes a signed public encryption certificate.</p>
<h2>What is public?</h2>
<p>Who sent a skeet to whom, inbox history, ciphertext, public keys, and delivery references. The service does not promise anonymity. Skeet bodies are encrypted on the sender's computer and decrypted on the recipient's computer.</p>
<p>Skeets and their publication records are retained for approximately 30 days. Save anything important locally. A missing publication ends inbox traversal.</p>
<h2>Security scope</h2>
<p>The service can drop, reorder, or duplicate deliveries. Clients verify signatures and encryption-key profiles independently using provider-published SSH public keys. A compromised recipient private decryption key can expose stored ciphertext.</p>
</main><footer>pubkey.chat is a delivery mechanism, not an archive.</footer>
</body></html>
```

## 9. Test and deployment checklist

The following local shell test checks that external OpenSSH and OpenSSL CMS formats round-trip on a developer's actual operating system. It does not verify the Worker SSHSIG implementation. Repeat on Git Bash and stock OpenBSD. In CI, include known-good and deliberately malformed SSHSIG vectors and server/client agreement on hashes and namespaces.

### `tests/interop.sh`

```sh
#!/bin/sh
# Local cryptography smoke test (does not contact Cloudflare).
set -eu
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
ssh-keygen -q -t ed25519 -N '' -f "$tmp/ssh" >/dev/null
printf 'hello sshsig\n' > "$tmp/payload"
ssh-keygen -Y sign -n skeet@pubkey.chat -f "$tmp/ssh" "$tmp/payload" >/dev/null 2>&1
printf 'test@github.com %s\n' "$(cat "$tmp/ssh.pub")" > "$tmp/allowed"
ssh-keygen -Y verify -n skeet@pubkey.chat -f "$tmp/allowed" -I test@github.com -s "$tmp/payload.sig" < "$tmp/payload" >/dev/null
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$tmp/priv.pem" >/dev/null 2>&1
openssl req -new -x509 -key "$tmp/priv.pem" -out "$tmp/cert.pem" -subj '/CN=interop' -days 1 >/dev/null 2>&1
openssl cms -encrypt -binary -aes256 -in "$tmp/payload" -outform DER -out "$tmp/cipher.der" "$tmp/cert.pem"
openssl cms -decrypt -binary -inform DER -in "$tmp/cipher.der" -recip "$tmp/cert.pem" -inkey "$tmp/priv.pem" -out "$tmp/plaintext"
cmp "$tmp/payload" "$tmp/plaintext"
echo 'SSHSIG and OpenSSL CMS interoperability: PASS'
```

### Example deployment commands

```sh
# 1. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID appropriately.
#    Provision base resources first; Terraform resources with script_name
#    references require the Worker to already exist.
cd terraform
terraform init
terraform plan -var="account_id=$CLOUDFLARE_ACCOUNT_ID" -var="zone_id=$CF_ZONE_ID"
# First-time bootstrap: selectively apply base R2/Queue/Pages resources or
# split those resources into a permanent bootstrap module.

# 2. Worker code. Wrangler owns these deployments only.
cd ../workers/api && npx wrangler deploy
cd ../publisher && npx wrangler deploy

# 3. Now apply all infrastructure: consumer, notifications, domains, cache.
cd ../../terraform
terraform apply -var="account_id=$CLOUDFLARE_ACCOUNT_ID" -var="zone_id=$CF_ZONE_ID"

# 4. Public instructions/download site.
cd ..
cp client/pubkey site/pubkey
cp ../pubkey-chat-spec.md site/spec.md  # adjust path for repository layout
npx wrangler pages deploy site --project-name pubkey-chat-site --branch main
```

Verify that `api.pubkey.chat` resolves to the API Worker and `data.pubkey.chat` resolves to the R2 custom domain. Smoke-test publication through to a live inbox, then test duplicate Queue delivery, concurrent HEAD updates, stale CDN HEADs, expired parents, removed provider SSH keys, and malformed signatures. Be aware that R2 event notifications use **one queue for the bucket prefix**, not one per recipient.

### Production blockers / consciously unfinished features

1. **Abuse and cost containment:** enforce per-sender/per-IP rate limits, per-recipient submission limits, maximum payload sizes, and Cloudflare WAF rules. Signed identities alone do not stop spam or prevent a malicious user from filling arbitrary inboxes. Current code includes basic size checks, not robust quotas.
2. **Recovery from failed notifications:** configure alarms on Queue/DLQ depth and oldest age, and implement a reconciliation or redrive mechanism that can republish skeets already in R2 but missing from inbox indexes. A new R2 `PutObject` notification is *not* guaranteed by retrying a create-only write to an existing key.
3. **SSH interoperability:** test the TypeScript SSHSIG Ed25519 verification against `ssh-keygen -Y sign` on OpenBSD, macOS, Linux, and Git Bash, including agent-backed signing, malformed SSH wire strings, and GitHub/GitLab key formats. Expand SSH key algorithms only when tested.
4. **Encryption-key rotation, loss and multi-device operation:** v1 creates one long-lived RSA key, deliberately supports no updates, and provides no forward secrecy. Define a safe signed update sequence and old-message decryption behavior before widespread use.
5. **Release provenance:** sign and publish CLI release artifacts independently of the website, so a compromised `pubkey.chat` cannot silently replace the CLI with key-exfiltrating code.
6. **Deployment validation:** run `terraform validate`, Wrangler type checking, an R2/Queue integration test and Pages custom-domain checks against your actual Cloudflare account. Provider and dashboard capabilities evolve.
7. **HTTPS fallback audit:** validate the `nc -c` raw-HTTP transport and `ftp`'s 404 behavior on real stock OpenBSD. The simple fallback uses HTTP/1.0 with connection-close and is less feature-rich than curl; it must not silently turn TLS/network failures into permanent read progress.
8. **Operational limits:** R2 limits writes to the same object key to about one per second. Batching multiple queued skeets for one inbox into one HEAD update postpones that limit; one queue can later be sharded into a fixed small count of queues without changing the client protocol.
9. **CDN caching:** confirm JSON cache eligibility, edge TTLs, 404 caching policy, and HEAD staleness. Use R2 origin ETags from Workers for compare-and-swap, not potentially stale CDN ETags.

## 10. Implementation assumptions that require explicit acceptance

* No official archival record is maintained and 404 terminates client traversal. A gap caused by an operational bug may hide older still-retrievable skeets; this is tolerated by the best-effort delivery model.
* Recipient and sender names are **published as plaintext metadata**. Sending junk to distract observers is not guaranteed anonymity, and the service may still observe timing and network endpoints.
* The initial client accepts only an SSH key whose public Ed25519 key appears in the claimed provider `.keys` response. Provider key removal retroactively affects verification against current keys.
* Only recipients that published a signed encryption-key profile can receive skeets; in v1, that profile is immutable, not retained for 30 days.
* The initial response from `POST /v1/skeets` signifies durable storage, not eventual queue publication. For reliability, monitor and repair the delivery index.
* This specification favors low-cost Cloudflare primitives, not a hypothetical standalone file-exchange or archival protocol.

## 11. Official reference links (consulted 2026-11-05)

* [Cloudflare R2 Workers API and conditional writes](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/)
* [Cloudflare R2 event notifications](https://developers.cloudflare.com/r2/buckets/event-notifications/)
* [Cloudflare R2 lifecycle configuration](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/r2_bucket_lifecycle)
* [Cloudflare Queue limits](https://developers.cloudflare.com/queues/platform/limits/)
* [Cloudflare Queue batching, retries](https://developers.cloudflare.com/queues/configuration/batching-retries/)
* [Cloudflare R2 rate limits](https://developers.cloudflare.com/r2/platform/limits/)
* [Cloudflare R2 public buckets and custom-domain caching](https://developers.cloudflare.com/r2/buckets/public-buckets/)
* [Cloudflare Terraform provider](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs)
* [Cloudflare Pages Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/)
* [OpenSSH SSHSIG wire format](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig)
* [OpenSSH ssh-keygen(1)](https://man.openbsd.org/ssh-keygen)
* [OpenBSD OpenSSL/LibreSSL CMS command](https://man.openbsd.org/openssl)
* [OpenBSD nc(1)](https://man.openbsd.org/nc)

