Match SPEC 8.11 and 14.5 where the brief left the wire open
The spec sections landed on privacy/sweep-2026-10 (flamenet-e2e 5455ab1)
while wave 0b was being written. Where the wave brief did not fix a detail,
the relay now follows the spec:
- handle certificates encode as {v, aud, uid, h, exp, sig}; the verifier
refuses `|` in any field and can check audience and account. A copy of
test/vectors/handle-cert-v1.json (sha256 d73de0c7...) runs in
HandleCertificateVectorTests: 4 valid verify, 10 invalid do not.
- POST /e2e/intro also answers with `intro` (the first of its tokens) and
`exp` in unix seconds; POST /e2e/messages takes `intro` as well as
`delivery_token` for the same capability.
- anonymous presence takes at most 8 tokens per request (DD3's batch).
- the device directory carries caps_sig.
Where the brief and the spec disagree (route names, contact-link API,
one-time links, proof of work on intro by handle, one token or three), the
brief is kept and the difference is reported, not resolved here.
Falsified: each of the five changes reverted alone turns its test red.
Full suite: 848 swift-testing tests in 94 suites and 61 XCTest pass, with
the Postgres suite.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
114fadbcbf
commit
b4bf14587b
+17
-12
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/API.md.
|
||||
Edits made HERE are deleted the next time that script runs.
|
||||
Change the markdown source and re-run it.
|
||||
source-sha256: bfd847e0ce8e8778 -->
|
||||
source-sha256: d71d19e64e2fe62b -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="relay-api">Relay API</h1>
|
||||
@@ -234,9 +234,10 @@ them only until the apps have moved here</td>
|
||||
<tr>
|
||||
<td><code>POST /e2e/messages</code></td>
|
||||
<td>Deposit envelopes. A sealed <code>delivery_token</code> may be one
|
||||
the recipient issued or one from <code>POST /e2e/intro</code>. With
|
||||
<code>FN_PAD_ENFORCE=enforce</code>, a request carrying any payload not
|
||||
exactly 16384 or 65536 characters long is refused with
|
||||
the recipient issued or one from <code>POST /e2e/intro</code>;
|
||||
<code>intro</code>, §14.5's name for the field, is taken the same way.
|
||||
With <code>FN_PAD_ENFORCE=enforce</code>, a request carrying any payload
|
||||
not exactly 16384 or 65536 characters long is refused with
|
||||
<code>unpadded</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -393,9 +394,12 @@ address.</p>
|
||||
<td><code>POST /e2e/intro</code></td>
|
||||
<td><code>{"handle": h}</code> (listed accounts only) or
|
||||
<code>{"link": secret}</code> (base64 or base64url, 32 bytes). Returns
|
||||
<code>user_id</code>, <code>fnid</code>, three <code>intro_tokens</code>
|
||||
good for one sealed send each for 10 minutes (<code>expires_in</code>),
|
||||
the target's bundles (<code>devices</code>, one-time prekeys popped) and
|
||||
<code>user_id</code>, <code>fnid</code>, <code>intro</code> and
|
||||
<code>exp</code> (§14.5's shape: one token and its expiry in unix
|
||||
seconds), and <code>intro_tokens</code>, three tokens of which
|
||||
<code>intro</code> is the first, each good for one sealed send within 10
|
||||
minutes (<code>expires_in</code>), the target's bundles
|
||||
(<code>devices</code>, one-time prekeys popped) and
|
||||
<code>handle_cert</code>. An unlisted or unknown handle, an unknown or
|
||||
revoked link and an account with no devices all get the same
|
||||
<code>404 not_found</code> "No such account.", no sooner than 5 ms.
|
||||
@@ -406,14 +410,15 @@ account (<code>FN_INTRO_RATE_TARGET</code>) and per network and account
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/anon/presence</code></td>
|
||||
<td><code>{"tokens": [...]}</code>, at most 64. The same answer as
|
||||
<td><code>{"tokens": [...]}</code>, at most 8. The same answer as
|
||||
<code>POST /messenger/presence</code>. Metered per address, by token
|
||||
count</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/anon/devices</code></td>
|
||||
<td><code>{"user_id": n, "cap": token?}</code>. Identity keys only, no
|
||||
<code>created_at</code> or <code>last_seen</code></td>
|
||||
<td><code>{"user_id": n, "cap": token?}</code>. Identity keys and
|
||||
<code>caps_sig</code> only, no <code>created_at</code> or
|
||||
<code>last_seen</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/anon/bundles</code></td>
|
||||
@@ -430,10 +435,10 @@ per caller). A <code>cap</code> that does not match is ignored, not
|
||||
refused, so the route says nothing about whether a token is good.</p>
|
||||
<p><strong>Handle certificates</strong> state that <code>user_id</code>
|
||||
holds <code>handle</code> on this relay until <code>exp</code>:</p>
|
||||
<pre class="json"><code>{ "v": 1, "audience": "relay.example", "user_id": 7, "handle": "ada",
|
||||
<pre class="json"><code>{ "v": 1, "aud": "relay.example", "uid": 7, "h": "ada",
|
||||
"exp": 1760000000, "sig": "<base64 Ed25519>" }</code></pre>
|
||||
<p><code>sig</code> is over the bytes
|
||||
<code>fnhandle-v1|<audience>|<user_id>|<handle>|<exp></code>,
|
||||
<code>fnhandle-v1|<aud>|<uid>|<h>|<exp></code>,
|
||||
made with the key that signs the log head, whose public half is the
|
||||
descriptor's <code>issuerPublicKey</code> that both apps pin on first
|
||||
sight. Handles are lowercase letters and digits, so <code>|</code>
|
||||
|
||||
@@ -217,7 +217,16 @@ enum Discoverability {
|
||||
///
|
||||
/// Handles are lowercase letters and digits only, so the separator cannot
|
||||
/// appear inside one.
|
||||
///
|
||||
/// On the wire as §14.5 has it: `{v, aud, uid, h, exp, sig}`.
|
||||
struct HandleCertificate: Content, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case v, exp, sig
|
||||
case audience = "aud"
|
||||
case user_id = "uid"
|
||||
case handle = "h"
|
||||
}
|
||||
|
||||
let v: Int
|
||||
let audience: String
|
||||
let user_id: Int
|
||||
@@ -251,9 +260,16 @@ struct HandleCertificate: Content, Equatable {
|
||||
sig: signature.base64EncodedString())
|
||||
}
|
||||
|
||||
/// What a client does with one: check it against the pinned key.
|
||||
func verifies(with publicKeyRaw: Data, now: Int) -> Bool {
|
||||
/// What a client does with one: check it against the pinned key, and,
|
||||
/// when given, that it is for this relay and this account (§14.5).
|
||||
func verifies(
|
||||
with publicKeyRaw: Data, now: Int, audience expected: String? = nil,
|
||||
userID expectedUser: Int? = nil
|
||||
) -> Bool {
|
||||
guard v == 1, exp > now,
|
||||
!audience.contains("|"), !handle.contains("|"),
|
||||
expected.map({ $0 == audience }) ?? true,
|
||||
expectedUser.map({ $0 == user_id }) ?? true,
|
||||
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyRaw),
|
||||
let signature = Data(base64Encoded: sig)
|
||||
else { return false }
|
||||
|
||||
@@ -452,7 +452,7 @@ struct Routes: Sendable {
|
||||
d.id.map {
|
||||
.init(
|
||||
device_id: $0, ik_dh: d.ikDH, ik_sig: d.ikSig,
|
||||
proto: d.proto,
|
||||
proto: d.proto, caps_sig: d.capsSig,
|
||||
// Absent, not null: the encoder leaves a nil out.
|
||||
created_at: includeTimes ? d.createdAt.map(iso.string(from:)) : nil,
|
||||
last_seen: includeTimes ? d.lastSeen.map(iso.string(from:)) : nil)
|
||||
@@ -567,10 +567,11 @@ struct Routes: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// §21.4 first, §14.4 second.
|
||||
let token =
|
||||
input.delivery_token?.isEmpty == false
|
||||
? Data(base64Encoded: input.delivery_token!) : nil
|
||||
// §21.4 first, §14.4 second. `intro` (§14.5) is the spec's field for
|
||||
// an introduction's token and is the same capability path.
|
||||
let capability = [input.delivery_token, input.intro]
|
||||
.compactMap { $0 }.first { !$0.isEmpty }
|
||||
let token = capability.flatMap { Data(base64Encoded: $0) }
|
||||
let sealed = token != nil || input.delivery_key?.isEmpty == false
|
||||
var fromUser = 0
|
||||
var fromDevice = ""
|
||||
|
||||
@@ -19,9 +19,9 @@ import Vapor
|
||||
/// The authenticated routes stay until the fleet has moved (DD4).
|
||||
extension Routes {
|
||||
|
||||
/// Tokens per anonymous presence request. Clients ask in batches of at
|
||||
/// most eight (DD3); this is a ceiling, not a target.
|
||||
static let maxAnonymousPresenceTokens = 64
|
||||
/// Tokens per anonymous presence request: eight, the batch size §8.11
|
||||
/// gives clients (DD3), so one request cannot be a whole contact list.
|
||||
static let maxAnonymousPresenceTokens = 8
|
||||
/// Presence tokens per address per hour.
|
||||
static let anonymousPresencePerHour = 20_000
|
||||
/// Directory reads per address per hour without a presence token, and
|
||||
|
||||
@@ -145,6 +145,8 @@ extension Routes {
|
||||
IntroResponse(
|
||||
user_id: targetID,
|
||||
fnid: target.fnid,
|
||||
intro: tokens[0].base64EncodedString(),
|
||||
exp: expires,
|
||||
intro_tokens: tokens.map { $0.base64EncodedString() },
|
||||
expires_in: Self.introLifetime,
|
||||
handle_cert: HandleCertificate.issue(
|
||||
@@ -254,6 +256,11 @@ struct IntroRequest: Decodable {
|
||||
struct IntroResponse: Content {
|
||||
let user_id: Int
|
||||
let fnid: String?
|
||||
/// §14.5's shape: one token and its expiry in unix seconds. The first of
|
||||
/// `intro_tokens`, so a client written to the spec spends that one and
|
||||
/// the rest expire unused.
|
||||
let intro: String
|
||||
let exp: Int
|
||||
/// Base64 32-byte tokens, each good for one sealed submission to
|
||||
/// `user_id`, presented as `delivery_token`.
|
||||
let intro_tokens: [String]
|
||||
|
||||
@@ -258,6 +258,10 @@ struct SendRequest: Decodable {
|
||||
let delivery_key: String?
|
||||
/// §21.4, a single-use capability, base64 32 bytes, spent on acceptance.
|
||||
let delivery_token: String?
|
||||
/// §14.5, an introduction from `POST /e2e/intro`. The spec's name for the
|
||||
/// same thing; spent exactly as `delivery_token` is, and either field may
|
||||
/// carry an intro token.
|
||||
var intro: String? = nil
|
||||
/// Time-critical: deliver with a VoIP push where the platform has one
|
||||
/// (§6.14).
|
||||
let urgent: Bool?
|
||||
@@ -327,6 +331,9 @@ struct DeviceListResponse: Content {
|
||||
let ik_dh: String
|
||||
let ik_sig: String
|
||||
let proto: Int
|
||||
/// The device's signed capability block, when it published one. Part
|
||||
/// of its identity, as in a bundle (§8.11 lists it in the directory).
|
||||
var caps_sig: String? = nil
|
||||
/// ISO-8601.
|
||||
let created_at: String?
|
||||
/// ISO-8601, hour-granular, when this device last polled.
|
||||
|
||||
@@ -63,7 +63,9 @@ struct AnonymousReadsTests {
|
||||
@Test("anonymous presence is capped per request and metered per address")
|
||||
func presenceLimits() async throws {
|
||||
try await withRelay { h in
|
||||
let many = (0...Routes.maxAnonymousPresenceTokens).map { _ in
|
||||
// Nine, written out: §8.11 says batches of at most eight, and a
|
||||
// test that read the constant would move with it.
|
||||
let many = (0..<9).map { _ in
|
||||
"\"\(W0B.random32().base64EncodedString())\""
|
||||
}
|
||||
let tooMany = await post(h, "/e2e/anon/presence", #"{"tokens":[\#(many.joined(separator: ","))]}"#) {
|
||||
@@ -93,11 +95,15 @@ struct AnonymousReadsTests {
|
||||
func directory() async throws {
|
||||
try await withRelay { h in
|
||||
_ = try await subject(h)
|
||||
let d = try #require(try await Device.find("aaaa-0007", on: h.db))
|
||||
d.capsSig = "caps-signature"
|
||||
try await d.save(on: h.db)
|
||||
let a = await post(h, "/e2e/anon/devices", #"{"user_id":7}"#) { req, body in
|
||||
try await h.routes.anonymousDevices(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
#expect(a.status == 200)
|
||||
#expect(a.body.contains("aaaa-0007") && a.body.contains("ik_sig"))
|
||||
#expect(a.body.contains("caps-signature"), "§8.11 lists caps_sig in the directory")
|
||||
#expect(!a.body.contains("created_at") && !a.body.contains("last_seen"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// `test/vectors/handle-cert-v1.json` from flamenet-e2e (branch
|
||||
/// privacy/sweep-2026-10, commit 7404465), copied byte for byte so this suite
|
||||
/// does not need a sibling checkout. sha256
|
||||
/// d73de0c7f9127a0d8e104e9c27bebc7377c0f5019863ce5e452e1edcf21da1a2. When the
|
||||
/// vectors move, this copy moves with them.
|
||||
///
|
||||
/// The relay's half of the contract is the wire shape and the signed bytes:
|
||||
/// every `valid` case must decode into `HandleCertificate` and verify, and
|
||||
/// every `invalid` one must fail to decode or fail to verify.
|
||||
@Suite("handle certificate vectors")
|
||||
struct HandleCertificateVectorTests {
|
||||
|
||||
static let json = #"""
|
||||
{
|
||||
"_comment": [
|
||||
"Handle-certificate vectors (SPEC 14.5). Generated by",
|
||||
"test/vectors/generate-handle-cert.mjs; do not edit by hand.",
|
||||
"",
|
||||
"Signed bytes: fnhandle-v1|<aud>|<uid>|<h>|<exp>, UTF-8, exp in unix seconds,",
|
||||
"Ed25519 under the relay key that signs its log heads (SPEC 19.3). Every",
|
||||
"`valid` case verifies at its verifyAt; every `invalid` case must not.",
|
||||
"",
|
||||
"THROWAWAY KEY: issuer.seed is SHA-256 of a fixed label, generated for this",
|
||||
"file alone, never a real relay key."
|
||||
],
|
||||
"version": 1,
|
||||
"issuer": {
|
||||
"seed": "jBgKukZEzd9AaBmtCCFNsTjq4pZDAR8Hls67dc7/JCE=",
|
||||
"publicKey": "A7kJrQ6iABR0iNyy7pbOLQS3zFi4hmp1oIwII9SvtDU="
|
||||
},
|
||||
"valid": [
|
||||
{
|
||||
"name": "plain",
|
||||
"note": "The ordinary case.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"canonical": "fnhandle-v1|relay.example.org|4242|ada|1800000000",
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "handle-with-dot-and-digits",
|
||||
"note": "A handle is signed as it is stored, byte for byte.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 7,
|
||||
"h": "ada.l2",
|
||||
"exp": 1800000000,
|
||||
"sig": "CbXogYwhOXFSDkgIVBwo3luQa7L8vGHINheQPdQ3UcQKJHPXhUF3rMyws4LYqhGwKZhHHF/WKSmgtBHljDYvAA=="
|
||||
},
|
||||
"canonical": "fnhandle-v1|relay.example.org|7|ada.l2|1800000000",
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "non-ascii-handle",
|
||||
"note": "UTF-8 in the signed bytes, not an escape.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 9,
|
||||
"h": "zoë",
|
||||
"exp": 1800000000,
|
||||
"sig": "8/3TVRuqzwtlgzeMuzmpYIqMra/UaH1vS24sA2MSLrCs2HcrDe3/ojibxFU7BuOZwmXOEUgzyRzBX4Tr68L+AA=="
|
||||
},
|
||||
"canonical": "fnhandle-v1|relay.example.org|9|zoë|1800000000",
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "one-second-before-expiry",
|
||||
"note": "Valid while exp is greater than now.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"canonical": "fnhandle-v1|relay.example.org|4242|ada|1800000000",
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799999999,
|
||||
"userId": 4242
|
||||
}
|
||||
}
|
||||
],
|
||||
"invalid": [
|
||||
{
|
||||
"name": "expired",
|
||||
"note": "At exp the certificate is already expired.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1800000000,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "another-relay",
|
||||
"note": "Signed for this relay, presented as if for another.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.net",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "another-account",
|
||||
"note": "A true certificate for somebody else: the uid must match the account it is about.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4243
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "handle-changed",
|
||||
"note": "The signature covers the handle.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "adam",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expiry-extended",
|
||||
"note": "The signature covers exp.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800086400,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uid-changed",
|
||||
"note": "The signature covers the uid.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4243,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4243
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "signed-by-a-stranger",
|
||||
"note": "Well formed and self-consistent, but not by the pinned key.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "ql984VUtTmd0ky58IQAD00stROOQQv9InxpVEy42N7s9KEYiNuTSnsqmhzYglm3HVTBjm3NGbWnMlQ3NF0MECA=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "separator-in-handle",
|
||||
"note": "A field holding `|` could be read two ways and is refused before any signature check.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada|4242",
|
||||
"exp": 1800000000,
|
||||
"sig": "flUXarDOXNruqHKfV5gxDOnFV5+FFjrikdOthwjVsSD8p9yct8AvxKHFMJx5z6OWpbobutgoJ828TU8DusGYDA=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wrong-version",
|
||||
"note": "Only v 1 is defined.",
|
||||
"cert": {
|
||||
"v": 2,
|
||||
"aud": "relay.example.org",
|
||||
"uid": 4242,
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uid-as-text",
|
||||
"note": "uid is a JSON integer. A string is refused rather than coerced.",
|
||||
"cert": {
|
||||
"v": 1,
|
||||
"aud": "relay.example.org",
|
||||
"uid": "4242",
|
||||
"h": "ada",
|
||||
"exp": 1800000000,
|
||||
"sig": "uyjUlkKHG21uRmt0s25BLJZUlMBv9cyaRce6Dqabw+gxcDSrCgESoT7t0tm+74JmXmovKQUPej+z9leB2P9pBQ=="
|
||||
},
|
||||
"verifyAt": {
|
||||
"audience": "relay.example.org",
|
||||
"nowSeconds": 1799913600,
|
||||
"userId": 4242
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"""#
|
||||
|
||||
struct Case: Decodable {
|
||||
let name: String
|
||||
let cert: AnyCert
|
||||
let verifyAt: At
|
||||
struct At: Decodable {
|
||||
let audience: String
|
||||
let nowSeconds: Int
|
||||
let userId: Int
|
||||
}
|
||||
}
|
||||
/// Raw JSON of the certificate, so a case the type refuses to decode is
|
||||
/// a refusal rather than a test that cannot load.
|
||||
struct AnyCert: Decodable {
|
||||
let data: Data
|
||||
init(from decoder: any Decoder) throws {
|
||||
let value = try JSONValue(from: decoder)
|
||||
data = try JSONEncoder().encode(value)
|
||||
}
|
||||
}
|
||||
indirect enum JSONValue: Codable {
|
||||
case s(String), n(Double), b(Bool), o([String: JSONValue]), a([JSONValue]), null
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if c.decodeNil() { self = .null }
|
||||
else if let x = try? c.decode(Bool.self) { self = .b(x) }
|
||||
else if let x = try? c.decode(Double.self) { self = .n(x) }
|
||||
else if let x = try? c.decode(String.self) { self = .s(x) }
|
||||
else if let x = try? c.decode([JSONValue].self) { self = .a(x) }
|
||||
else { self = .o(try c.decode([String: JSONValue].self)) }
|
||||
}
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .s(let x): try c.encode(x)
|
||||
case .n(let x):
|
||||
if x == x.rounded(), abs(x) < 1e15 { try c.encode(Int64(x)) } else { try c.encode(x) }
|
||||
case .b(let x): try c.encode(x)
|
||||
case .o(let x): try c.encode(x)
|
||||
case .a(let x): try c.encode(x)
|
||||
case .null: try c.encodeNil()
|
||||
}
|
||||
}
|
||||
}
|
||||
struct File: Decodable {
|
||||
let issuer: Issuer
|
||||
let valid: [Case]
|
||||
let invalid: [Case]
|
||||
struct Issuer: Decodable { let publicKey: String }
|
||||
}
|
||||
|
||||
private func verdict(_ c: Case, key: Data) -> Bool {
|
||||
guard let cert = try? JSONDecoder().decode(HandleCertificate.self, from: c.cert.data)
|
||||
else { return false }
|
||||
return cert.verifies(
|
||||
with: key, now: c.verifyAt.nowSeconds,
|
||||
audience: c.verifyAt.audience, userID: c.verifyAt.userId)
|
||||
}
|
||||
|
||||
@Test("every valid vector verifies and every invalid one does not")
|
||||
func vectors() throws {
|
||||
let file = try JSONDecoder().decode(File.self, from: Data(Self.json.utf8))
|
||||
let key = try #require(Data(base64Encoded: file.issuer.publicKey))
|
||||
#expect(file.valid.count == 4 && file.invalid.count == 10)
|
||||
for c in file.valid {
|
||||
#expect(verdict(c, key: key), "valid case \(c.name) did not verify")
|
||||
}
|
||||
for c in file.invalid {
|
||||
#expect(!verdict(c, key: key), "invalid case \(c.name) verified")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the relay's own certificate encodes with the spec's keys")
|
||||
func keys() throws {
|
||||
let seed = Data(repeating: 3, count: 32)
|
||||
let cert = try #require(
|
||||
HandleCertificate.issue(
|
||||
audience: "relay.example.org", userID: 12, handle: "ada", now: 1_000,
|
||||
signingKeyRaw: seed))
|
||||
let object = try #require(
|
||||
try JSONSerialization.jsonObject(with: JSONEncoder().encode(cert)) as? [String: Any])
|
||||
#expect(Set(object.keys) == ["v", "aud", "uid", "h", "exp", "sig"])
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ struct IntroductionTests {
|
||||
#expect(body.user_id == 7)
|
||||
#expect(body.intro_tokens.count == Routes.introTokensPerIntro)
|
||||
#expect(body.expires_in == 600)
|
||||
// §14.5's shape alongside: the first token and its expiry.
|
||||
#expect(body.intro == body.intro_tokens[0] && body.exp == now + 600)
|
||||
#expect(body.devices.map(\.device_id) == ["aaaa-0007"])
|
||||
#expect(body.devices.first?.opk?.id == 1, "the bundle did not pop a prekey")
|
||||
#expect(try await Routes.poolSize(PreKey.self, "aaaa-0007", on: h.db) == 1)
|
||||
@@ -64,6 +66,28 @@ struct IntroductionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the spec's `intro` field spends an intro token once, as delivery_token does")
|
||||
func introField() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let body = try W0B.decode(IntroResponse.self, try await W0B.intro(h, handle: "ada"))
|
||||
func send() async -> W0B.Answer {
|
||||
let json = Data(
|
||||
#"{"to":7,"messages":[{"to_device":"aaaa-0007","payload":"ct"}],"intro":"\#(body.intro)"}"#
|
||||
.utf8)
|
||||
return await W0B.answer {
|
||||
try await h.routes.send(
|
||||
h.anonymousRequest(.POST, "/e2e/messages", body: json), body: json,
|
||||
clientIP: "198.51.100.7", now: now)
|
||||
}
|
||||
}
|
||||
#expect(await send().status == 200)
|
||||
#expect(await send().status == 401)
|
||||
#expect(try await Envelope.query(on: h.db).first()?.fromUser == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an intro token for one account does not open another's inbox")
|
||||
func boundToTarget() async throws {
|
||||
try await withRelay { h in
|
||||
|
||||
+6
-6
@@ -137,7 +137,7 @@ its inbox.
|
||||
| `DELETE /e2e/devices/{device_id}` | Retire a device and stop its bundles being served |
|
||||
| `GET /e2e/keys/{user}` | Fetch bundles. Pops a one-time prekey per device |
|
||||
| `POST /e2e/prekeys` | Top the pool back up |
|
||||
| `POST /e2e/messages` | Deposit envelopes. A sealed `delivery_token` may be one the recipient issued or one from `POST /e2e/intro`. With `FN_PAD_ENFORCE=enforce`, a request carrying any payload not exactly 16384 or 65536 characters long is refused with `unpadded` |
|
||||
| `POST /e2e/messages` | Deposit envelopes. A sealed `delivery_token` may be one the recipient issued or one from `POST /e2e/intro`; `intro`, §14.5's name for the field, is taken the same way. With `FN_PAD_ENFORCE=enforce`, a request carrying any payload not exactly 16384 or 65536 characters long is refused with `unpadded` |
|
||||
| `GET /e2e/messages` | Poll this device's inbox. Stops near 2 MiB of payload, never below one envelope, and then adds `"more": true`: poll again from the returned `cursor` |
|
||||
| `POST /e2e/push/token` | Register this device's APNs token |
|
||||
| `POST /e2e/delivery-key` | Publish this device's delivery-key digest for sealed sending (§14.4). A sealed send is accepted against any of the account's device digests |
|
||||
@@ -183,9 +183,9 @@ it, never the id. The relay still sees the address.
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `POST /e2e/intro` | `{"handle": h}` (listed accounts only) or `{"link": secret}` (base64 or base64url, 32 bytes). Returns `user_id`, `fnid`, three `intro_tokens` good for one sealed send each for 10 minutes (`expires_in`), the target's bundles (`devices`, one-time prekeys popped) and `handle_cert`. An unlisted or unknown handle, an unknown or revoked link and an account with no devices all get the same `404 not_found` "No such account.", no sooner than 5 ms. Metered per address (`FN_INTRO_RATE`), per network (`FN_INTRO_RATE_NETWORK`), and once an account is found, per account (`FN_INTRO_RATE_TARGET`) and per network and account (a third of that); at most 90 unspent intro tokens per account |
|
||||
| `POST /e2e/anon/presence` | `{"tokens": [...]}`, at most 64. The same answer as `POST /messenger/presence`. Metered per address, by token count |
|
||||
| `POST /e2e/anon/devices` | `{"user_id": n, "cap": token?}`. Identity keys only, no `created_at` or `last_seen` |
|
||||
| `POST /e2e/intro` | `{"handle": h}` (listed accounts only) or `{"link": secret}` (base64 or base64url, 32 bytes). Returns `user_id`, `fnid`, `intro` and `exp` (§14.5's shape: one token and its expiry in unix seconds), and `intro_tokens`, three tokens of which `intro` is the first, each good for one sealed send within 10 minutes (`expires_in`), the target's bundles (`devices`, one-time prekeys popped) and `handle_cert`. An unlisted or unknown handle, an unknown or revoked link and an account with no devices all get the same `404 not_found` "No such account.", no sooner than 5 ms. Metered per address (`FN_INTRO_RATE`), per network (`FN_INTRO_RATE_NETWORK`), and once an account is found, per account (`FN_INTRO_RATE_TARGET`) and per network and account (a third of that); at most 90 unspent intro tokens per account |
|
||||
| `POST /e2e/anon/presence` | `{"tokens": [...]}`, at most 8. The same answer as `POST /messenger/presence`. Metered per address, by token count |
|
||||
| `POST /e2e/anon/devices` | `{"user_id": n, "cap": token?}`. Identity keys and `caps_sig` only, no `created_at` or `last_seen` |
|
||||
| `POST /e2e/anon/bundles` | `{"user_id": n, "cap": token?}`. As `GET /e2e/keys/{user}`, popping a prekey per device |
|
||||
|
||||
For the last two, `cap` is a presence token the subject issued to the caller.
|
||||
@@ -198,11 +198,11 @@ not refused, so the route says nothing about whether a token is good.
|
||||
until `exp`:
|
||||
|
||||
```json
|
||||
{ "v": 1, "audience": "relay.example", "user_id": 7, "handle": "ada",
|
||||
{ "v": 1, "aud": "relay.example", "uid": 7, "h": "ada",
|
||||
"exp": 1760000000, "sig": "<base64 Ed25519>" }
|
||||
```
|
||||
|
||||
`sig` is over the bytes `fnhandle-v1|<audience>|<user_id>|<handle>|<exp>`,
|
||||
`sig` is over the bytes `fnhandle-v1|<aud>|<uid>|<h>|<exp>`,
|
||||
made with the key that signs the log head, whose public half is the
|
||||
descriptor's `issuerPublicKey` that both apps pin on first sight. Handles are
|
||||
lowercase letters and digits, so `|` cannot occur inside one. Good for 7 days.
|
||||
|
||||
Reference in New Issue
Block a user