@forgesworn/fold-kit 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/seal.d.ts CHANGED
@@ -56,6 +56,26 @@ export declare function credentialSeal(credential: DeviceCredential): string | u
56
56
  * exists to shut out.
57
57
  */
58
58
  export declare function sealTarget(device: string, credential?: DeviceCredential): string;
59
+ /**
60
+ * The tag on a rekey (kind 1462) or an authority's grant (kind 20469) that
61
+ * names the one-time key it was sealed from. The event is still signed by
62
+ * the authority, so the tag is the authority's word; the one-time secret is
63
+ * discarded once the copies are sealed, so whoever later takes the
64
+ * authority's key cannot recompute them. Forward secrecy step 1, see
65
+ * docs/seal-key.md, "One-time senders".
66
+ */
67
+ export declare const SENDER_TAG = "from";
68
+ /**
69
+ * Who sealed the copies in `event`: the key its single `from` tag names, or
70
+ * its signer when it carries none, which is every rekey and grant written
71
+ * before one-time senders. Null when the tags are unusable: more than one
72
+ * `from`, or one that is not a point on the curve. Only the authority could
73
+ * have signed either, so a reader treats null as a copy it cannot open.
74
+ */
75
+ export declare function sealSender(event: {
76
+ pubkey: string;
77
+ tags: string[][];
78
+ }): string | null;
59
79
  /** Seal `plaintext` from `senderSk` to `device`, by `sealTarget`. */
60
80
  export declare function sealTo(plaintext: string, senderSk: Uint8Array, device: string, credential?: DeviceCredential): string;
61
81
  /**
package/dist/seal.js CHANGED
@@ -84,6 +84,31 @@ export function sealTarget(device, credential) {
84
84
  return to;
85
85
  return credentialSeal(credential) ?? to;
86
86
  }
87
+ /**
88
+ * The tag on a rekey (kind 1462) or an authority's grant (kind 20469) that
89
+ * names the one-time key it was sealed from. The event is still signed by
90
+ * the authority, so the tag is the authority's word; the one-time secret is
91
+ * discarded once the copies are sealed, so whoever later takes the
92
+ * authority's key cannot recompute them. Forward secrecy step 1, see
93
+ * docs/seal-key.md, "One-time senders".
94
+ */
95
+ export const SENDER_TAG = 'from';
96
+ /**
97
+ * Who sealed the copies in `event`: the key its single `from` tag names, or
98
+ * its signer when it carries none, which is every rekey and grant written
99
+ * before one-time senders. Null when the tags are unusable: more than one
100
+ * `from`, or one that is not a point on the curve. Only the authority could
101
+ * have signed either, so a reader treats null as a copy it cannot open.
102
+ */
103
+ export function sealSender(event) {
104
+ const tags = event.tags.filter((t) => t[0] === SENDER_TAG);
105
+ if (tags.length === 0)
106
+ return normaliseHex(event.pubkey);
107
+ if (tags.length > 1)
108
+ return null;
109
+ const value = tags[0][1];
110
+ return isSealPubkey(value) ? normaliseHex(value) : null;
111
+ }
87
112
  /** Seal `plaintext` from `senderSk` to `device`, by `sealTarget`. */
88
113
  export function sealTo(plaintext, senderSk, device, credential) {
89
114
  return nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(senderSk, sealTarget(device, credential)));
@@ -49,17 +49,50 @@ Unknown fields, non-data descriptors, custom prototypes, sparse arrays and
49
49
  noncanonical reload strings are refused. JavaScript proxies are outside this
50
50
  plain-data transport boundary; callers must not pass executable objects.
51
51
 
52
- Queries admit at most 64 delivered candidates, including invalid candidates and
53
- duplicates, with 16,384 UTF-8 bytes per canonical carrier and 262,144 UTF-8 bytes
54
- across all delivered canonical candidates. Work/bytes are charged before crypto;
55
- candidate 65 is refused before carrier copying. Retained evidence has the same
56
- limits. Event tags are bounded to 64 arrays of at most eight strings. These are
57
- evidence-profile limits, not new wire limits for the legacy API. Exact duplicates
58
- cost no additional retained capacity but consume query work and byte budget;
59
- the same ID with a different carrier fails the entire query. Capacity is checked
60
- before admitting another candidate, with no eviction or truncated success.
61
- Malformed/oversized object framing fails the query. Well-framed events that
62
- fail signature, capability or scope validation are ignored, never retained.
52
+ Retained evidence holds at most 64 events, with 16,384 UTF-8 bytes per
53
+ canonical carrier and 262,144 UTF-8 bytes across all retained carriers. Event
54
+ tags are bounded to 64 arrays of at most eight strings. These are
55
+ evidence-profile limits, not new wire limits for the legacy API.
56
+
57
+ A query reads at most 512 deliveries
58
+ (`MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES`): every relay's copy of every
59
+ event, repeats, other authors and junk alike, each charged before it is parsed.
60
+ Past that the query fails, because an event that could matter may be among
61
+ those it did not read; delivery 513 is never inspected. The bound limits parse
62
+ work only. It is eight times the retained limit so that 64 events heard from
63
+ each of eight relays still fit.
64
+
65
+ What is delivered is then sorted cheaply, before any signature check:
66
+
67
+ - A malformed carrier (not plain data, a wrong type, bad hex), an event not by the inviter, not kind 1463
68
+ or 1461, or without exactly one `d` tag naming the invitation, is dropped.
69
+ It is not evidence and it does not fail the query.
70
+ - An exact repeat, another carrier under an id already held, and a carrier
71
+ already checked and refused, are dropped. A held id was verified, so a second
72
+ carrier with it has the same signed fields.
73
+ - An event in scope but over the carrier size limits (16,384 bytes, 64 tags,
74
+ eight strings in a tag) can never be retained. It is hashed and verified once,
75
+ charged to the signature bound below. If the authority signed it, the query
76
+ fails, because it may be a retirement or a newer list that no evidence can
77
+ hold. If not, it is dropped, and a repeat of it costs nothing.
78
+ - Anything else in scope costs one signature check. A query is charged at most
79
+ 128 (`MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS`): room for 64 retained
80
+ events and 64 more in-scope events that fail, forged or not a supported body.
81
+ The 129th is never checked and the query fails. The bound counts checks, not
82
+ Schnorr verifications. A retained retirement is verified twice, and the
83
+ finished query revalidates every event it kept, so a query that succeeds can
84
+ do up to 128 + 64 verifications. A forgery fails at its first, so a hostile
85
+ relay can cause at most 128.
86
+ - A checked, valid authority event is retained. If that would be a 65th event
87
+ or pass 262,144 bytes, the query fails. Nothing is evicted or truncated.
88
+
89
+ An in-scope, well-formed, validly signed authority event is never dropped, and
90
+ neither is an over-limit one: each is retained or fails the query. What the
91
+ query drops is what cannot be an authority event (malformed, out of scope,
92
+ unsigned by the authority) or repeats what it already holds.
93
+ `encodePersistentInvitation` refuses to sign an event over the size limits, so
94
+ only another writer can produce one. The failures above reject with
95
+ `PersistentInvitationEvidenceQueryError`.
63
96
 
64
97
  Evidence records, carrier strings and ID arrays are frozen owned copies.
65
98
  String memory is not claimed securely erased. Each admitted result has a fresh
@@ -102,8 +135,8 @@ custody and later independent revalidation.
102
135
  ## Completion and failure
103
136
 
104
137
  The query waits for the transport's EOSE even after an invitation or retirement
105
- arrives. Timeout, abort, subscription failure, contradictory duplicate or
106
- capacity failure rejects with `PersistentInvitationEvidenceQueryError`.
138
+ arrives. Timeout, abort, subscription failure or a capacity failure (deliveries,
139
+ signature checks, retained events or retained bytes) rejects with `PersistentInvitationEvidenceQueryError`.
107
140
  Its bounded `observation` is explicitly `completion: 'incomplete'`, diagnostic
108
141
  custody only. The revalidator always refuses incomplete observations. They
109
142
  cannot be relabelled to establish completeness; completion metadata itself is
package/docs/seal-key.md CHANGED
@@ -89,6 +89,41 @@ Not covered here:
89
89
  - `decodeEpochRequest` and `decodeMemberEpochRequest` return what they did in
90
90
  0.6.0. The desks read the credential internally.
91
91
 
92
+ ## One-time senders
93
+
94
+ Forward secrecy step 1 (KithMoot `docs/2026-10-06-key-schedule-next-steps.md`,
95
+ phase 2b). Every rekey copy and every authority grant used to be sealed from
96
+ the authority's long-lived key, so whoever later took that key could
97
+ recompute every past copy's conversation key and open it with nothing else.
98
+
99
+ With `oneTimeSender: true`, `encodeRekeyEvent` and `encodeEpochGrant` draw a
100
+ fresh key, seal every copy from it, name its public half in one
101
+ `["from", <x-only hex>]` tag, and zero the secret before signing. The event
102
+ is still signed by the authority, so the tag is the authority's word and a
103
+ forger's `from` is never read (the signer check comes first).
104
+
105
+ - A reader opens a copy with `sealSender(event)`: the single `from` tag's key,
106
+ or the signer when there is none. More than one `from`, or one off the
107
+ curve, is null: a rekey is then read without a secret (the device asks for
108
+ the epoch), and a grant is refused.
109
+ - Taking the authority's key later no longer opens a copy. It still lets the
110
+ taker sign new rekeys and grants; that is the healing question, not this
111
+ one.
112
+ - A copy is still sealed to the recipient's seal key, so a past copy stays
113
+ open to whoever holds that seal secret. Retiring seal keys older than the
114
+ history window is step 3.
115
+ - The member grant (20472) has always been signed by a one-time key, and is
116
+ unchanged.
117
+
118
+ **Readers first.** A reader older than 0.13.0 opens no copy from a one-time
119
+ key. For a rekey that costs a round trip (it asks for the epoch); for a grant
120
+ it costs the epoch. So `oneTimeSender` stays off in every writer until readers
121
+ have shipped on every client and had a release in the field. Off, every
122
+ event is byte-identical to before.
123
+
124
+ `hostRoomEpoch` takes `oneTimeSender` too and passes it to every grant it
125
+ writes; it is off by default, like the encoders.
126
+
92
127
  ## Vectors
93
128
 
94
129
  `vectors/seal-vectors.json` (generated by `scripts/generate-seal.mjs`,
@@ -102,3 +137,7 @@ checked by `vectors/verify-seal.test.ts`):
102
137
  (byte-identical to a rekey with bare devices);
103
138
  - `epoch-grant-seal` and `member-grant-seal` (the thief replays the copied
104
139
  credential; the desk seals to the renewed one).
140
+ - `rekey-one-time-sender` and `epoch-grant-one-time-sender` (the same rekey
141
+ and grant from a one-time key; the authority's key no longer opens a copy),
142
+ and the refusals `rekey-from-doubled`, `rekey-from-off-curve` and
143
+ `epoch-grant-from-off-curve`.
package/llms.txt CHANGED
@@ -10,7 +10,7 @@ ESM-only, Node.js 22.13+. Four entry points: `@forgesworn/fold-kit` (circle prim
10
10
  npm install @forgesworn/fold-kit
11
11
  ```
12
12
 
13
- This file describes `main` (version 0.12.0), which can run ahead of the npm `latest` release; compare `npm view @forgesworn/fold-kit version` with the version of the API you need. To use an unreleased change, pin an immutable Git commit (a full SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`); the `prepare` script builds `dist/` on install:
13
+ This file describes `main` (version 0.14.0), which can run ahead of the npm `latest` release; compare `npm view @forgesworn/fold-kit version` with the version of the API you need. To use an unreleased change, pin an immutable Git commit (a full SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`); the `prepare` script builds `dist/` on install:
14
14
 
15
15
  ```bash
16
16
  npm install "github:forgesworn/fold-kit#<commit>"
@@ -124,6 +124,7 @@ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy
124
124
  - Same optional entry: `revalidatePersistentInvitationSource({ event, invitation, expectedRoomSecret })` independently authenticates one actual encoder Event or exact canonical stored carrier against a mandatory room-root pin. Returns a frozen `PersistentInvitationSource` with `version`, `invitationId`, `inviter`, `eventId`, `createdAt`, `event` string and owned `admission`. No completion/EOSE, publication, newest-selection, currentness or non-retirement claim. Rejects retirement, unsupported bodies/tags, forged signature caches, noncanonical strings and oversize before parsing/crypto. Creator clients retain exact event bytes and publication intent before pools; retry those bytes under fresh effect guards. Erase returned admission.secret after use.
125
125
  - Conference rooms: `encodePersistentInvitation({ ..., endsAt })` puts `ends` in the body and `['expiration', String(endsAt)]` on the 1463; `decodePersistentInvitation` returns `endsAt` and is null when an `expiration` tag disagrees with the body; `encodeInvitationRetirement({ ..., endsAt })` tags the 1461 the same way
126
126
  - Room relays: `encodePersistentInvitation({ ..., relays })` puts the room's own relays (1-8 distinct canonical `wss://host/` URLs, else it throws) in the body after `ends`; the decoder returns `relays` and is null on a malformed list; `requestPersistentRoomAdmission` keeps the newest signed copy's list (a copy without one says nothing); `isInvitationRelays`, `requireInvitationRelays`, `MAX_INVITATION_RELAYS` (8)
127
+ - Room storage nodes: `encodePersistentInvitation({ ..., storage })` puts the room's group storage nodes (0-8 canonical base URLs on distinct hosts, e.g. `https://grove.example.net`; `http` only for `localhost`, `127.x.y.z` or an onion host; no default port, no trailing slash, no user info, query or fragment; else it throws) last in the body after `relays`, never a tag; `[]` means the authority withdrew every node, an absent key says nothing and leaves the body byte-identical to 0.13.0's. The decoder returns `storage` and `storageAt` (the `created_at` of the copy whose list stood) and is null on a malformed list; `requestPersistentRoomAdmission` and the strict evidence path keep the newest signed copy that names storage (a copy without the key says nothing), independently of `relays`; the strict path holds a same-second disagreement as `conflicting-storage` and `contributors.storage` names the copy that stood (present only when a copy names storage). `storageNodeFromBase(text)` → `{ base, server }` canonicalises an operator's URL (throws a message fit to show an operator); `storageNodeServer(base)` → the `server` (host) of a canonical base by grammar alone, `undefined` when not canonical, no URL parser; `isStorageNodeBase`, `isInvitationStorage`, `requireInvitationStorage`, `MAX_INVITATION_STORAGE_NODES` (8), `MAX_STORAGE_NODE_URL_LENGTH` (256). Vectors: `vectors/storage-vectors.json`
127
128
  - Self-destructing rooms (see `docs/room-destruct.md`): `encodePersistentInvitation({ ..., destruct: true })` puts `"destruct": true` in the encrypted body after `ends` (valid without `ends`), never a tag; `decodePersistentInvitation` returns `destruct: true` and is null when the field is present and not `true`; `requestPersistentRoomAdmission` keeps `destruct` if any valid copy carries it (sticky, like the earliest `ends`). `encodeInvitationRetirement({ ..., ended: true, destruct: true })` writes `{"v":1,"ended":true,"destruct":true}` (plain JSON, like `ended`) and throws on `destruct` without `ended`; `decodeInvitationRetirementNotice` returns `{ ended, destruct?: true }`, believing `destruct` only beside `ended`. `encodeRekeyEvent({ ..., closed: true, destruct: true })` writes it in the encrypted body after `closed` and throws without `closed`; `RekeyNotice.destruct` / `RekeyEvidence.destruct` are set only on a closing body, and a flag on an open rekey is dropped, not refused. Without the flag every event is byte-identical to 0.8.0, and a 0.8.0 reader ignores it
128
129
  - `withExpiration(tags, expiresAt?)` - NIP-40 rule for a room that ends: add the tag, keep an earlier one, lower a later one; `isRoomEnds`, `requireRoomEnds(endsAt, now)`, `MAX_ROOM_ENDS_SECONDS` (30 days)
129
130
  - Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
@@ -139,7 +140,7 @@ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy
139
140
  - Quiet replies (see `docs/quiet-replies.md`): `hostRoomEpoch` waits a random `replyDelayMs` (`[min, max]` ms, default `DEFAULT_REPLY_DELAY_MS` = `[500, 2000]`, `[0, 0]` answers at once) before every grant or refusal, deciding when the wait ends; closing cancels waits. A participant is sent `removed`/`closed` at most once per `refuseAgainAfter` seconds (default `REFUSE_AGAIN_AFTER_SECONDS` = 3600) and `unknown` once per `reportUnknownEvery` (60); at most `maxRefusalsPerMinute` refusals (default `MAX_REFUSALS_PER_MINUTE` = 20) go out in any minute in all. Grants are never limited. A suppressed refusal still calls `onRefused`/`onUnknown`; its asker times out with the plain error. `random` and `schedule` (`ReplySchedule`) are injectable. `RefusalLimiter` (bounded to `REFUSAL_LIMITER_CAPACITY` = 256 keys) and `replyDelay(range, random)` are exported. Events and vectors are unchanged
140
141
  - Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. A verified grant is `{ epoch, removed, passed }`: `passed` holds the epochs between the asker's and `epoch`, oldest first, and `requestRoomEpoch` carries it as an optional `passed` when a member answered. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
141
142
  - The known-members gate (#207, see `docs/member-epoch-catch-up.md`): once a room has removed anybody (`removed()` not empty), `hostRoomEpoch` and `hostMemberEpochDesk` grant only to a participant for which `known(participant)` is true; anybody else is reported through `onUnknown` (once per participant, and again every `reportUnknownEvery` seconds, default `REPORT_UNKNOWN_EVERY_SECONDS` = 60, while they keep asking) and refused: the authority answers `refused: 'unknown'` (an `EpochRefusal`), a member stays silent. `unknown` is not final: `requestRoomEpoch` keeps asking, calls its `onUnknown` once, and rejects with `EpochRefusedError('unknown')` only at its timeout; a desk looks at the requester's next ask afresh, so letting somebody in is making `known` say yes. No `known` after a removal means nobody is known. Member lists: `encodeRekeyEvent({ members })` writes the authority's list (removed dropped, sorted) into the body, read back as `RekeyNotice.members` and `RekeyEvidence.members`, and as `MemberEpochGrant.members` (newest in the chain); `hostRoomEpoch({ members })` puts it in its grants (`EpochGrant.members`). `readMemberList(raw)` reads one, undefined when malformed. Without `members` and with no removal every event is byte-identical to 0.5.1
142
- - Seal keys (see `docs/seal-key.md`): `createDeviceCredential({ ..., seal })` appends `["seal", <x-only pubkey>]` last, under the participant's signature; mint a fresh one per credential with `generateSealKey()` and keep its secret as you keep the device key (old copies are sealed to old seal keys; healing needs only that each new one is fresh). `encodeRekeyEvent` recipients may be `{ device, credential }`; `encodeEpochGrant` and `encodeMemberEpochGrant` take `credential`; each copy is sealed to `sealTarget(device, credential)`: the credential's seal key, else the device key (never back to the device key because the credential lapsed). Readers take `sealSks` (`decodeRekeyEvent`, `decodeEpochGrant`, `decodeMemberEpochGrant`) or a `sealSks()` getter (`requestRoomEpoch`, `memberEpochSource`) and try them before the device key (`openSealed`); a rekey copy nothing opens reads as no copy. Desks take `credentialFor(device)`, the newest credential the room has seen (keep it monotone), and seal to the newer of that and the presented one (`sealCredential`, `newerCredential`: later expiration, then later created_at). `credentialSeal(c)` is the pubkey, undefined for none, null when doubled or off the curve (sealed to the device key). Without seal keys every event is byte-identical to 0.6.0
143
+ - Seal keys (see `docs/seal-key.md`): `createDeviceCredential({ ..., seal })` appends `["seal", <x-only pubkey>]` last, under the participant's signature; mint a fresh one per credential with `generateSealKey()` and keep its secret as you keep the device key (old copies are sealed to old seal keys; healing needs only that each new one is fresh). `encodeRekeyEvent` recipients may be `{ device, credential }`; `encodeEpochGrant` and `encodeMemberEpochGrant` take `credential`; each copy is sealed to `sealTarget(device, credential)`: the credential's seal key, else the device key (never back to the device key because the credential lapsed). Readers take `sealSks` (`decodeRekeyEvent`, `decodeEpochGrant`, `decodeMemberEpochGrant`) or a `sealSks()` getter (`requestRoomEpoch`, `memberEpochSource`) and try them before the device key (`openSealed`); a rekey copy nothing opens reads as no copy. Desks take `credentialFor(device)`, the newest credential the room has seen (keep it monotone), and seal to the newer of that and the presented one (`sealCredential`, `newerCredential`: later expiration, then later created_at). `credentialSeal(c)` is the pubkey, undefined for none, null when doubled or off the curve (sealed to the device key). Without seal keys every event is byte-identical to 0.6.0 - One-time senders (see `docs/seal-key.md`, "One-time senders"): `encodeRekeyEvent` and `encodeEpochGrant` take `oneTimeSender: true`, sealing every copy from a fresh key named in one `["from", <x-only pubkey>]` tag (still authority-signed) and zeroing it, so a later copy of the authority key opens no past copy; readers open with `sealSender(event)` (the `from` key, else the signer; null when doubled or off the curve: a rekey reads without a secret, a grant is refused). `hostRoomEpoch({ oneTimeSender: true })` does the same for every grant the desk writes. Keep writers off until every client's readers have shipped; off, events are byte-identical to 0.12.0.
143
144
  - Scheduled rekeys and the history window (see `docs/scheduled-rekey.md`): `encodeRekeyEvent({ ..., scheduled: true })` writes `"scheduled": true` inside the encrypted body (never a tag), and throws beside a non-empty `removed` or `closed`; `decodeRekeyEvent` (`RekeyNotice.scheduled`) and `readRekeyEvidence` (`RekeyEvidence.scheduled`) report `true` only when `removed` is empty and the room is not closed, so a contradictory body is still announced. `HISTORY_WINDOW_SECONDS` (30 days) and `MAX_HISTORY_EPOCHS` (16); `epochsInWindow(left, now)` keeps entries with `leftAt >= now - HISTORY_WINDOW_SECONDS`, newest first by epoch, first of a doubled epoch, at most 16, never throws. `encodeEpochGrant({ ..., passed })` takes `LeftEpoch[]` (`{ epoch, secret, leftAt }`) and writes `passed: [{ epoch, secret (base64url), left }]` oldest first, only when non-empty; it throws on more than 16, epoch 0 (the room secret is never carried), an epoch not below the granted one, a doubled epoch, a non-32-byte secret or a bad `leftAt`. `hostRoomEpoch({ past })` is asked on every grant, filtered to what the encoder accepts and to `epochsInWindow`, so a bad `past()` costs the history, not the grant. `decodeEpochGrant` returns `EpochGrant.passed` (`leftAt` set) only when the list is exactly in that form, else drops the field and keeps the grant; `decodeMemberEpochGrant` sets `leftAt` on each passed epoch from the next rekey's `created_at`. `MAX_MEMBER_EPOCH_CHAIN` (32) is a different limit. Without the marker and `passed` every event is byte-identical to 0.7.0, and a 0.7.0 reader ignores both
144
145
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
145
146
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -32,6 +32,7 @@
32
32
  "vectors/seal-vectors.json",
33
33
  "vectors/schedule-vectors.json",
34
34
  "vectors/destruct-vectors.json",
35
+ "vectors/storage-vectors.json",
35
36
  "LICENSE",
36
37
  "README.md",
37
38
  "llms.txt",
@@ -55,6 +56,7 @@
55
56
  "generate-seal": "node scripts/generate-seal.mjs",
56
57
  "generate-schedule": "node scripts/generate-schedule.mjs",
57
58
  "generate-destruct": "node scripts/generate-destruct.mjs",
59
+ "generate-storage": "node scripts/generate-storage.mjs",
58
60
  "bundle-check": "node scripts/bundle-check.mjs",
59
61
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
60
62
  "vectors": "vitest run vectors/",
@@ -623,6 +623,310 @@
623
623
  },
624
624
  "thief": null
625
625
  }
626
+ },
627
+ {
628
+ "name": "rekey-one-time-sender",
629
+ "kind": "positive",
630
+ "note": "rekey-seal's rekey with `oneTimeSender`: tags d, epoch, then [\"from\", x-only pubkey of a key made for this rekey alone], still signed by the authority, so the tag is the authority's word. Every copy is sealed from that one-time key to the recipient's seal key (or device key), and the one-time secret is discarded, so whoever later takes the authority's key cannot recompute the copies (`authorityOpens` false). A reader opens its copy with the key the single `from` tag names (`sealSender`), and with the signer when there is none, which is every earlier rekey. `randomHex`: the 48-byte seed for the one-time key (`senderSkHex`), one seal nonce per recipient in order, the body nonce, the signature aux-rand. A reader older than this opens no copy, reads the rekey without a secret and asks for the epoch.",
631
+ "input": {
632
+ "roomId": "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c",
633
+ "authority": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
634
+ "authoritySkHex": "bb058b43370d37b7ed3f09e3a36a6ffcc88a8dad56c5ee204b1715240a1466e0",
635
+ "previousKeyHex": "09126b23a6148694280325e5a433c1badbc853026c8e71c0833ffd4bd0ea08ab",
636
+ "next": {
637
+ "epoch": 1,
638
+ "secretHex": "cc4e6941abd893daab2fdce0bbb182816b531fbc04b9531fdc5d03fd742fb26b"
639
+ },
640
+ "recipients": [
641
+ {
642
+ "device": "f29e30f72c9aaaa5066aa366afd4f0970e13853d10922cf5ba2ece21dc18ee5b",
643
+ "credential": {
644
+ "id": "3c3f64a769c826a89f2712ed9898e43afacc5f2784201999e8ca0ed5cdcf3d6c",
645
+ "pubkey": "3683caad0bf9bb5a653582155c0ba9192fd23d7d6c717a7b0d233c8356a7042f",
646
+ "created_at": 1800000000,
647
+ "kind": 20460,
648
+ "tags": [
649
+ [
650
+ "d",
651
+ "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c"
652
+ ],
653
+ [
654
+ "device",
655
+ "f29e30f72c9aaaa5066aa366afd4f0970e13853d10922cf5ba2ece21dc18ee5b"
656
+ ],
657
+ [
658
+ "expiration",
659
+ "1800043200"
660
+ ],
661
+ [
662
+ "seal",
663
+ "63a40c9bc84c065f4aab7e63208ea37c3edcd3ab47a334b157cf000409c7eaaf"
664
+ ]
665
+ ],
666
+ "content": "",
667
+ "sig": "fdd0c5f23d2f1531594100470d6fe6c87d6c28d2185898b49844b664ffcb65f3c76ce0b9fcef3166708baca068d7f9d2ee74a827f56140b6cef11591ce942049"
668
+ }
669
+ },
670
+ "1cddb17ae843752d2469ea50b2a6bd1468edbb75b7913ea58ba7847663cb1b8e"
671
+ ],
672
+ "deviceSkHex": "678d943bfdab0df34f3945be66ca867a4c15b507d7a9c125651f9018a905f305",
673
+ "bareDeviceSkHex": "32796464cb7434a5d397f03cce5055df28f9be8d15ce4bf14760d764bbd2e156",
674
+ "renewedSealSkHex": "7729de6b913094fc7fd121d12671448494184b9eff28633869a0c81567291035",
675
+ "copiedSealSkHex": "22b29538be86feaf0dbe57fcd046cc95ca39082e813d450d8d9d653a2434a374",
676
+ "createdAt": 1800000000,
677
+ "event": {
678
+ "id": "3f38d5f8a93cb0818f9a62fccc8ba59b44ebc413c1369205447f840e46bfb474",
679
+ "pubkey": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
680
+ "created_at": 1800000000,
681
+ "kind": 1462,
682
+ "tags": [
683
+ [
684
+ "d",
685
+ "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c"
686
+ ],
687
+ [
688
+ "epoch",
689
+ "1"
690
+ ],
691
+ [
692
+ "from",
693
+ "02f71d0f7235cbe6e623bc1542539c9db10601a3a892af483783614c00c7fd99"
694
+ ]
695
+ ],
696
+ "content": "AmcNeMrs/5cl6E/Z1PoCcQXOxzckQIRSjE9XOZJKvrsqFN0ZfMu/0hPX0dWFqdgAWRDZ5/K+9O3ajNsKlDmnx5zcAtlrzeJUTDGtN6YrwcorfL6NRMeSz9bnic0jAGAxo+Zj5n0FUV9qOzAkjKktseXy6y2oQeQ3gN2uPW1GXSOcaH++d3YwmiND6c48I8SlsNB5wRVawNb5Dm6UViC4h0wpbBR9sLqA08+p8IMFx2HxmENDbNNMfeQVcMhNGZ+XobZcsH4Zd0JNVxRVUGF0TOYCfaOQFAzO3LxfXI9tcC9vaisCA/DbGoGiz5nqOwU4xnsF5RgdX6yX+xIQXSzCtne69p/tdjePhsL8WmlENE131aSBdY/L7bUAumu+FJxfVJBzNCz+y/o0noBLirOrUuUIqWxFnQ/zoWsE+gYCU9Um9LvtbhK+m1+COhl6K09ruh/qlcZBn7OEciUywGZSkRadpm4kktc5cCfGbdrlkfVPwgrJiRVYLkIC+O+N+xqx037XLOtI3wC460QDnaoii207OrsdGip8F5K1sZ8ZV/Mc+gNmcCKAZJEGaoyowPvP4cYXMyos7j3MCZ0lpwi1E8jwyXYJZK17JKXmykEpBywPpYLSgBQR9C9yJ6oCfaVsxkOBVgbEiPKpA+hGngsiDmGBoy41VdgpA9ZCBTIH5W8B2z1xqCk+dHVkczJqYNpM+Dad0Xagq42rnK1kLff2bFsIKxly3ak1uFRYBCQbS74BEo2kbczOAn1XLfPYMLiEiMCdjXgILzVgqe4TVjue9Nv7Zak5iheUPqLVZfxTbuQUWIWrLakQWSVHfQ8c+/L98/5M9TsgYSIV5BS5JCABvbCVe4RHeq4ibECcsf41I2qPUeCSXxHuTvJ26L/T3ONpAEzXRw2+8tJNaK3Plczm5f9TfYPL3yuZGcplKsgPwogm0us=",
697
+ "sig": "a6e036a4b6ac94703f747337bf28c308a295fcfc2ab3c028d5a35cc50969ba19807a9ba14252ef7782e4b4bfd3395c71986eea1cdcdc36e2fe18a77d9ce1219d"
698
+ },
699
+ "randomHex": [
700
+ "a110c4cffaadabb2fedbf6279627cc857757f036ecaffdcb89452e2eb12acc8349c693f9cce385720b83f75629ae26e6",
701
+ "6d4bbae698b19d076387d818c6a9b8aaaa23ab42850bb933ab1ab0ab4a36ce91",
702
+ "a25e6555c2cbbf30733f70ba666453a3712cf5dbefd4c1cea47fa9b70b1b691a",
703
+ "670d78caecff9725e84fd9d4fa027105cec737244084528c4f5739924abebb2a",
704
+ "18c1fc0b6372dc662bcafd2d4704e5c1f4bcc9c61b1e129a91dec165a6296212"
705
+ ],
706
+ "senderSkHex": "4405467674110e0cb5278a657356cac70724f7442d2dc3ade9e350768088ff67"
707
+ },
708
+ "output": {
709
+ "sealSender": "02f71d0f7235cbe6e623bc1542539c9db10601a3a892af483783614c00c7fd99",
710
+ "renewedDevice": {
711
+ "epoch": 1,
712
+ "secretHex": "cc4e6941abd893daab2fdce0bbb182816b531fbc04b9531fdc5d03fd742fb26b"
713
+ },
714
+ "thief": {
715
+ "epoch": 1
716
+ },
717
+ "bareDevice": {
718
+ "epoch": 1,
719
+ "secretHex": "cc4e6941abd893daab2fdce0bbb182816b531fbc04b9531fdc5d03fd742fb26b"
720
+ },
721
+ "authorityOpens": {
722
+ "f29e30f72c9aaaa5066aa366afd4f0970e13853d10922cf5ba2ece21dc18ee5b": false,
723
+ "1cddb17ae843752d2469ea50b2a6bd1468edbb75b7913ea58ba7847663cb1b8e": false
724
+ }
725
+ }
726
+ },
727
+ {
728
+ "name": "rekey-from-doubled",
729
+ "kind": "negative",
730
+ "note": "rekey-one-time-sender's content re-signed by the authority with two `from` tags. Only the authority could sign it, so it is a broken writer, not an attack: `sealSender` is null, the rekey is still read for its epoch and removals, and no copy is opened (no secretHex); the device asks for the epoch.",
731
+ "input": {
732
+ "roomId": "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c",
733
+ "authority": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
734
+ "previousKeyHex": "09126b23a6148694280325e5a433c1badbc853026c8e71c0833ffd4bd0ea08ab",
735
+ "deviceSkHex": "678d943bfdab0df34f3945be66ca867a4c15b507d7a9c125651f9018a905f305",
736
+ "renewedSealSkHex": "7729de6b913094fc7fd121d12671448494184b9eff28633869a0c81567291035",
737
+ "event": {
738
+ "id": "fc8be7e8e1965a5888ca5351706456d76c8e548c3fc0a9378e0bb1caf4be23f6",
739
+ "pubkey": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
740
+ "created_at": 1800000000,
741
+ "kind": 1462,
742
+ "tags": [
743
+ [
744
+ "d",
745
+ "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c"
746
+ ],
747
+ [
748
+ "epoch",
749
+ "1"
750
+ ],
751
+ [
752
+ "from",
753
+ "02f71d0f7235cbe6e623bc1542539c9db10601a3a892af483783614c00c7fd99"
754
+ ],
755
+ [
756
+ "from",
757
+ "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533"
758
+ ]
759
+ ],
760
+ "content": "AmcNeMrs/5cl6E/Z1PoCcQXOxzckQIRSjE9XOZJKvrsqFN0ZfMu/0hPX0dWFqdgAWRDZ5/K+9O3ajNsKlDmnx5zcAtlrzeJUTDGtN6YrwcorfL6NRMeSz9bnic0jAGAxo+Zj5n0FUV9qOzAkjKktseXy6y2oQeQ3gN2uPW1GXSOcaH++d3YwmiND6c48I8SlsNB5wRVawNb5Dm6UViC4h0wpbBR9sLqA08+p8IMFx2HxmENDbNNMfeQVcMhNGZ+XobZcsH4Zd0JNVxRVUGF0TOYCfaOQFAzO3LxfXI9tcC9vaisCA/DbGoGiz5nqOwU4xnsF5RgdX6yX+xIQXSzCtne69p/tdjePhsL8WmlENE131aSBdY/L7bUAumu+FJxfVJBzNCz+y/o0noBLirOrUuUIqWxFnQ/zoWsE+gYCU9Um9LvtbhK+m1+COhl6K09ruh/qlcZBn7OEciUywGZSkRadpm4kktc5cCfGbdrlkfVPwgrJiRVYLkIC+O+N+xqx037XLOtI3wC460QDnaoii207OrsdGip8F5K1sZ8ZV/Mc+gNmcCKAZJEGaoyowPvP4cYXMyos7j3MCZ0lpwi1E8jwyXYJZK17JKXmykEpBywPpYLSgBQR9C9yJ6oCfaVsxkOBVgbEiPKpA+hGngsiDmGBoy41VdgpA9ZCBTIH5W8B2z1xqCk+dHVkczJqYNpM+Dad0Xagq42rnK1kLff2bFsIKxly3ak1uFRYBCQbS74BEo2kbczOAn1XLfPYMLiEiMCdjXgILzVgqe4TVjue9Nv7Zak5iheUPqLVZfxTbuQUWIWrLakQWSVHfQ8c+/L98/5M9TsgYSIV5BS5JCABvbCVe4RHeq4ibECcsf41I2qPUeCSXxHuTvJ26L/T3ONpAEzXRw2+8tJNaK3Plczm5f9TfYPL3yuZGcplKsgPwogm0us=",
761
+ "sig": "6f034c009f3eb02e069355d5a065f3aa72d1fe92dd5e6bc1bedbd905dfca08370fdc39b21470341397eafc2a5ad1c977c4658811aab3198937099ce844f0db37"
762
+ }
763
+ },
764
+ "output": {
765
+ "sealSender": null,
766
+ "renewedDevice": {
767
+ "epoch": 1
768
+ }
769
+ }
770
+ },
771
+ {
772
+ "name": "rekey-from-off-curve",
773
+ "kind": "negative",
774
+ "note": "The same with one `from` tag that is not the x coordinate of any point (x = 5): `sealSender` null, read without a secret.",
775
+ "input": {
776
+ "roomId": "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c",
777
+ "authority": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
778
+ "previousKeyHex": "09126b23a6148694280325e5a433c1badbc853026c8e71c0833ffd4bd0ea08ab",
779
+ "deviceSkHex": "678d943bfdab0df34f3945be66ca867a4c15b507d7a9c125651f9018a905f305",
780
+ "renewedSealSkHex": "7729de6b913094fc7fd121d12671448494184b9eff28633869a0c81567291035",
781
+ "event": {
782
+ "id": "3d0722b71fa5a04938936d6fd89ba718830a59c89ecb060931f442394e1702ab",
783
+ "pubkey": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
784
+ "created_at": 1800000000,
785
+ "kind": 1462,
786
+ "tags": [
787
+ [
788
+ "d",
789
+ "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c"
790
+ ],
791
+ [
792
+ "epoch",
793
+ "1"
794
+ ],
795
+ [
796
+ "from",
797
+ "0000000000000000000000000000000000000000000000000000000000000005"
798
+ ]
799
+ ],
800
+ "content": "AmcNeMrs/5cl6E/Z1PoCcQXOxzckQIRSjE9XOZJKvrsqFN0ZfMu/0hPX0dWFqdgAWRDZ5/K+9O3ajNsKlDmnx5zcAtlrzeJUTDGtN6YrwcorfL6NRMeSz9bnic0jAGAxo+Zj5n0FUV9qOzAkjKktseXy6y2oQeQ3gN2uPW1GXSOcaH++d3YwmiND6c48I8SlsNB5wRVawNb5Dm6UViC4h0wpbBR9sLqA08+p8IMFx2HxmENDbNNMfeQVcMhNGZ+XobZcsH4Zd0JNVxRVUGF0TOYCfaOQFAzO3LxfXI9tcC9vaisCA/DbGoGiz5nqOwU4xnsF5RgdX6yX+xIQXSzCtne69p/tdjePhsL8WmlENE131aSBdY/L7bUAumu+FJxfVJBzNCz+y/o0noBLirOrUuUIqWxFnQ/zoWsE+gYCU9Um9LvtbhK+m1+COhl6K09ruh/qlcZBn7OEciUywGZSkRadpm4kktc5cCfGbdrlkfVPwgrJiRVYLkIC+O+N+xqx037XLOtI3wC460QDnaoii207OrsdGip8F5K1sZ8ZV/Mc+gNmcCKAZJEGaoyowPvP4cYXMyos7j3MCZ0lpwi1E8jwyXYJZK17JKXmykEpBywPpYLSgBQR9C9yJ6oCfaVsxkOBVgbEiPKpA+hGngsiDmGBoy41VdgpA9ZCBTIH5W8B2z1xqCk+dHVkczJqYNpM+Dad0Xagq42rnK1kLff2bFsIKxly3ak1uFRYBCQbS74BEo2kbczOAn1XLfPYMLiEiMCdjXgILzVgqe4TVjue9Nv7Zak5iheUPqLVZfxTbuQUWIWrLakQWSVHfQ8c+/L98/5M9TsgYSIV5BS5JCABvbCVe4RHeq4ibECcsf41I2qPUeCSXxHuTvJ26L/T3ONpAEzXRw2+8tJNaK3Plczm5f9TfYPL3yuZGcplKsgPwogm0us=",
801
+ "sig": "eff2cc0225466dd6367fda5509d36fcb17e88b28039148b17870100ebe65b7658b005a18d0563ae01003ff88833d0716d01029b2e1b78456d63e19d27261cb24"
802
+ }
803
+ },
804
+ "output": {
805
+ "sealSender": null,
806
+ "renewedDevice": {
807
+ "epoch": 1
808
+ }
809
+ }
810
+ },
811
+ {
812
+ "name": "epoch-grant-one-time-sender",
813
+ "kind": "positive",
814
+ "note": "epoch-grant-seal's grant with `oneTimeSender`: tags d, p, then [\"from\", one-time pubkey], signed by the authority, content sealed from the one-time key to the renewed seal key. `randomHex`: the 48-byte seed for the one-time key (`senderSkHex`), body nonce, aux-rand. The renewed device opens it; the thief does not; the authority's key no longer opens it. A reader older than this cannot open it at all, which is why writers wait for readers.",
815
+ "input": {
816
+ "roomId": "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c",
817
+ "authority": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
818
+ "authoritySkHex": "bb058b43370d37b7ed3f09e3a36a6ffcc88a8dad56c5ee204b1715240a1466e0",
819
+ "deviceSkHex": "678d943bfdab0df34f3945be66ca867a4c15b507d7a9c125651f9018a905f305",
820
+ "now": 1800000000,
821
+ "request": "ef8f52180b25300214a75780fc12a89cb1d4ee1a88a78b2f8afb92605769e87a",
822
+ "credential": {
823
+ "id": "3c3f64a769c826a89f2712ed9898e43afacc5f2784201999e8ca0ed5cdcf3d6c",
824
+ "pubkey": "3683caad0bf9bb5a653582155c0ba9192fd23d7d6c717a7b0d233c8356a7042f",
825
+ "created_at": 1800000000,
826
+ "kind": 20460,
827
+ "tags": [
828
+ [
829
+ "d",
830
+ "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c"
831
+ ],
832
+ [
833
+ "device",
834
+ "f29e30f72c9aaaa5066aa366afd4f0970e13853d10922cf5ba2ece21dc18ee5b"
835
+ ],
836
+ [
837
+ "expiration",
838
+ "1800043200"
839
+ ],
840
+ [
841
+ "seal",
842
+ "63a40c9bc84c065f4aab7e63208ea37c3edcd3ab47a334b157cf000409c7eaaf"
843
+ ]
844
+ ],
845
+ "content": "",
846
+ "sig": "fdd0c5f23d2f1531594100470d6fe6c87d6c28d2185898b49844b664ffcb65f3c76ce0b9fcef3166708baca068d7f9d2ee74a827f56140b6cef11591ce942049"
847
+ },
848
+ "epoch": {
849
+ "epoch": 2,
850
+ "secretHex": "1173b8e8457a46fdb6ae949e37aa41ce259534d4bb4f15a5fdb74aefc83344f5"
851
+ },
852
+ "renewedSealSkHex": "7729de6b913094fc7fd121d12671448494184b9eff28633869a0c81567291035",
853
+ "copiedSealSkHex": "22b29538be86feaf0dbe57fcd046cc95ca39082e813d450d8d9d653a2434a374",
854
+ "event": {
855
+ "id": "38ad58597fa0c34a18e3975e755c27523b4c1cdf60d4a49aa89d2817f734dc76",
856
+ "pubkey": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
857
+ "created_at": 1800000000,
858
+ "kind": 20469,
859
+ "tags": [
860
+ [
861
+ "d",
862
+ "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c"
863
+ ],
864
+ [
865
+ "p",
866
+ "f29e30f72c9aaaa5066aa366afd4f0970e13853d10922cf5ba2ece21dc18ee5b"
867
+ ],
868
+ [
869
+ "from",
870
+ "0f44811c4dab721de6ee8e8d740b264fe8e106453fca7fe2a4543656cf10bb98"
871
+ ]
872
+ ],
873
+ "content": "Ar+D/3yOjgZRgyeW88MD9C17M37/kTTSSXfT5gTHvH8ecKBRUai4cGo/MMEiMBPN7b/YLj+HKOab20VjFGh1b6+CwWjoruSUpgvxNvZUcKiOF2lmZp6Awd2R46k55IJ8ShyGHQ674wyHyBs7J2GbZeEZZlsAPOztW0iCXEB4RzdUP4HrmyYbhKxY9g+8juxhT91aMIH7bmFN+kOCrOO3W+4gaj73tGRyB6OBvMKledoK1ypL+COkXqj4GzFUcDsG88fFOD6+JMnVoUDNhyobovbxRsj6B5wApTkr6Bj6nAr5+er5RTTEBAwmUuIc/V7qFzK76NUmYGunr6r5bZHaxLld3Q==",
874
+ "sig": "7992b0b101abb281e45c6066132e571a30ef8b02fcd0eb25f8764cf967c960d89f8d6def75e7bf91cf31d6af1efaa7b9d0171eaeba7482521afe9bc07d1fde17"
875
+ },
876
+ "randomHex": [
877
+ "5ac80b90d9396022bf334b58968aec74e7c8c72a7f62635b91a13924910c605a62efa5fac4e4ce2c169755acc6d1b49c",
878
+ "bf83ff7c8e8e0651832796f3c303f42d7b337eff9134d24977d3e604c7bc7f1e",
879
+ "a100e717ef4874d688fb51f37339cc25d5eddb8a17f3c554f939a7d3098ad8fa"
880
+ ],
881
+ "senderSkHex": "5b2583975fba6efa84bdfd6fa48d2422e1d31090140b71d4cadc19e4ed5ee25d"
882
+ },
883
+ "output": {
884
+ "sealSender": "0f44811c4dab721de6ee8e8d740b264fe8e106453fca7fe2a4543656cf10bb98",
885
+ "renewedDevice": {
886
+ "epoch": 2,
887
+ "secretHex": "1173b8e8457a46fdb6ae949e37aa41ce259534d4bb4f15a5fdb74aefc83344f5"
888
+ },
889
+ "thief": null,
890
+ "authorityOpens": false
891
+ }
892
+ },
893
+ {
894
+ "name": "epoch-grant-from-off-curve",
895
+ "kind": "negative",
896
+ "note": "epoch-grant-one-time-sender's content re-signed by the authority with a `from` tag off the curve: the grant is refused (null).",
897
+ "input": {
898
+ "roomId": "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c",
899
+ "authority": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
900
+ "deviceSkHex": "678d943bfdab0df34f3945be66ca867a4c15b507d7a9c125651f9018a905f305",
901
+ "now": 1800000000,
902
+ "request": "ef8f52180b25300214a75780fc12a89cb1d4ee1a88a78b2f8afb92605769e87a",
903
+ "renewedSealSkHex": "7729de6b913094fc7fd121d12671448494184b9eff28633869a0c81567291035",
904
+ "event": {
905
+ "id": "3185002094d21dd5f585b6e718c8587a5ab494235e73a5351f51ba0648af5db9",
906
+ "pubkey": "4fca1ba4b39e24ea0acea325af3e6a62315903952c277e197cccfe8f0143c533",
907
+ "created_at": 1800000000,
908
+ "kind": 20469,
909
+ "tags": [
910
+ [
911
+ "d",
912
+ "58c0b5a427b80559f5b67f35e74c686f630259dcdafca2ab03e3b082f4a1740c"
913
+ ],
914
+ [
915
+ "p",
916
+ "f29e30f72c9aaaa5066aa366afd4f0970e13853d10922cf5ba2ece21dc18ee5b"
917
+ ],
918
+ [
919
+ "from",
920
+ "0000000000000000000000000000000000000000000000000000000000000005"
921
+ ]
922
+ ],
923
+ "content": "Ar+D/3yOjgZRgyeW88MD9C17M37/kTTSSXfT5gTHvH8ecKBRUai4cGo/MMEiMBPN7b/YLj+HKOab20VjFGh1b6+CwWjoruSUpgvxNvZUcKiOF2lmZp6Awd2R46k55IJ8ShyGHQ674wyHyBs7J2GbZeEZZlsAPOztW0iCXEB4RzdUP4HrmyYbhKxY9g+8juxhT91aMIH7bmFN+kOCrOO3W+4gaj73tGRyB6OBvMKledoK1ypL+COkXqj4GzFUcDsG88fFOD6+JMnVoUDNhyobovbxRsj6B5wApTkr6Bj6nAr5+er5RTTEBAwmUuIc/V7qFzK76NUmYGunr6r5bZHaxLld3Q==",
924
+ "sig": "ba59c2a57832972de1b73a75724f6636fd2cea73e6a9040e05bc3ea022a10a5260d866d94c49dd16408237ea62a65e9bfb39cf332099a23f560ff7ca463eed02"
925
+ }
926
+ },
927
+ "output": {
928
+ "renewedDevice": null
929
+ }
626
930
  }
627
931
  ]
628
932
  }