Personal API (PAPI) — a well-known, self-describing person document: the wire-format spec plus an introspection/validation tool that checks domains for conformance against it.
A PAPI is one canonical JSON document a person publishes from an HTTP API subdomain to answer four machine-readable questions about themselves: how to encrypt to them, which keys may SSH as them, where their code lives, and what they publish. It is discoverable from a single well-known URI and cleanly separates freely-fetchable public information from data scoped to an authenticated caller — where the credential is the caller's own PIV key and the published encryption recipients are themselves the authentication identities (no pre-shared secret).
This repository is the canonical home of:
- the PAPI wire-format spec — RFC-0001, the normative interface contract (document schema, the visibility/ACL projection model, the HTTP endpoints, and the reflexive challenge/response auth handshake); and
papi— a Go CLI that fetches a domain's PAPI, reports what it publishes, and validates it against that contract.
It is not the reference server implementation. That lives in
friedenberg/linenisgreat
(api/protected/lib/), served live at
https://api.linenisgreat.com/.well-known/papi, and is documented there in
ADR-0004.
papi has these subcommands: validate checks a domain against the spec;
piggy-ids / ssh-keys / person / repos surface a domain's published
identity material, keys, and repositories for downstream consumption;
ssh-copy-id installs those keys onto a remote host, and ssh-sync keeps a
local managed authorized_keys file in sync with a domain on a timer (via a
home-manager service); bootstrap prints a domain's
cold-host self-bootstrap shim; query runs a jq expression
over the document; enroll emits a signed enrollment receipt for a new
YubiKey; verify-receipt checks that receipt against a domain's published
keys (FDR-0001); verified-recipients distils a batch of receipts into the
verified slot-9D encryption-recipient set (FDR-0002); sign-challenge answers a
§5 auth challenge by signing the server's nonce with your slot-9A key; gh-check
reconciles your GitHub SSH keys against a domain's published keys; and gh-auth
grants gh the scopes those GitHub commands need.
Fetch <domain>'s PAPI, report what it publishes, and check it against the
RFC-0001 conformance contract — discovery, the {data, meta} envelope and
meta.visibility, ACL-strip, projection, the text/plain endpoints, the auth
error codes, identity-ownership proofs (§9), the detached document signature
(§10), and the nix binary cache entry schema (§11). Output is an
ndjson-crap stream
(pipe it to crap-present to render); the process exits non-zero on any MUST
violation.
$ papi validate linenisgreat.com | crap-presentAccepts a bare domain (https assumed) or a full URL. By default it validates
only the public/anonymous tier. To also exercise the §5 challenge/response
handshake and the authenticated/scoped projection, supply a recipient identity
you control and a --decrypt-cmd that reads the challenge ebox (base64) on
stdin and writes the recovered nonce on stdout. base64 -d | pivy-box stream decrypt (talking to a running pivy/piggy-agent that holds the recipient's
slot-9D key) is exactly such a command — it base64-decodes the ebox and decrypts
it through the card:
$ papi validate linenisgreat.com \
--recipient piggy-recipient-v1@... \
--decrypt-cmd 'base64 -d | pivy-box stream decrypt' | crap-present| flag | meaning |
|---|---|
--recipient |
piggy recipient id to authenticate as; runs the §5 handshake + scoped-tier checks |
--decrypt-cmd |
shell command that reads the challenge ebox (base64) on stdin and writes the nonce on stdout |
Fetch <domain>'s GET /papi/piggy-ids and print it verbatim — the piggy-ids
file: comment lines, slot-9D encryption recipients, and slot-9A SSH auth ids.
With --recipients-only, emit just the bare slot-9D encryption recipients
(RFC-0001 §5.1), ready to feed as a recipient set to an encryptor:
$ papi piggy-ids --recipients-only linenisgreat.comFetch <domain>'s GET /papi/ssh-authorized-keys and print it verbatim — one
OpenSSH authorized_keys line per visible slot-9A key, each annotated with
guid=<HEX> and cn=<name>. With --guid <HEX>, print only the line whose
guid= annotation matches (case-insensitively), erroring if none does — the
affordance a bootstrapping client uses to pin its own card's signing key:
$ papi ssh-keys --guid DEADBEEF linenisgreat.comFetch all of <domain>'s published slot-9A keys and install them into
<destination>'s ~/.ssh/authorized_keys — like ssh-copy-id(1), but sourcing
the keys from PAPI instead of a local file. <destination> is anything ssh
accepts: a hostname, an IP, a user@host, or — most usefully — a Host alias
from your ~/.ssh/config. The install shells to ssh <destination>, so ssh
resolves the config (HostName, User, Port, IdentityFile, ProxyJump, …)
exactly as a normal ssh <destination> would; --port / --identity override
it. The append is idempotent (deduped by key material; ~/.ssh and the file are
created 0700/0600 if missing), so re-running keeps a host in sync as cards
are enrolled or rotated. With --guid <HEX>, install just one card's key. Only
lines that parse as real SSH keys are installed (a hostile domain cannot inject
text into the remote step).
The command presents via the crap-TUI (amarbel-llc/crap):
on a terminal it renders a live viewport of the operation (the fetch + install
phases and a per-key tally); piped or redirected it emits raw ndjson-crap, so
… | crap-present renders the same TUI and … > run.ndjson captures it.
$ papi ssh-copy-id prod --domain linenisgreat.com # 'prod' resolved from ~/.ssh/config
▸ ssh-copy-id prod
✓ fetch /papi/ssh-authorized-keys
✓ install via ssh
ssh-copy-id prod — 2 done, 1 skipped, 0 failedThe install runs a small sh script remotely by default. If the destination has
no usable shell (a forced-command, sftp-only, or nologin-shell target), papi
automatically retries over SFTP — fetching authorized_keys, merging the new
keys locally, and re-uploading it, with no remote shell. The crap stream shows
this as a skipped install via ssh (orange — a shell-less host is an
expected miss, not an error) followed by a passing install via sftp, and the
operation as a whole succeeds. (SSH can't advertise its subsystems, so attempting
is the only way to discover SFTP works.) Pass --sftp to force the SFTP path
directly and skip the
shell attempt. A connection/auth failure is not retried over SFTP (it would
fail identically); a host offering neither a shell nor SFTP (e.g. a strict
rsync-only target that confines paths away from ~/.ssh) can't be driven in-band
at all.
Note: ssh-copy-id currently targets pre-provisioned, non-interactive hosts — the live viewport owns the terminal, so a step that prompts for an SSH passphrase or YubiKey touch isn't supported yet (crap#31).
Fetch all of <domain>'s published slot-9A keys and (re)write them in full
into a LOCAL managed file — the counterpart to ssh-copy-id for this host.
Where ssh-copy-id appends to a remote authorized_keys and never prunes,
ssh-sync owns its target file: it is rewritten to exactly the domain's current
key set each run, so a rotated or revoked card is removed on the next sync. The
default target is $XDG_CONFIG_HOME/papi/ssh-sync/<domain>.keys (the host,
lowercased, non-[a-z0-9.-] bytes → _); override with --authorized-keys. The
write is atomic (0700 dir / 0600 file), and a deterministic header banner
marks the file machine-owned, so an unchanged upstream is reported unchanged.
--guid <HEX> syncs a single card's key; a domain publishing no keys prunes the
file to header-only rather than erroring.
$ papi ssh-sync linenisgreat.com
synced 2 key(s) to /home/me/.config/papi/ssh-sync/linenisgreat.com.keys (updated)Run it on a schedule with the services.papi-ssh-sync home-manager module
(homeManagerModules.papi-ssh-sync): a systemd user timer + oneshot service on
Linux, a launchd agent on Darwin. It works on NixOS, nix-darwin, and standalone
home-manager (Ubuntu).
services.papi-ssh-sync = {
enable = true;
domain = "linenisgreat.com"; # or `instances.<name> = { domain = …; }` for several
};For incoming logins to honor the synced keys, sshd must read the managed file.
On NixOS the companion nixosModules.papi-ssh-sync wires
services.openssh.authorizedKeysFiles automatically. On nix-darwin and
standalone home-manager add the line once yourself:
AuthorizedKeysFile .ssh/authorized_keys .config/papi/ssh-sync/linenisgreat.com.keys
(Or point authorizedKeysPath at ~/.ssh/authorized_keys2, which stock sshd
reads by default — zero config for a single domain.) See
FDR-0005 for the full design and trust
model.
Fetch <domain>'s GET /papi/bootstrap and print the self-bootstrap shim
verbatim — the small POSIX-sh script a cold, YubiKey-provisioned host runs to
clone eng over HTTPS and exec its provisioner. It is the inspect-before-you-run
companion to the cold-host entrypoint, which needs no papi binary at all
(FDR-0003):
$ curl -fsSL https://linenisgreat.com/papi/bootstrap | sh # cold host: only a card + network
$ papi bootstrap linenisgreat.com | less # or review it firstThe shim's contents are owned and version-controlled in eng (bin/provision.sh);
PAPI only hosts them — public and unprojected, since gating a bootstrap shim
behind §5 would be circular.
Fetch <domain>'s GET /papi and print its person block as JSON — handle,
display name, and contact email. Anonymously the ACL-gated person.contact is
stripped, so no email shows (RFC-0001 §2). Pass --recipient (and the same
--decrypt-cmd as validate) to run the §5 handshake and fetch the scoped
projection, revealing contact.email — the identity-bootstrap affordance a
downstream consumer sources name/email from:
$ papi person linenisgreat.com # anonymous: handle + display_name
$ papi person linenisgreat.com \
--recipient piggy-recipient-v1@... \
--decrypt-cmd 'base64 -d | pivy-box stream decrypt' # + contact.emailFetch <domain>'s GET /papi/repos — the flattened, provenance-annotated
repository list — and print it. By default emits the repos as JSON; --url
prints one repository url per line (a curl+jq replacement for consumers that
clone them); --owner filters to a single owner:
$ papi repos linenisgreat.com # JSON: name/url/owner/forge/…
$ papi repos linenisgreat.com --owner amarbel-llc --url
https://github.com/amarbel-llc/papi
https://github.com/amarbel-llc/eng
…Fetch <domain>'s GET /papi/profiles — the host profiles (flakerefs) a staged
installer activates (RFC-0001 §13) — and print them. By default emits the
profiles as JSON; --id selects a single profile (erroring if none matches);
--flakeref prints one flakeref per line. Host profiles are commonly §5-gated, so
an unauthenticated fetch shows only the anonymous-visible set:
$ papi profiles linenisgreat.com # JSON: id/flakeref/home_flakeref/…
$ papi profiles linenisgreat.com --id framework-laptop --flakeref
github:amarbel-llc/eng#nixosConfigurations.framework-laptopFetch <domain>'s GET /papi document and evaluate a jq expression against it —
an embedded gojq, so no external jq is
needed — printing each result as JSON, or unquoted strings under --raw/-r.
Lets consumers pluck arbitrary fields (forges[], organizations[], repos[],
person, …) without bespoke curl+jq:
$ papi query linenisgreat.com '.person.handle' -r
linenisgreat
$ papi query linenisgreat.com '.forges[].repos[].url' -rProvision a new YubiKey and emit a signed card-enrollment receipt
(papi-enroll-receipt-v1,
FDR-0001), attested by an
already-bootstrapped trusted card, for <domain>'s deploy side to publish.
By default it shows an interactive picker over the attached cards — blank
cards are selectable, the provisioned trusted card is shown but not enrollable —
then runs piggy card init on the chosen blank card, reads it back, and enrolls
it. The new card self-signs its 9D↔9A binding and the trusted card attests
(piggy sign-bytes --slot 9a); the receipt is written then verified against
<domain>. papi drives the papi-agnostic piggy primitives (piggy list,
age-plugin-piggy, piggy sign-bytes); all cards must be present (PCSC) and
provisioning prompts for the PIN on your terminal. On success papi also registers
the new card's slot-9A key on your GitHub account as both an authentication
and a signing key (via gh), so the card can immediately git@github.com and
sign commits — pass --no-gh-register to skip (e.g. enrolling for someone else).
--new-guid <G>— enroll an already-provisioned card (skip the picker + provisioning).--new-serial <N>— pick the blank card to provision non-interactively.--allow-reprovision— also offer provisioned cards in the picker; choosing one resets it (destroys its keys) and re-provisions from scratch, behind a loud extra confirm. Off by default — re-provisioning is destructive and never the silent default.--cn-prefix <name>— name the new card's slot certs (cn=…, surfaces inpiggy listand/papi/ssh-authorized-keys), e.g.laptop-alice. Default: piggy'spiv-auth@<guid8>. Interactive runs prompt for it.--no-gh-register— do NOT register the new card's slot-9A key on GitHub (auth + signing). Registration is on by default; skip it when enrolling a card for someone else's account.--trusted-guid <G>— the attester (default: the sole provisioned card).
Pair it with verify-receipt on the deploy side.
$ papi enroll linenisgreat.com --new-guid A1B2C3D4 --trusted-guid E5F6A7B8 --pin ******
wrote enroll-receipt-a1b2c3d4.json
self_proof: verified — new card's slot-9A key signs the 9D↔9A binding claim
attestation: verified — an already-published slot-9A key attests the receiptVerify a card-enrollment receipt (papi-enroll-receipt-v1, FDR-0001)
emitted when a new YubiKey is provisioned. Two checks, both required: the
self_proof binds the new card's slot-9D recipient to its slot-9A key (a §9.3
papi-proof-sig-v1 over the claim, verified against the receipt's own slot-9A
key), and the attestation is signed by a slot-9A key already published on
--domain's /papi/piggy-ids (a papi-enroll-att-v1 over the receipt's
canonical bytes) — an already-trusted card vouching for the new one. The two
checks are presented via the crap-TUI (live viewport on a terminal,
ndjson-crap when piped to crap-present); the command exits non-zero if either
fails — this is the verifier a deploy gate runs before publishing a new key.
$ papi verify-receipt enroll-receipt-55C3439D.json --domain linenisgreat.com
✓ self_proof — new card's slot-9A key signs the 9D↔9A binding claim
✓ attestation — an already-published slot-9A key attests the receiptA host without a Go binary (e.g. a php-wasm site verifying receipts natively) can
run the same two checks from the network-free WASM module — just build-wasm
builds cmd/papi-verify-wasm to a wasip1 artifact that takes the published keys
as input instead of fetching them (FDR-0002).
Verify a batch of enrollment receipts against --domain and print the slot-9D
recipient id (recipient.id) of every one that passes — the verified
encryption-recipient set, in the piggy-ids --recipients-only form. It is the
trust gate of the FDR-0002
composition: a card's recipient is emitted only when a trusted card has attested
its enrollment, so the set can drive a PIV-gated encrypt (linenisgreat's
.pivy-ids) instead of a hand-curated list. Failing receipts are reported on
stderr and excluded; --strict makes any failure exit non-zero with no output:
$ papi verified-recipients --domain linenisgreat.com enroll-receipt-*.json
piggy-recipient-v1@pivy_ecdh_p256_pub-q0p9kkux…
piggy-recipient-v1@pivy_ecdh_p256_pub-qfjr3sgs…
# enroll-receipt-bogus.json: excluded — attestation: …not published… (stderr)Answer a §5.2 sign-challenge
(the RECOMMENDED papi/v0 auth scheme): read the server's challenge JSON
({challenge_id, nonce, expires_at}, the POST /papi/auth/challenge response) on
stdin, build the domain-separated preimage papi-auth-v1\n<domain>\n<nonce>,
sign SHA-256(preimage) with your PIV slot-9A key (ECDSA P-256, via piggy sign-bytes --slot 9a — the card must be present; no agent), and print the POST /papi/auth/response body {challenge_id, signature} on stdout, where
signature is a papi-auth-sig-v1@ecdsa_p256_sig markl id (raw 64-byte r‖s).
--domain is the PAPI identity domain the signature binds to — it is never
echoed by the challenge (cross-site relay defense), so you supply it. With no
--guid the sole provisioned card is used; --pin passes the slot-9A PIN. The
command does no network I/O itself — the caller POSTs the body and the server
verifies it against the registered slot-9A key, minting a session.
$ echo '{"challenge_id":"a1b2…","nonce":"3f9c…","expires_at":1750000000}' \
| papi sign-challenge --domain staging.example.com --pin ******
{"challenge_id":"a1b2…","signature":"papi-auth-sig-v1@ecdsa_p256_sig-qqqsyq…"}Cross-check <domain>'s published slot-9A keys — the domain is the source of
truth — against the SSH keys on your authenticated GitHub account (both
authentication and signing, via gh api), matching by key material. Every
domain-published key must be registered on GitHub; a published card missing
from GitHub is a failure (a gap). Extra keys on GitHub (not from the domain)
are fine and never fail — --show-orphans lists them as informational notes.
Presented via the crap-TUI; exits non-zero only on a gap.
GitHub gates the two key kinds behind separate scopes — auth keys need
admin:public_key, signing keys need admin:ssh_signing_key (or the read:
variants). A missing scope skips that kind (surfacing gh's
gh auth refresh -s … hint) rather than failing the whole check; grant both with
papi gh-auth.
$ papi gh-check linenisgreat.com
✓ domain key guid=55C3439D… is registered on GitHub
✗ domain key guid=2835305C… is registered on GitHub
reason: gap — published on the domain but NOT on GitHub
↷ GitHub signing keys listed # SKIP gh api …: needs admin:ssh_signing_keyLaunch gh auth refresh to add the OAuth scopes papi's GitHub integration uses —
admin:public_key and admin:ssh_signing_key — to your existing gh login. The
one-liner for the missing-scope case above; interactive (gh runs its
browser/device flow). --hostname overrides the default github.com.
$ papi gh-authThe CLI is distributed as a Nix flake package — there is no non-Nix install path. Run it ad-hoc, install it onto your profile, or pin a tagged release:
$ nix run github:amarbel-llc/papi -- validate linenisgreat.com # ad-hoc
$ nix profile install github:amarbel-llc/papi#papi # onto PATH
$ nix build github:amarbel-llc/papi#papi # ./result/bin/papiFor a reproducible consumer, pin a released tag rather than the floating default branch:
$ nix run github:amarbel-llc/papi/v0.2.0 -- validate linenisgreat.com# flake.nix
inputs.papi.url = "github:amarbel-llc/papi/v0.2.0";papi version reports the burned-in papi <version>+<commit> (the version
comes from version.env, injected by igloo's buildGoApplication).
Releases are cut with just release <new> from master (eng-versioning(7)):
it generates the changelog, bumps version.env, and creates a signed v<sem>
tag plus a GitHub release.
A just recipe drives every dev loop; just with no argument runs the local CI
lane (lint + build + test), which is also the pre-merge gate.
$ just # lint build test (the CI lane)
$ just build-go # fast out-of-nix build to ./build/papi
$ just test-go # hermetic Go test suite (httptest fixtures; no network)
$ nix develop # devShell with go, just, gomod2nix, conformistRun just --list for the full recipe set. Dependency changes go through
just update-go (go mod tidy + regenerate gomod2nix.toml).
docs/rfcs/ the PAPI wire-format spec (RFC-0001)
internal/0/papi/ HTTP client + wire-format decoders + enrollment receipt
internal/0/markl/ markl-id (blech32) parser (RFC-0002)
internal/alfa/inspect/ the validate command + receipt verification core
internal/alfa/enroll/ the enroll command: card provisioning + receipt assembly
internal/alfa/signchallenge/ the sign-challenge command: §5.2 preimage + slot-9A response
cmd/papi-verify-wasm/ network-free receipt verifier, built to wasip1 (FDR-0002)
cmd/papi-client-wasm/ network-free RFC-0001 decode/verify core, built to js/wasm (FDR-0007)
cmd/papi-installer/ staged host installer: RFC-0003 phase engine + crap TUI (FDR-0006)
clients/ts/ TypeScript client wrapper over the js/wasm core (FDR-0007)
internal/0/installer/ the RFC-0003 phase engine the installer drives (FDR-0006)
nix/hm, nix/nixos/ the papi-ssh-sync home-manager + NixOS modules (FDR-0005)
main.go cobra CLI (validate, piggy-ids, ssh-keys, ssh-copy-id, ssh-sync, bootstrap, gh-check, gh-auth, person, enroll, verify-receipt, verified-recipients, sign-challenge)
Packages under internal/ are tiered by dependency depth — NATO-phonetic
levels where 0 is a leaf (no internal deps), alfa depends only on level
0, and so on — repositioned with dagnabit
(nix run github:amarbel-llc/purse-first#dagnabit -- --initial internal).