@forgesworn/fold-kit 0.6.0 → 0.7.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/README.md +8 -0
- package/dist/credential.d.ts +5 -0
- package/dist/credential.js +4 -0
- package/dist/epoch.d.ts +41 -2
- package/dist/epoch.js +69 -14
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/member-epoch.d.ts +13 -0
- package/dist/member-epoch.js +22 -9
- package/dist/seal.d.ts +77 -0
- package/dist/seal.js +128 -0
- package/llms.txt +1 -0
- package/package.json +3 -1
- package/vectors/seal-vectors.json +629 -0
package/dist/seal.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { schnorr } from '@noble/curves/secp256k1.js';
|
|
2
|
+
import { nip44 } from 'nostr-tools';
|
|
3
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
|
4
|
+
import { hexEquals, normaliseHex } from './hex.js';
|
|
5
|
+
/**
|
|
6
|
+
* Seal keys: how a device heals after its key is copied.
|
|
7
|
+
*
|
|
8
|
+
* A device key signs, and until now every rekey copy and every epoch grant
|
|
9
|
+
* was also sealed to it. Whoever copied it opened every one after, until
|
|
10
|
+
* somebody noticed and removed the device. Rotating a receiving key signed
|
|
11
|
+
* by the device key would heal nothing: the thief holds that key and signs
|
|
12
|
+
* their own rotation.
|
|
13
|
+
*
|
|
14
|
+
* So the receiving key rides in the device credential (kind 20460), which
|
|
15
|
+
* the participant signs. A device mints a fresh seal key at every renewal,
|
|
16
|
+
* and its participant's signer, not the device, binds it. Rekeys and grants
|
|
17
|
+
* are sealed to the seal key of the newest credential the sender holds for
|
|
18
|
+
* the device. A thief who copied the device once holds the seal keys minted
|
|
19
|
+
* up to then, and none minted after: once the copied credential has lapsed
|
|
20
|
+
* and the room has rekeyed, they are out.
|
|
21
|
+
*
|
|
22
|
+
* A device keeps its seal secrets as it keeps its device key, to open what
|
|
23
|
+
* was sealed to them when it reads the room again. Healing needs only that
|
|
24
|
+
* each new one is fresh; retiring old ones is a question of forward
|
|
25
|
+
* secrecy, not of healing.
|
|
26
|
+
*
|
|
27
|
+
* A credential with no `seal` tag is sealed to as before, to the device
|
|
28
|
+
* key, so a client that knows nothing of this keeps working, unhealed. See
|
|
29
|
+
* docs/seal-key.md.
|
|
30
|
+
*/
|
|
31
|
+
/** The credential tag that names a device's seal key. */
|
|
32
|
+
export const SEAL_TAG = 'seal';
|
|
33
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
34
|
+
/** True for an x-only secp256k1 public key: 32-byte hex that lifts to a
|
|
35
|
+
* point on the curve, so NIP-44 can agree a key with it. */
|
|
36
|
+
export function isSealPubkey(value) {
|
|
37
|
+
if (typeof value !== 'string' || !HEX64.test(value))
|
|
38
|
+
return false;
|
|
39
|
+
try {
|
|
40
|
+
schnorr.utils.lift_x(BigInt(`0x${value}`));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** A fresh seal key, for the next credential this device asks for. */
|
|
48
|
+
export function generateSealKey() {
|
|
49
|
+
const secretKey = generateSecretKey();
|
|
50
|
+
return { secretKey, pubkey: getPublicKey(secretKey) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The seal key a credential names. Undefined when it names none, and null
|
|
54
|
+
* when its `seal` tags are unusable: more than one, or one that is not a
|
|
55
|
+
* point on the curve. Only the participant could have signed either, so
|
|
56
|
+
* nothing here is an attack, but a sender must not throw on it mid-rekey:
|
|
57
|
+
* it treats null as none.
|
|
58
|
+
*/
|
|
59
|
+
export function credentialSeal(credential) {
|
|
60
|
+
const tags = credential.tags.filter((t) => t[0] === SEAL_TAG);
|
|
61
|
+
if (tags.length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (tags.length > 1)
|
|
64
|
+
return null;
|
|
65
|
+
const value = tags[0][1];
|
|
66
|
+
return isSealPubkey(value) ? normaliseHex(value) : null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Where to seal something for `device`: the seal key `credential` names, or
|
|
70
|
+
* the device key when it names none, or is not this device's.
|
|
71
|
+
*
|
|
72
|
+
* Expiry is deliberately not checked. A device that has gone quiet for
|
|
73
|
+
* longer than its credential kept that seal key, and opens the copy when it
|
|
74
|
+
* is back. Falling back to the device key instead would hand that copy to
|
|
75
|
+
* anybody holding a stolen device key, which is the one person healing
|
|
76
|
+
* exists to shut out.
|
|
77
|
+
*/
|
|
78
|
+
export function sealTarget(device, credential) {
|
|
79
|
+
const to = normaliseHex(device);
|
|
80
|
+
if (!credential)
|
|
81
|
+
return to;
|
|
82
|
+
const named = credential.tags.find((t) => t[0] === 'device')?.[1];
|
|
83
|
+
if (named === undefined || !hexEquals(named, to))
|
|
84
|
+
return to;
|
|
85
|
+
return credentialSeal(credential) ?? to;
|
|
86
|
+
}
|
|
87
|
+
/** Seal `plaintext` from `senderSk` to `device`, by `sealTarget`. */
|
|
88
|
+
export function sealTo(plaintext, senderSk, device, credential) {
|
|
89
|
+
return nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(senderSk, sealTarget(device, credential)));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Open something sealed to this device by `sender`: under each seal key in
|
|
93
|
+
* turn, then under the device key. Throws when none of them opens it.
|
|
94
|
+
* NIP-44's MAC verifies under the right key only, so a wrong one fails
|
|
95
|
+
* rather than producing garbage.
|
|
96
|
+
*/
|
|
97
|
+
export function openSealed(ciphertext, sender, deviceSk, sealSks = []) {
|
|
98
|
+
for (const sk of sealSks) {
|
|
99
|
+
try {
|
|
100
|
+
return nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(sk, sender));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Not this one: try the next, then the device key.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(deviceSk, sender));
|
|
107
|
+
}
|
|
108
|
+
function expirationOf(credential) {
|
|
109
|
+
const raw = credential.tags.find((t) => t[0] === 'expiration')?.[1];
|
|
110
|
+
const at = raw === undefined ? NaN : Number(raw);
|
|
111
|
+
return Number.isFinite(at) ? at : -Infinity;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The newer of two credentials: the later expiry, then the later
|
|
115
|
+
* `created_at`. Expiry first because some remote signers restamp
|
|
116
|
+
* `created_at` with their own clock (kithmoot#205), while the expiry is the
|
|
117
|
+
* device's own request, inside the signature either way. Ties keep `a`.
|
|
118
|
+
*
|
|
119
|
+
* Neither is checked here; compare only credentials already verified for
|
|
120
|
+
* the same device.
|
|
121
|
+
*/
|
|
122
|
+
export function newerCredential(a, b) {
|
|
123
|
+
const ea = expirationOf(a);
|
|
124
|
+
const eb = expirationOf(b);
|
|
125
|
+
if (eb !== ea)
|
|
126
|
+
return eb > ea ? b : a;
|
|
127
|
+
return b.created_at > a.created_at ? b : a;
|
|
128
|
+
}
|
package/llms.txt
CHANGED
|
@@ -81,6 +81,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
81
81
|
- `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
|
|
82
82
|
- 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)
|
|
83
83
|
- 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
|
|
84
|
+
- 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
|
|
84
85
|
- `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
|
|
85
86
|
- `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
|
|
86
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"vectors/kithmoot-vectors.json",
|
|
22
22
|
"vectors/circle-vectors.json",
|
|
23
23
|
"vectors/member-epoch-vectors.json",
|
|
24
|
+
"vectors/seal-vectors.json",
|
|
24
25
|
"LICENSE",
|
|
25
26
|
"README.md",
|
|
26
27
|
"llms.txt"
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"diff-source": "node scripts/diff-source.mjs",
|
|
34
35
|
"generate-fold": "node scripts/generate-fold.mjs",
|
|
35
36
|
"generate-member-epoch": "node scripts/generate-member-epoch.mjs",
|
|
37
|
+
"generate-seal": "node scripts/generate-seal.mjs",
|
|
36
38
|
"bundle-check": "node scripts/bundle-check.mjs",
|
|
37
39
|
"tarball-smoke": "node scripts/tarball-smoke.mjs",
|
|
38
40
|
"vectors": "vitest run vectors/",
|