@forgesworn/fold-kit 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ This kit is pre-1.0 (see AGENTS.md "Release Notes"); a behaviour change on
5
5
  byte-identical copy of a KithMoot function (see EXTRACTION.md) and this is
6
6
  the one place its behaviour has deliberately diverged.
7
7
 
8
+ ## 0.13.0
9
+
10
+ ### Added
11
+
12
+ - One-time senders for rekeys (1462) and authority grants (20469):
13
+ `oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
14
+ copy from a fresh key named in a `from` tag, so a later copy of the
15
+ authority's key no longer opens past copies. Off by default; off, events
16
+ are byte-identical to 0.12.0. Readers (`decodeRekeyEvent`,
17
+ `decodeEpochGrant`) open either form now, through the new `sealSender`
18
+ (also exported, with `SENDER_TAG`). Turn writers on only once these readers
19
+ have shipped everywhere. See docs/seal-key.md, "One-time senders"; vectors
20
+ in `seal-vectors.json`.
21
+
8
22
  ## 0.12.0
9
23
 
10
24
  ### Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@ vectors (see EXTRACTION.md). Framework-free: owns no storage, UI, relay
13
13
  defaults or identity keys, and no relay I/O of its own - callers inject a
14
14
  transport.
15
15
 
16
- **Status:** Version 0.12.0 on `main`. Published on npm as
16
+ **Status:** Version 0.13.0 on `main`. Published on npm as
17
17
  `@forgesworn/fold-kit`, but `main` can run ahead of the npm `latest` release, so
18
18
  compare `npm view @forgesworn/fold-kit version` with this repository's
19
19
  `package.json` before relying on an API listed here. KithMoot has cut over to
@@ -310,6 +310,11 @@ decision completes, so approval cannot extend an expired request's authority.
310
310
  `sealSks` on the readers; `credentialFor` on both desks;
311
311
  `credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
312
312
  `sealCredential`. Vectors: `vectors/seal-vectors.json`
313
+ - One-time senders (`docs/seal-key.md`, "One-time senders"):
314
+ `oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
315
+ copy from a fresh key named in a `from` tag, so a later copy of the
316
+ authority's key opens no past copy; readers use `sealSender`. Off by
317
+ default until every client's readers have shipped.
313
318
  - Scheduled rekeys and the history window (`docs/scheduled-rekey.md`):
314
319
  `encodeRekeyEvent({ scheduled: true })` marks a turn of the key that
315
320
  removes nobody, read back as `RekeyNotice.scheduled` and
package/dist/epoch.d.ts CHANGED
@@ -134,6 +134,13 @@ export interface EncodeRekeyOptions {
134
134
  /** A conference room's end, in unix seconds: the event carries it as a
135
135
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
136
136
  expiresAt?: number;
137
+ /** Seal the copies from a one-time key, named in a `from` tag and
138
+ * discarded once they are sealed, instead of from the authority's key.
139
+ * Readers from fold-kit 0.13.0 open either; an older reader opens no copy
140
+ * from a one-time key, so turn this on only once readers have shipped
141
+ * (docs/seal-key.md, "One-time senders"). Omitted, the event is
142
+ * byte-identical to before. */
143
+ oneTimeSender?: boolean;
137
144
  }
138
145
  /** A rekey recipient: a device, and the newest credential the sender holds
139
146
  * for it. */
@@ -330,6 +337,13 @@ export interface EncodeEpochGrantOptions {
330
337
  /** A conference room's end, in unix seconds: the event carries it as a
331
338
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
332
339
  expiresAt?: number;
340
+ /** Seal the copies from a one-time key, named in a `from` tag and
341
+ * discarded once they are sealed, instead of from the authority's key.
342
+ * Readers from fold-kit 0.13.0 open either; an older reader opens no copy
343
+ * from a one-time key, so turn this on only once readers have shipped
344
+ * (docs/seal-key.md, "One-time senders"). Omitted, the event is
345
+ * byte-identical to before. */
346
+ oneTimeSender?: boolean;
333
347
  }
334
348
  /** Answer one request: the current epoch sealed to the asking device, or a
335
349
  * refusal it alone can read. */
package/dist/epoch.js CHANGED
@@ -5,14 +5,14 @@ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
5
5
  import { schnorr } from '@noble/curves/secp256k1.js';
6
6
  import { base64urlnopad } from '@scure/base';
7
7
  import { nip44 } from 'nostr-tools';
8
- import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
8
+ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
9
9
  import { hexEquals, normaliseHex } from './hex.js';
10
10
  import { KINDS } from './kinds.js';
11
11
  import { deriveRoom } from './room.js';
12
12
  import { verifyDeviceCredential } from './credential.js';
13
13
  import { evaluateAccess } from './access.js';
14
14
  import { verifyEventUncached } from './verify.js';
15
- import { newerCredential, openSealed, sealTo } from './seal.js';
15
+ import { SENDER_TAG, newerCredential, openSealed, sealSender, sealTo } from './seal.js';
16
16
  import { withExpiration } from './expiration.js';
17
17
  import { epochCommitment } from './epoch-commit.js';
18
18
  import { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, RefusalLimiter, defaultReplySchedule, replyDelay } from './quiet-reply.js';
@@ -135,6 +135,14 @@ function memberListOf(members, removed) {
135
135
  out.delete(normaliseHex(p));
136
136
  return [...out].sort();
137
137
  }
138
+ /** The key a rekey or grant seals from: the authority's own, or, with
139
+ * `oneTimeSender`, a fresh one named in a `from` tag and zeroed once used. */
140
+ function oneTimeSenderOf(opts) {
141
+ if (!opts.oneTimeSender)
142
+ return { sk: opts.authoritySk, tags: [], discard: () => { } };
143
+ const sk = generateSecretKey();
144
+ return { sk, tags: [[SENDER_TAG, getPublicKey(sk)]], discard: () => sk.fill(0) };
145
+ }
138
146
  /** Announce the next epoch. See the module comment for what this is. */
139
147
  export function encodeRekeyEvent(opts) {
140
148
  require32(opts.authoritySk, 'authority secret key');
@@ -151,10 +159,16 @@ export function encodeRekeyEvent(opts) {
151
159
  const keys = {};
152
160
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
153
161
  const plaintext = JSON.stringify(sealed);
154
- for (const raw of opts.closed ? [] : opts.recipients) {
155
- const recipient = typeof raw === 'string' ? { device: raw } : raw;
156
- const device = requireHex32(recipient.device, 'recipient device');
157
- keys[device] = sealTo(plaintext, opts.authoritySk, device, recipient.credential);
162
+ const sender = oneTimeSenderOf(opts);
163
+ try {
164
+ for (const raw of opts.closed ? [] : opts.recipients) {
165
+ const recipient = typeof raw === 'string' ? { device: raw } : raw;
166
+ const device = requireHex32(recipient.device, 'recipient device');
167
+ keys[device] = sealTo(plaintext, sender.sk, device, recipient.credential);
168
+ }
169
+ }
170
+ finally {
171
+ sender.discard();
158
172
  }
159
173
  const body = {
160
174
  v: 1,
@@ -174,6 +188,7 @@ export function encodeRekeyEvent(opts) {
174
188
  tags: withExpiration([
175
189
  ['d', roomId],
176
190
  ['epoch', String(epoch)],
191
+ ...sender.tags,
177
192
  ], opts.expiresAt),
178
193
  content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
179
194
  }, opts.authoritySk);
@@ -239,13 +254,14 @@ export function decodeRekeyEvent(event, opts) {
239
254
  notice.members = members;
240
255
  const device = getPublicKey(opts.deviceSk);
241
256
  const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
242
- if (typeof mine === 'string') {
257
+ const sender = sealSender(event);
258
+ if (typeof mine === 'string' && sender !== null) {
243
259
  // A copy none of this device's keys opens is no copy: sealed to a
244
260
  // seal key it has since erased, or one it never had. The rest of the
245
261
  // rekey still says who was removed, so it is read without the secret,
246
262
  // and the device asks for the epoch.
247
263
  try {
248
- const sealed = JSON.parse(openSealed(mine, event.pubkey, opts.deviceSk, opts.sealSks));
264
+ const sealed = JSON.parse(openSealed(mine, sender, opts.deviceSk, opts.sealSks));
249
265
  if (sealed.v === 1 && typeof sealed.secret === 'string') {
250
266
  const secret = base64urlnopad.decode(sealed.secret);
251
267
  if (secret.length === 32)
@@ -506,14 +522,23 @@ export function encodeEpochGrant(opts) {
506
522
  if (opts.passed !== undefined && opts.passed.length > 0)
507
523
  body.passed = passedListOf(opts.passed, body.epoch);
508
524
  }
525
+ const sender = oneTimeSenderOf(opts);
526
+ let content;
527
+ try {
528
+ content = sealTo(JSON.stringify(body), sender.sk, device, opts.credential);
529
+ }
530
+ finally {
531
+ sender.discard();
532
+ }
509
533
  return finalizeEvent({
510
534
  kind: KINDS.EPOCH_GRANT,
511
535
  created_at: opts.now,
512
536
  tags: withExpiration([
513
537
  ['d', roomId],
514
538
  ['p', device],
539
+ ...sender.tags,
515
540
  ], opts.expiresAt),
516
- content: sealTo(JSON.stringify(body), opts.authoritySk, device, opts.credential),
541
+ content,
517
542
  }, opts.authoritySk);
518
543
  }
519
544
  /** Read the authority's answer to this device's own request. Null for
@@ -535,7 +560,10 @@ export function decodeEpochGrant(event, opts) {
535
560
  const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
536
561
  if (addressed === undefined || !hexEquals(addressed, device))
537
562
  return null;
538
- const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
563
+ const sender = sealSender(event);
564
+ if (sender === null)
565
+ return null;
566
+ const body = JSON.parse(openSealed(event.content, sender, opts.deviceSk, opts.sealSks));
539
567
  if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
540
568
  return null;
541
569
  if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof
7
7
  export type { RelayTransport } from './transport.js';
8
8
  export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
9
9
  export type { CreateCredentialOptions, VerifyResult } from './credential.js';
10
- export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
10
+ export { SEAL_TAG, SENDER_TAG, sealSender, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
11
11
  export type { SealKey } from './seal.js';
12
12
  export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
13
13
  export type { ScopedKeys } from './scoped.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { verifyEventUncached, boundedEventVerifier } from './verify.js';
5
5
  export { localIdentity } from './identity.js';
6
6
  export { KINDS } from './kinds.js';
7
7
  export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
8
- export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
8
+ export { SEAL_TAG, SENDER_TAG, sealSender, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
9
9
  export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
10
10
  export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
11
11
  export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
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)));
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` has no switch for it yet; adding one is part of turning
125
+ writers on, alongside the keeper's own rekeys.
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.13.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>"
@@ -139,7 +139,7 @@ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy
139
139
  - 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
140
  - 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
141
  - 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
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 - 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). Keep writers off until every client's readers have shipped; off, events are byte-identical to 0.12.0.
143
143
  - 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
144
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
145
145
  - `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.13.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",
@@ -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
  }