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:
Paul Hitt
2026-10-07 14:37:36 -04:00
co-authored by Claude Opus 5.5
parent 114fadbcbf
commit b4bf14587b
10 changed files with 462 additions and 29 deletions
+17 -12
View File
@@ -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>{ &quot;v&quot;: 1, &quot;audience&quot;: &quot;relay.example&quot;, &quot;user_id&quot;: 7, &quot;handle&quot;: &quot;ada&quot;,
<pre class="json"><code>{ &quot;v&quot;: 1, &quot;aud&quot;: &quot;relay.example&quot;, &quot;uid&quot;: 7, &quot;h&quot;: &quot;ada&quot;,
&quot;exp&quot;: 1760000000, &quot;sig&quot;: &quot;&lt;base64 Ed25519&gt;&quot; }</code></pre>
<p><code>sig</code> is over the bytes
<code>fnhandle-v1|&lt;audience&gt;|&lt;user_id&gt;|&lt;handle&gt;|&lt;exp&gt;</code>,
<code>fnhandle-v1|&lt;aud&gt;|&lt;uid&gt;|&lt;h&gt;|&lt;exp&gt;</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>
+18 -2
View File
@@ -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 }
+6 -5
View File
@@ -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 = ""
+3 -3
View File
@@ -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
+7
View File
@@ -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]
+7
View File
@@ -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
View File
@@ -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.