OpenYacht

Request-Signing Test Vectors

Status: Draft v0.1 (2026-08) — companion to federation-protocol.md §Request Signing. An implementation whose signing code reproduces these signatures byte-for-byte, and whose verification code accepts them, interoperates with every other conforming implementation.

⚠ The keypair below is published and therefore compromised by definition. It exists only for tests. Never load it into a running node.

Test keypair

ItemValue
Private key (raw 32-byte seed, hex)4f70656e59616368742d746573742d766563746f722d736565642d3030303031
— same seed as ASCII (32 bytes)OpenYacht-test-vector-seed-00001
Public key (base64, raw 32 bytes)QKcwbi+S0spqvUIba9P45r2SDvKqbXmjCb6zsTn51Ac=
Key ID (first 16 hex chars of SHA-256 of raw public key)25f0c5c537a07c58

Deriving the key ID is itself a useful first test: sha256(raw_public_key_bytes) → hex → first 16 characters.

Signing-string construction (normative reference)

Per federation-protocol.md: the UTF-8 concatenation, joined by single \n (0x0A) characters — no trailing newline — of:

  1. uppercase HTTP method
  2. request path including query string
  3. lowercase host of the receiving node
  4. the X-OpenYacht-Timestamp value
  5. lowercase hex SHA-256 of the raw request body (the hash of the empty string for bodyless requests)

The Ed25519 signature is computed over the UTF-8 bytes of that string and sent base64-encoded in X-OpenYacht-Signature.

Vector 1 — bodyless GET

Request:

GET /openyacht/v1/listings?updated_since=2026-08-01T00:00:00Z&page_size=50 HTTP/1.1
Host: receiver.example
X-OpenYacht-Node: sender.example
X-OpenYacht-Key: 25f0c5c537a07c58
X-OpenYacht-Timestamp: 2026-08-21T09:00:00Z

Body SHA-256 (empty body — this is the well-known SHA-256 of the empty string):

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Signing string (\n rendered visibly; there are exactly four newlines):

GET\n/openyacht/v1/listings?updated_since=2026-08-01T00:00:00Z&page_size=50\nreceiver.example\n2026-08-21T09:00:00Z\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Expected X-OpenYacht-Signature:

0ZS5EQbB26H01ovHjBIJeYIp2hpK1rmB11zNr89HOKmbWsrTaAbfLXGrJ8kzigOBn8+3Z9ADf0g46/K9HInYAw==

Vector 2 — POST with JSON body

Request:

POST /openyacht/v1/partners/request HTTP/1.1
Host: receiver.example
Content-Type: application/json
X-OpenYacht-Node: sender.example
X-OpenYacht-Key: 25f0c5c537a07c58
X-OpenYacht-Timestamp: 2026-08-21T09:05:00Z

Body — the raw bytes hashed are exactly this 94-byte string, no trailing newline:

{"message":"Requesting partnership for co-brokerage.","contact_email":"[email protected]"}

Body SHA-256:

6702c6af06cc1732a1605c524713b79f39c2999595ddeb562c355824f85288ee

Signing string:

POST\n/openyacht/v1/partners/request\nreceiver.example\n2026-08-21T09:05:00Z\n6702c6af06cc1732a1605c524713b79f39c2999595ddeb562c355824f85288ee

Expected X-OpenYacht-Signature:

GdI9tqtzMIm3fzSArP8DHu1P2iKbcyOHQ9rST27sbeXXD7w9vPmeXBXmShjTwAxuJYrtuokrY7VGNvTzdGY8AA==

Negative tests every verifier should pass

Using vector 1 as the base, verification MUST fail when:

  1. Any single byte of the signing string changes (e.g. page_size=51) — signature invalid.
  2. The timestamp in the header differs from the one in the signing string — signature invalid.
  3. The timestamp is valid and signed but outside ±300 s of the receiver’s clock — reject with TIMESTAMP_OUT_OF_RANGE without a signature check being the deciding factor.
  4. The body is altered after signing (POST vector with an extra space in the JSON) — body-hash mismatch → signature invalid.
  5. X-OpenYacht-Key names a key ID the sender’s well-known document does not list — after the one permitted fresh refetch, reject with SIGNATURE_INVALID.

Reproducing these vectors

Python (cryptography package):

import base64, hashlib
from cryptography.hazmat.primitives.asymmetric import ed25519

priv = ed25519.Ed25519PrivateKey.from_private_bytes(b"OpenYacht-test-vector-seed-00001")
signing_string = "\n".join([
    "GET",
    "/openyacht/v1/listings?updated_since=2026-08-01T00:00:00Z&page_size=50",
    "receiver.example",
    "2026-08-21T09:00:00Z",
    hashlib.sha256(b"").hexdigest(),
])
print(base64.b64encode(priv.sign(signing_string.encode())).decode())

PHP (sodium, as the Laravel reference implementation would use):

$seed = 'OpenYacht-test-vector-seed-00001';
$keypair = sodium_crypto_sign_seed_keypair($seed);
$secret  = sodium_crypto_sign_secretkey($keypair);
$signingString = implode("\n", [
    'GET',
    '/openyacht/v1/listings?updated_since=2026-08-01T00:00:00Z&page_size=50',
    'receiver.example',
    '2026-08-21T09:00:00Z',
    hash('sha256', ''),
]);
echo base64_encode(sodium_crypto_sign_detached($signingString, $secret));