A tiny, zero-dependency verifier for VRP (Vacation Rental Protocol) signed verified-stay offers.
It answers one question for an AI agent or client: may I quote this offer as an official, direct offer from the host? It verifies an Ed25519 (EdDSA) signature over the offer against the host domain's own JWKS — the host is the root of trust — and then evaluates the offline-checkable Safe-to-Quote conditions of the VRP specification (§7, with §5, §5.1 and §6): the offer's lifetime (a positive window of at most 900 seconds, not started in the future, not yet passed), the dates of the stay, that the signed stay is the stay you asked for, and that the offer comes from a host-owned domain (§2): the host's own registrable domain, never a name under a platform's, marketplace's or site builder's domain. Every value is read from the signed payload; nothing is taken from an unsigned part of the envelope. There is no central registry, and this package never contacts one.
- Zero runtime dependencies. Node standard library (
node:crypto) only. - No network, no canonicalization. You supply the JWKS, the domain you
fetched the offer from, the stay you asked for, and two public lists (the
Public Suffix List and ICANN's gTLD list); the signature is checked over the
exact bytes as sent. The evaluation instant is injectable (
now) and defaults to the system clock; the crypto layer itself never consults a clock. - Fail-closed. Only a full pass is quoteable.
- Conformance-tested against the public VRP offer vectors.
npm install @vacationrentalprotocol/vrp-verifyRequires Node.js >= 18.
import {
verifyOffer,
quoteChecks,
icannRulesFromPsl,
specification13FromGtlds,
PSL_URL,
GTLDS_URL,
} from "@vacationrentalprotocol/vrp-verify";
// The two public lists VRP §2 reads. Fetch them yourself and cache them
// (both change rarely); this package never fetches anything.
const hostOwnedLists = {
icannRules: icannRulesFromPsl(await (await fetch(PSL_URL)).text()),
specification13Tlds: specification13FromGtlds(await (await fetch(GTLDS_URL)).json()),
};
// `domain` is the domain you are verifying: the host of the URL you requested,
// e.g. https://<domain>/.well-known/vacation-rental.json, before any redirect.
// `envelope` is a signed_verified_stay_offer (VRP spec §5).
// `jwks` is the JSON at https://<domain>/.well-known/jwks.json
// `request` is the stay you asked the offer endpoint for (§4): the same
// check_in, check_out and guests you sent, as you sent them.
const request = { check_in: "2027-03-08", check_out: "2027-03-12", guests: 2 };
const result = verifyOffer({ envelope, jwks, now: new Date() });
const { ok, failures } = quoteChecks(result, { domain, hostOwnedLists, request });
if (ok) {
// Signature valid; generated_at and valid_until well-formed, a lifetime of
// at most 900 seconds, not started in the future, still fresh; the signed
// stay is the stay you asked for, on dates that exist and have not passed;
// availability true, price exact, booking URL pinned to the host domain,
// agent permission granted, and the offer names the host-owned domain it
// was fetched from. Quote result.payload as an official direct offer.
} else {
console.warn(result.status, result.code, failures); // do NOT quote
}safeToQuote(result, { domain, hostOwnedLists, request }) returns the same
verdict as a boolean. Without domain, hostOwnedLists and request the
gate is closed (domain_unknown, host_owned_unknown, asked_stay_unknown):
an offer whose domain is not checked, or whose stay cannot be compared with the
stay asked for, is unknown (§2, §7, §9), never safe to quote.
Up to 0.2.0, verifyOffer() read valid_until only, with a lenient date
parser, and quoteChecks() compared nothing about the stay. An offer with a
valid_until ten years out, or without generated_at, or signed for other
dates or another guest count than the ones you asked for, could pass as
verified and as safe to quote. From 0.3.0, verifyOffer() enforces spec §5
and §6 on the signed payload — generated_at and valid_until are UTC
date-times ending in Z that exist, valid_until is later than generated_at
and at most 900 seconds after it, generated_at is at most 60 seconds ahead of
now, and valid_until has not passed — and quoteChecks() enforces §5 and
§7 on request: the dates exist, check_out is later than check_in,
check_in is not earlier than the day before the UTC date, language (when
present) is a language tag, and check_in, check_out and guests equal the
stay you pass as context.request. quoteChecks() judges the lifetime again
at the quoting instant — context.now when you pass one, else the current
time — so an offer verified fresh and quoted after its valid_until fails with
expired. A now that cannot be read (NaN, an Invalid Date, an unparseable
string) fails with clock_unusable in verifyOffer(), quoteChecks() and
checkOfferRules().
Migration. A caller that upgrades without passing context.request gets
false from safeToQuote() for every offer, valid ones included
(asked_stay_unknown): the gate no longer has a way to know whether the signed
stay is the one asked for. Pass { domain, hostOwnedLists, request }; the
reason codes are those of the VRP offer-rules conformance vectors.
Up to 0.1.2, safeToQuote(result) and quoteChecks(result) did not check the
domain: as this README said, whether the offer came from a host-owned domain
was the caller's responsibility. A caller that skipped that check could quote
an offer signed for a name under another party's domain. From 0.2.0 the gate
checks it, and fails closed when the caller does not pass the domain and the
lists. Update your calls to pass { domain, hostOwnedLists }.
VRP spec §7 (Safe-to-Quote Rules) lists sixteen conditions. This package checks the thirteen that are decidable offline from the signed offer, the JWKS, the domain, the stay you asked for and the clock you pass in, and the host-owned domain (§2) from the domain and the lists you pass in:
| # | §7 condition | where |
|---|---|---|
| 1 | JWKS contains an Ed25519 key matching the JWS | verifyOffer |
| 2 | the compact JWS verifies against the host-domain JWKS | verifyOffer |
| 3 | the signed payload matches the returned offer | verifyOffer |
| 4 | valid_until is present and fresh |
verifyOffer |
| 4a | generated_at and valid_until meet §5 and §6: UTC date-times ending in Z that exist, valid_until later than generated_at and at most 900 seconds after it, generated_at at most 60 seconds ahead of now |
verifyOffer, re-checked by quoteChecks at the quoting instant |
| 4b | request.check_in and request.check_out exist, check_out is later than check_in, check_in is not earlier than the day before the UTC date (§5, §7); request.language, when present, has the form §5 gives |
quoteChecks |
| 4c | request.check_in, request.check_out and request.guests are the stay you asked for (§7) |
quoteChecks, from request |
| 5 | availability.available is true |
quoteChecks |
| 6 | price.exact is true |
quoteChecks |
| 7 | a direct booking URL is present — and, per §5.1, is a node URL (§2): https, host the offer's canonical_domain or www. followed by it, no userinfo, the default port |
quoteChecks |
| 8 | agent_permission.may_quote_as_official_direct_offer is true |
quoteChecks |
| 9 | the signed canonical_domain is the domain the offer was fetched from (§7) |
quoteChecks, from domain |
| 10 | that domain is host-owned (§2): lowercase A-labels, its own registrable domain under the ICANN section of the Public Suffix List (the PRIVATE section does not count), not under a brand or closed top-level domain (Specification 13, booking, hotels); no usable lists = unknown |
quoteChecks, from hostOwnedLists |
What remains the caller's responsibility needs network context this package deliberately does not have:
- fetching over HTTPS and following a redirect only to a node URL of the domain (§2),
- the discovery document itself: its
canonical_domainis the domain it was fetched from, and it declaresprotocol: "vacation-rental-protocol"andprotocol_version: "0.1".
Likewise, fetching the JWKS itself (over HTTPS, from the host's own domain, with your own caching and rotation policy) is yours: this package verifies against whatever JWKS document you hand it.
verifyOffer() returns:
| field | meaning |
|---|---|
status |
verified | expired | invalid | unverifiable |
code |
machine reason code; null for verified and expired (both mean the signature checked out) |
reason |
short human-readable explanation |
fresh |
valid_until > now; null whenever the verdict is not verified/expired |
header |
the JWS protected header, when it could be parsed |
payload |
the signed offer payload, when available |
payloadMatchesOffer |
whether envelope.offer equals the signed payload; null when there is no plaintext offer to compare |
evaluatedAt |
the instant the lifetime was judged at (ISO date-time); null when the verdict was reached before the clock mattered |
reasons |
every lifetime rule (§5, §6) the signed payload breaks at that instant, in the order of the VRP offer-rules vectors; [] when none |
The four statuses:
- verified — signature valid, the lifetime meets §5 and §6, and the offer is still fresh.
- expired — signature valid and lifetime well-formed, but
valid_untilhas passed. - invalid — the presented artifact fails verification: a signature is
present and does not verify, the envelope is tampered/malformed, or the
signed lifetime breaks a rule a node MUST keep (
lifetime_not_positive,lifetime_too_long,generated_at_in_future). This is a statement about the envelope — per spec §9 a failed check leaves every value inside Unknown, so nothing in it may be cited as true or false. - unverifiable — a verdict could not be reached: the signing key is absent
from the JWKS or unusable, or
generated_at/valid_untilis missing or not a UTC date-time ending inZso the lifetime cannot be determined (generated_at_unusable,valid_until_unusable), ornowitself is not a usable date (clock_unusable). This is never reported asinvalid, because absence of a key is not proof of forgery.
status: "verified" alone is not permission to quote — it is the crypto
verdict. safeToQuote() / quoteChecks() layer the §7/§5.1 policy conditions on
top; quoteChecks() names each failed condition
(availability_not_true, price_not_exact, booking_url_missing,
booking_url_invalid, booking_url_host_mismatch,
quote_permission_not_granted, domain_unknown, host_owned_unknown,
offer_domain_mismatch, malformed_domain, public_suffix, not_host_owned,
closed_tld, request_missing, invalid_check_in, invalid_check_out,
check_out_not_after_check_in, check_in_in_past, invalid_language,
asked_stay_unknown, request_mismatch, a lifetime code such as expired
when quoting later than verifying, clock_unusable, or not_verified). The host-domain codes are the ones the VRP
host-domain conformance vectors use, the offer-rules codes are the ones the VRP
offer-rules conformance vectors use, and checkHostOwned(domain, lists) and
checkOfferRules(payload, { now, asked }) are exported for checking a domain,
or a payload's lifetime and stay, on their own.
import { verifyCompactJws } from "@vacationrentalprotocol/vrp-verify";
const { valid, reason, header, payload } = verifyCompactJws(compactJws, jwks);verifyCompactJws() performs the pure JWS check only (no freshness, no
policy). Its reason codes match the published VRP conformance vectors.
- The host domain's JWKS is the trust root. Fetch it over HTTPS from the host's own domain and pass it in. This package does not fetch it for you, so transport, caching, and rotation policy stay under your control.
- The algorithm is pinned to EdDSA. A JWS declaring any other
algis rejected as a downgrade attempt. - The signature is verified over the exact received bytes — no canonicalization step to disagree on.
- A valid signature over a payload that disagrees with a plaintext
envelope.offeris treated as tampering (invalid). - Only the signed payload speaks.
valid_until,generated_at,availability,price,booking,agent_permissionandrequestare read from the verified payload and nowhere else; a member missing there is missing, whatever an unsigned part of the envelope says. - The lifetime is bounded (§5, §6): a window longer than 900 seconds, one
that starts more than 60 seconds in the future, one that runs backwards, or
one whose instants are not UTC date-times ending in
Zis neververified, however valid the signature. - The stay is the stay you asked for (§7): a genuine, fresh, correctly signed offer for other dates or another guest count fails the gate, and so does a gate call that does not say what was asked for.
- The booking URL is a node URL (§5.1, §2): a signed offer whose
direct_booking_urlis not on the offer'scanonical_domainorwww.followed by it fails the gate, even though its signature is valid. Any other name under the domain (airbnb.example) fails too: it may be served by another party. So do userinfo and a port other than thehttpsdefault. - The node is a host-owned domain (§2): a name a platform, marketplace,
site builder or operator hands out under its own domain (
villa.wixsite.com,villa.example-platform.com) or a name under a brand or closed top-level domain (villa.booking) is never a VRP node, whoever signs for it. Without the domain and both lists the gate is closed.
Report vulnerabilities per the VRP security policy (see the vrp-spec repository). Do not open public issues for security reports.
The package's test suite (in the source repository; tests are not shipped in
the npm tarball) runs this verifier against byte-identical copies of the VRP
signed-offer conformance vectors and asserts that its outcome matches each
vector's declared expected block on the three fields the vectors define
(verified / reason / fresh) — the same three-field contract the vrp-spec
reference runner checks. It also runs the gate against a byte-identical copy of
the VRP host-domain vectors (host_owned, real_world, the offer-binding cases
for the signed offer, and the booking-URL node URLs), and checkOfferRules,
verifyOffer and quoteChecks against a byte-identical copy of the VRP
offer-rules vectors (the lifetime, the clock, the dates, the language tag and
the stay asked for), each re-signed with the conformance test key so the rules,
not the signature, decide. The typed statuses and the quote gate are additional
API surface on top of that contract, covered by their own tests.
VRP is an open standard. If you are building an independent verifier, the
self-describing conformance vectors and the "get listed" process are documented
in the vrp-spec repository's IMPLEMENTATIONS.md.
Apache-2.0. See LICENSE and NOTICE. Built entirely from the public VRP conformance surface; contains no vendor-specific code.