@forgesworn/fold-kit 0.0.0-seed.0 → 0.2.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 +180 -4
- package/dist/access.d.ts +34 -0
- package/dist/access.js +102 -0
- package/dist/channel.d.ts +27 -0
- package/dist/channel.js +38 -0
- package/dist/credential.d.ts +74 -0
- package/dist/credential.js +177 -0
- package/dist/display-name.d.ts +38 -0
- package/dist/display-name.js +78 -0
- package/dist/epoch.d.ts +387 -0
- package/dist/epoch.js +665 -0
- package/dist/hex.d.ts +28 -0
- package/dist/hex.js +32 -0
- package/dist/identity.d.ts +49 -0
- package/dist/identity.js +17 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +19 -0
- package/dist/invitation.d.ts +217 -0
- package/dist/invitation.js +484 -0
- package/dist/kinds.d.ts +64 -0
- package/dist/kinds.js +64 -0
- package/dist/lane.d.ts +41 -0
- package/dist/lane.js +63 -0
- package/dist/link.d.ts +52 -0
- package/dist/link.js +107 -0
- package/dist/network-hints.d.ts +16 -0
- package/dist/network-hints.js +65 -0
- package/dist/persistent-invitation.d.ts +35 -0
- package/dist/persistent-invitation.js +118 -0
- package/dist/room.d.ts +53 -0
- package/dist/room.js +153 -0
- package/dist/scoped.d.ts +67 -0
- package/dist/scoped.js +83 -0
- package/dist/sub-cert.d.ts +117 -0
- package/dist/sub-cert.js +192 -0
- package/dist/transport.d.ts +24 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.js +46 -0
- package/llms.txt +106 -0
- package/package.json +77 -6
- package/vectors/circle-vectors.json +2174 -0
- package/vectors/kithmoot-vectors.json +1550 -0
package/dist/scoped.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EpochKeys } from './epoch.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-app scoped labels, derived from an epoch key (kit T3.1).
|
|
4
|
+
*
|
|
5
|
+
* A consuming app (a board tool, a task list, anything that rides a
|
|
6
|
+
* KithMoot-derived circle) needs its own payload keys and stream ids that
|
|
7
|
+
* live under the same epoch as the roster and the chat, without ever
|
|
8
|
+
* colliding with a KithMoot channel key or being mistaken for one. Sharing
|
|
9
|
+
* one epoch, rather than deriving a second independent secret, is what lets
|
|
10
|
+
* a board and a room use the same link and the same removal: both derive
|
|
11
|
+
* from the same epoch key, so both move together on a rekey.
|
|
12
|
+
*
|
|
13
|
+
* `deriveScoped` is deliberately NOT `deriveChannel`: a channel name is a
|
|
14
|
+
* KithMoot registry concept, signed off by the room's authority
|
|
15
|
+
* (`epoch.ts`'s `signChannels`) and rendered to members as a place to chat.
|
|
16
|
+
* An app's own scoped keys are neither: nobody signs off on them, and they
|
|
17
|
+
* are never shown as a channel. Reusing `deriveChannel` for them would let
|
|
18
|
+
* an app-chosen string collide with a real channel name, or a channel name
|
|
19
|
+
* collide with an app's payload key, so this is its own derivation under
|
|
20
|
+
* its own info strings.
|
|
21
|
+
*
|
|
22
|
+
* `label` MUST NOT start with this kit's own protocol namespace (see
|
|
23
|
+
* `KITHMOOT_NAMESPACE_PREFIX` below) - that namespace belongs to this kit's
|
|
24
|
+
* own derivations (`EPOCH_ID_INFO`, `CHANNEL_ID_INFO`, and so on), and
|
|
25
|
+
* refusing the prefix here means no app label can ever be mistaken for one
|
|
26
|
+
* of this kit's own, whatever an app supplies. `label` MUST match
|
|
27
|
+
* `^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\x21-\x7e]{1,200}$`: a lower-case app
|
|
28
|
+
* namespace of 1 to 64 bytes, a version segment of 1 to 9 digits (an app's
|
|
29
|
+
* label shape is itself something it may need to change - this pins wire
|
|
30
|
+
* compatibility label by label, not by fiat), then 1 to 200 printable-ASCII
|
|
31
|
+
* bytes naming what the label is for. The namespace and version bounds are
|
|
32
|
+
* deliberately tight - an app label is always a short, fixed, hand-written
|
|
33
|
+
* string, never derived from untrusted input - so an unbounded namespace or
|
|
34
|
+
* version segment (as an earlier revision allowed) is refused rather than
|
|
35
|
+
* accepted and merely hashed away: nothing here needs to parse a namespace
|
|
36
|
+
* or version number back out of a label, but a caller building one by
|
|
37
|
+
* concatenation from attacker-influenced text should get a small, cheap
|
|
38
|
+
* refusal rather than an expensive regex match over untrusted length.
|
|
39
|
+
*
|
|
40
|
+
* `ikm` is the epoch KEY, never the epoch id: publishing a scoped id (which
|
|
41
|
+
* a relay necessarily sees as a `d` tag) must reveal nothing about any
|
|
42
|
+
* other scoped key derived from the same epoch, matching `deriveRoom` and
|
|
43
|
+
* `deriveChannel`'s own reasoning for deriving from the room/channel KEY.
|
|
44
|
+
* No salt, matching `deriveEpoch` and `deriveChannel`.
|
|
45
|
+
*/
|
|
46
|
+
export declare const SCOPED_LABEL_PATTERN: RegExp;
|
|
47
|
+
/** A derived id and key for one app-defined purpose under one epoch. */
|
|
48
|
+
export interface ScopedKeys {
|
|
49
|
+
/** Lower-case hex, 32 bytes. Suitable as a `d` tag or any other public
|
|
50
|
+
* identifier a relay is expected to see. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** 32 bytes. A NIP-44 conversation key, an envelope key, an HKDF salt for
|
|
53
|
+
* a further derivation - whatever the label's owner needs a secret for. */
|
|
54
|
+
key: Uint8Array;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `{ id, key } = HKDF-SHA256(ikm = epoch.key, info = label + "/id" | label +
|
|
58
|
+
* "/key", 32)`, no salt.
|
|
59
|
+
*
|
|
60
|
+
* Refuses a label in this kit's own protocol namespace, and any label that
|
|
61
|
+
* does not match `SCOPED_LABEL_PATTERN`. Two different labels always derive
|
|
62
|
+
* to two different (id, key) pairs (barring an HKDF collision); the same
|
|
63
|
+
* label under two different epoch keys always derives to two different
|
|
64
|
+
* pairs too, which is what lets a rekey rotate every scoped key an app
|
|
65
|
+
* derived from the old epoch.
|
|
66
|
+
*/
|
|
67
|
+
export declare function deriveScoped(epoch: EpochKeys, label: string): ScopedKeys;
|
package/dist/scoped.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
/**
|
|
4
|
+
* Per-app scoped labels, derived from an epoch key (kit T3.1).
|
|
5
|
+
*
|
|
6
|
+
* A consuming app (a board tool, a task list, anything that rides a
|
|
7
|
+
* KithMoot-derived circle) needs its own payload keys and stream ids that
|
|
8
|
+
* live under the same epoch as the roster and the chat, without ever
|
|
9
|
+
* colliding with a KithMoot channel key or being mistaken for one. Sharing
|
|
10
|
+
* one epoch, rather than deriving a second independent secret, is what lets
|
|
11
|
+
* a board and a room use the same link and the same removal: both derive
|
|
12
|
+
* from the same epoch key, so both move together on a rekey.
|
|
13
|
+
*
|
|
14
|
+
* `deriveScoped` is deliberately NOT `deriveChannel`: a channel name is a
|
|
15
|
+
* KithMoot registry concept, signed off by the room's authority
|
|
16
|
+
* (`epoch.ts`'s `signChannels`) and rendered to members as a place to chat.
|
|
17
|
+
* An app's own scoped keys are neither: nobody signs off on them, and they
|
|
18
|
+
* are never shown as a channel. Reusing `deriveChannel` for them would let
|
|
19
|
+
* an app-chosen string collide with a real channel name, or a channel name
|
|
20
|
+
* collide with an app's payload key, so this is its own derivation under
|
|
21
|
+
* its own info strings.
|
|
22
|
+
*
|
|
23
|
+
* `label` MUST NOT start with this kit's own protocol namespace (see
|
|
24
|
+
* `KITHMOOT_NAMESPACE_PREFIX` below) - that namespace belongs to this kit's
|
|
25
|
+
* own derivations (`EPOCH_ID_INFO`, `CHANNEL_ID_INFO`, and so on), and
|
|
26
|
+
* refusing the prefix here means no app label can ever be mistaken for one
|
|
27
|
+
* of this kit's own, whatever an app supplies. `label` MUST match
|
|
28
|
+
* `^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\x21-\x7e]{1,200}$`: a lower-case app
|
|
29
|
+
* namespace of 1 to 64 bytes, a version segment of 1 to 9 digits (an app's
|
|
30
|
+
* label shape is itself something it may need to change - this pins wire
|
|
31
|
+
* compatibility label by label, not by fiat), then 1 to 200 printable-ASCII
|
|
32
|
+
* bytes naming what the label is for. The namespace and version bounds are
|
|
33
|
+
* deliberately tight - an app label is always a short, fixed, hand-written
|
|
34
|
+
* string, never derived from untrusted input - so an unbounded namespace or
|
|
35
|
+
* version segment (as an earlier revision allowed) is refused rather than
|
|
36
|
+
* accepted and merely hashed away: nothing here needs to parse a namespace
|
|
37
|
+
* or version number back out of a label, but a caller building one by
|
|
38
|
+
* concatenation from attacker-influenced text should get a small, cheap
|
|
39
|
+
* refusal rather than an expensive regex match over untrusted length.
|
|
40
|
+
*
|
|
41
|
+
* `ikm` is the epoch KEY, never the epoch id: publishing a scoped id (which
|
|
42
|
+
* a relay necessarily sees as a `d` tag) must reveal nothing about any
|
|
43
|
+
* other scoped key derived from the same epoch, matching `deriveRoom` and
|
|
44
|
+
* `deriveChannel`'s own reasoning for deriving from the room/channel KEY.
|
|
45
|
+
* No salt, matching `deriveEpoch` and `deriveChannel`.
|
|
46
|
+
*/
|
|
47
|
+
export const SCOPED_LABEL_PATTERN = /^[a-z0-9-]{1,64}\/v[0-9]{1,9}\/[\x21-\x7e]{1,200}$/;
|
|
48
|
+
/** See the comment at its one use site in `deriveScoped`. */
|
|
49
|
+
const KITHMOOT_NAMESPACE_PREFIX = 'kithmoot' + '/';
|
|
50
|
+
function hex(bytes) {
|
|
51
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `{ id, key } = HKDF-SHA256(ikm = epoch.key, info = label + "/id" | label +
|
|
55
|
+
* "/key", 32)`, no salt.
|
|
56
|
+
*
|
|
57
|
+
* Refuses a label in this kit's own protocol namespace, and any label that
|
|
58
|
+
* does not match `SCOPED_LABEL_PATTERN`. Two different labels always derive
|
|
59
|
+
* to two different (id, key) pairs (barring an HKDF collision); the same
|
|
60
|
+
* label under two different epoch keys always derives to two different
|
|
61
|
+
* pairs too, which is what lets a rekey rotate every scoped key an app
|
|
62
|
+
* derived from the old epoch.
|
|
63
|
+
*/
|
|
64
|
+
export function deriveScoped(epoch, label) {
|
|
65
|
+
// Built by concatenation, deliberately not as one string literal: this
|
|
66
|
+
// kit's `src/labels.test.ts` scans src/*.ts for every literal wire-format
|
|
67
|
+
// label this kit's own protocol owns, and freezes the exact set found.
|
|
68
|
+
// This check needs the same prefix at runtime, but it names that
|
|
69
|
+
// namespace in order to refuse it, rather than adding a new label of its
|
|
70
|
+
// own - so it is assembled at call time instead of appearing in the
|
|
71
|
+
// source as a scannable literal that test would otherwise pick up.
|
|
72
|
+
if (label.startsWith(KITHMOOT_NAMESPACE_PREFIX)) {
|
|
73
|
+
throw new Error('a scoped label may not start with this kit\'s own protocol namespace');
|
|
74
|
+
}
|
|
75
|
+
if (!SCOPED_LABEL_PATTERN.test(label)) {
|
|
76
|
+
throw new Error('scoped label must match ^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\\x21-\\x7e]{1,200}$');
|
|
77
|
+
}
|
|
78
|
+
if (epoch.key.length !== 32)
|
|
79
|
+
throw new Error('epoch key must be 32 bytes');
|
|
80
|
+
const id = hex(hkdf(sha256, epoch.key, undefined, `${label}/id`, 32));
|
|
81
|
+
const key = hkdf(sha256, epoch.key, undefined, `${label}/key`, 32);
|
|
82
|
+
return { id, key };
|
|
83
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ParticipantIdentity } from './identity.js';
|
|
2
|
+
import type { DeviceCredential } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Sub-key certificates (kit T3.3).
|
|
5
|
+
*
|
|
6
|
+
* A device credential (`credential.ts`) says a device may act for a
|
|
7
|
+
* participant, in one room or everywhere. An app that signs its own traffic
|
|
8
|
+
* under a further per-scope key - a board's per-epoch signing key, say,
|
|
9
|
+
* derived from the epoch key and never held anywhere but derived on demand
|
|
10
|
+
* (see `scoped.ts`) - needs a second, narrower statement: that THIS
|
|
11
|
+
* credentialled device is the one that minted THIS particular sub-key, for
|
|
12
|
+
* THIS particular scoped id. Without it, a relay (or a receiver) has no way
|
|
13
|
+
* to tell a sub-key's traffic apart from a stranger who also derived a key
|
|
14
|
+
* and started signing with it - the sub-key is not itself proof of anything.
|
|
15
|
+
*
|
|
16
|
+
* The certificate is never published on its own: it rides inside a signed
|
|
17
|
+
* message's ciphertext, alongside the device credential it depends on, so a
|
|
18
|
+
* relay only ever sees the sub-key's signature on outer events and never
|
|
19
|
+
* learns which credentialled device stands behind it.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately its own kind-20460 event with `scope: "sub"`, not a variant
|
|
22
|
+
* `verifyDeviceCredential` accepts: `verifyDeviceCredential` already refuses
|
|
23
|
+
* any `scope` it does not recognise (`credential.ts`, "unknown scope"), so a
|
|
24
|
+
* sub-key certificate can never be mistaken for a room or person credential
|
|
25
|
+
* by the existing verifier, and does not need that function to change at
|
|
26
|
+
* all. Its `device` tag also means something different here - the sub-key
|
|
27
|
+
* being certified, not a device being authorised - so folding it into
|
|
28
|
+
* `verifyDeviceCredential`'s shape would only invite the two to be confused.
|
|
29
|
+
*
|
|
30
|
+
* Exactly four tags, in this fixed order, each exactly `[name, value]`: `d`
|
|
31
|
+
* (the scoped signer id this certificate is for), `device` (the sub-key
|
|
32
|
+
* pubkey), `expiration` (MUST equal the accompanying device credential's
|
|
33
|
+
* `expiration`, compared as the exact tag string - a sub-key certificate
|
|
34
|
+
* never outlives the credential that backs it, and never gets its own
|
|
35
|
+
* independent expiry a caller could set longer by mistake, nor a
|
|
36
|
+
* differently-formatted but numerically-equal one), `scope` (always
|
|
37
|
+
* `"sub"`). `content` MUST be the empty string, matching `credential.ts`'s
|
|
38
|
+
* own convention.
|
|
39
|
+
*/
|
|
40
|
+
export declare const SUB_KEY_CERTIFICATE_SCOPE = "sub";
|
|
41
|
+
export interface CreateSubKeyCertificateOptions {
|
|
42
|
+
/** The credentialled device: the same key a device credential names in
|
|
43
|
+
* its `device` tag. Signs the certificate. */
|
|
44
|
+
identity: ParticipantIdentity;
|
|
45
|
+
/** The scoped id this certificate is for - typically
|
|
46
|
+
* `deriveScoped(epoch, '<app>/v1/.../signer').id`. Must be 32-byte hex. */
|
|
47
|
+
signerId: string;
|
|
48
|
+
/** The sub-key pubkey being certified. Must be 32-byte hex (x-only). */
|
|
49
|
+
subKeyPubkey: string;
|
|
50
|
+
/** MUST equal the accompanying device credential's `expiration` exactly.
|
|
51
|
+
* A safe integer, strictly in the future of `now` (or the real clock). */
|
|
52
|
+
expiresAt: number;
|
|
53
|
+
/** Injectable clock, in unix seconds. Defaults to the real one. */
|
|
54
|
+
now?: () => number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Mint a sub-key certificate. Asynchronous for the same reason
|
|
58
|
+
* `createDeviceCredential` is: the identity may be a remote signer.
|
|
59
|
+
*
|
|
60
|
+
* Validates its own inputs before asking the signer to sign anything: a
|
|
61
|
+
* bad `signerId`/`subKeyPubkey` shape or a non-future `expiresAt` is a
|
|
62
|
+
* caller bug, not something worth turning into a certificate a verifier
|
|
63
|
+
* would refuse for a more confusing reason downstream.
|
|
64
|
+
*
|
|
65
|
+
* Checks what the signer handed back against what was asked for, exactly as
|
|
66
|
+
* `createDeviceCredential` does, so a signer that stamps its own fields (a
|
|
67
|
+
* different `created_at` is fine and deliberately not compared; anything
|
|
68
|
+
* else is not) cannot silently hand back a certificate for something else.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createSubKeyCertificate(opts: CreateSubKeyCertificateOptions): Promise<DeviceCredential>;
|
|
71
|
+
export type VerifySubKeyCertificateResult = {
|
|
72
|
+
ok: true;
|
|
73
|
+
device: string;
|
|
74
|
+
subKeyPubkey: string;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
reason: string;
|
|
78
|
+
};
|
|
79
|
+
export interface VerifySubKeyCertificateOptions {
|
|
80
|
+
/** The scoped signer id the certificate must name in its `d` tag. */
|
|
81
|
+
signerId: string;
|
|
82
|
+
/**
|
|
83
|
+
* The device credential this certificate depends on, ALREADY VERIFIED by
|
|
84
|
+
* the caller (a successful `verifyDeviceCredential` call). This function
|
|
85
|
+
* reads that credential's `device` and `expiration` tags itself, rather
|
|
86
|
+
* than trusting a caller to have copied them out correctly: the
|
|
87
|
+
* certificate's signer (`cert.pubkey`) must equal the credential's
|
|
88
|
+
* `device` tag exactly, and the certificate's `expiration` tag must equal
|
|
89
|
+
* the credential's `expiration` tag as the EXACT SAME STRING (not merely
|
|
90
|
+
* the same number) - so a certificate cannot outlive, or be reissued
|
|
91
|
+
* under a differently-formatted equivalent of, the credential backing it.
|
|
92
|
+
*/
|
|
93
|
+
credential: DeviceCredential;
|
|
94
|
+
/** The pubkey that signed the message this certificate rode in on - the
|
|
95
|
+
* sub-key. The certificate's `device` tag must equal it. */
|
|
96
|
+
subKeyPubkey: string;
|
|
97
|
+
now: number;
|
|
98
|
+
/** The containing message's sign time: the certificate's own `created_at`
|
|
99
|
+
* must not be later than `at + skewSeconds`. Omit `at` entirely to skip
|
|
100
|
+
* this bound (a caller with no message context yet, such as a vector,
|
|
101
|
+
* may not have one). Giving `at` without `skewSeconds` does NOT relax
|
|
102
|
+
* the bound to unlimited skew - `skewSeconds` then defaults to `0`, a
|
|
103
|
+
* certificate must not be minted after the message at all. */
|
|
104
|
+
at?: number;
|
|
105
|
+
skewSeconds?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Verify a sub-key certificate: that the credentialled device named by
|
|
109
|
+
* `credential` certified `subKeyPubkey` as its sub-key for `signerId`, with
|
|
110
|
+
* an expiry that matches `credential` exactly and a mint time no later than
|
|
111
|
+
* the message it rode in on.
|
|
112
|
+
*
|
|
113
|
+
* Every check below is cheap-first, strict and canonical, and never throws,
|
|
114
|
+
* matching `verifyDeviceCredential`; the signature is checked last, since it
|
|
115
|
+
* is the most expensive and every tag check above invalidates it anyway.
|
|
116
|
+
*/
|
|
117
|
+
export declare function verifySubKeyCertificate(cert: DeviceCredential, opts: VerifySubKeyCertificateOptions): VerifySubKeyCertificateResult;
|
package/dist/sub-cert.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { KINDS } from './kinds.js';
|
|
2
|
+
import { verifyEventUncached } from './verify.js';
|
|
3
|
+
import { hexEquals, normaliseHex } from './hex.js';
|
|
4
|
+
/**
|
|
5
|
+
* Sub-key certificates (kit T3.3).
|
|
6
|
+
*
|
|
7
|
+
* A device credential (`credential.ts`) says a device may act for a
|
|
8
|
+
* participant, in one room or everywhere. An app that signs its own traffic
|
|
9
|
+
* under a further per-scope key - a board's per-epoch signing key, say,
|
|
10
|
+
* derived from the epoch key and never held anywhere but derived on demand
|
|
11
|
+
* (see `scoped.ts`) - needs a second, narrower statement: that THIS
|
|
12
|
+
* credentialled device is the one that minted THIS particular sub-key, for
|
|
13
|
+
* THIS particular scoped id. Without it, a relay (or a receiver) has no way
|
|
14
|
+
* to tell a sub-key's traffic apart from a stranger who also derived a key
|
|
15
|
+
* and started signing with it - the sub-key is not itself proof of anything.
|
|
16
|
+
*
|
|
17
|
+
* The certificate is never published on its own: it rides inside a signed
|
|
18
|
+
* message's ciphertext, alongside the device credential it depends on, so a
|
|
19
|
+
* relay only ever sees the sub-key's signature on outer events and never
|
|
20
|
+
* learns which credentialled device stands behind it.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately its own kind-20460 event with `scope: "sub"`, not a variant
|
|
23
|
+
* `verifyDeviceCredential` accepts: `verifyDeviceCredential` already refuses
|
|
24
|
+
* any `scope` it does not recognise (`credential.ts`, "unknown scope"), so a
|
|
25
|
+
* sub-key certificate can never be mistaken for a room or person credential
|
|
26
|
+
* by the existing verifier, and does not need that function to change at
|
|
27
|
+
* all. Its `device` tag also means something different here - the sub-key
|
|
28
|
+
* being certified, not a device being authorised - so folding it into
|
|
29
|
+
* `verifyDeviceCredential`'s shape would only invite the two to be confused.
|
|
30
|
+
*
|
|
31
|
+
* Exactly four tags, in this fixed order, each exactly `[name, value]`: `d`
|
|
32
|
+
* (the scoped signer id this certificate is for), `device` (the sub-key
|
|
33
|
+
* pubkey), `expiration` (MUST equal the accompanying device credential's
|
|
34
|
+
* `expiration`, compared as the exact tag string - a sub-key certificate
|
|
35
|
+
* never outlives the credential that backs it, and never gets its own
|
|
36
|
+
* independent expiry a caller could set longer by mistake, nor a
|
|
37
|
+
* differently-formatted but numerically-equal one), `scope` (always
|
|
38
|
+
* `"sub"`). `content` MUST be the empty string, matching `credential.ts`'s
|
|
39
|
+
* own convention.
|
|
40
|
+
*/
|
|
41
|
+
export const SUB_KEY_CERTIFICATE_SCOPE = 'sub';
|
|
42
|
+
/** Lower-case or upper-case 32-byte hex - the shape of every pubkey and
|
|
43
|
+
* scoped id this module handles. Matches `epoch.ts`'s own `HEX64`. */
|
|
44
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
45
|
+
/** A canonical decimal unix timestamp: no leading/trailing whitespace, no
|
|
46
|
+
* sign, no `0x` prefix, no fraction, and no leading zeros (other than the
|
|
47
|
+
* single digit `"0"` itself, which is not a legal expiry here since it
|
|
48
|
+
* refuses at "expired" but is excluded from ambiguity regardless). Matches
|
|
49
|
+
* `expiresAt` values `credential.ts` itself would produce with `String()`
|
|
50
|
+
* on a safe non-negative integer. */
|
|
51
|
+
const CANONICAL_DECIMAL = /^(0|[1-9][0-9]*)$/;
|
|
52
|
+
function requireHex64(value, what) {
|
|
53
|
+
if (!HEX64.test(value))
|
|
54
|
+
throw new Error(`${what} must be 32-byte hex`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Mint a sub-key certificate. Asynchronous for the same reason
|
|
58
|
+
* `createDeviceCredential` is: the identity may be a remote signer.
|
|
59
|
+
*
|
|
60
|
+
* Validates its own inputs before asking the signer to sign anything: a
|
|
61
|
+
* bad `signerId`/`subKeyPubkey` shape or a non-future `expiresAt` is a
|
|
62
|
+
* caller bug, not something worth turning into a certificate a verifier
|
|
63
|
+
* would refuse for a more confusing reason downstream.
|
|
64
|
+
*
|
|
65
|
+
* Checks what the signer handed back against what was asked for, exactly as
|
|
66
|
+
* `createDeviceCredential` does, so a signer that stamps its own fields (a
|
|
67
|
+
* different `created_at` is fine and deliberately not compared; anything
|
|
68
|
+
* else is not) cannot silently hand back a certificate for something else.
|
|
69
|
+
*/
|
|
70
|
+
export async function createSubKeyCertificate(opts) {
|
|
71
|
+
requireHex64(opts.signerId, 'signerId');
|
|
72
|
+
requireHex64(opts.subKeyPubkey, 'subKeyPubkey');
|
|
73
|
+
const now = (opts.now ?? (() => Math.floor(Date.now() / 1000)))();
|
|
74
|
+
if (!Number.isSafeInteger(opts.expiresAt) || opts.expiresAt <= now) {
|
|
75
|
+
throw new Error('expiresAt must be a safe integer unix timestamp in the future');
|
|
76
|
+
}
|
|
77
|
+
const unsigned = {
|
|
78
|
+
kind: KINDS.CREDENTIAL,
|
|
79
|
+
created_at: now,
|
|
80
|
+
tags: [
|
|
81
|
+
['d', opts.signerId],
|
|
82
|
+
['device', opts.subKeyPubkey],
|
|
83
|
+
['expiration', String(opts.expiresAt)],
|
|
84
|
+
['scope', SUB_KEY_CERTIFICATE_SCOPE],
|
|
85
|
+
],
|
|
86
|
+
content: '',
|
|
87
|
+
};
|
|
88
|
+
const signed = await opts.identity.signEvent(unsigned);
|
|
89
|
+
if (!hexEquals(signed.pubkey, opts.identity.pubkey)) {
|
|
90
|
+
throw new Error('the signer returned a certificate signed by a different key');
|
|
91
|
+
}
|
|
92
|
+
if (signed.kind !== unsigned.kind || signed.content !== unsigned.content) {
|
|
93
|
+
throw new Error('the signer returned a certificate for something else');
|
|
94
|
+
}
|
|
95
|
+
if (JSON.stringify(signed.tags) !== JSON.stringify(unsigned.tags)) {
|
|
96
|
+
throw new Error('the signer returned a certificate on different terms than it was asked for');
|
|
97
|
+
}
|
|
98
|
+
if (!verifyEventUncached(signed)) {
|
|
99
|
+
throw new Error('the signer returned a certificate that does not verify');
|
|
100
|
+
}
|
|
101
|
+
return signed;
|
|
102
|
+
}
|
|
103
|
+
/** Verified sub-key certificates, keyed on the whole signed event exactly as
|
|
104
|
+
* `credential.ts`'s own bounded cache is: a Schnorr signature is
|
|
105
|
+
* randomised, so the identical certificate can arrive carrying two
|
|
106
|
+
* different valid signatures, and an id-keyed cache would refuse the
|
|
107
|
+
* second. Kept separate from `credential.ts`'s cache because a sub-key
|
|
108
|
+
* certificate and a device credential are verified under different rules
|
|
109
|
+
* and neither should be able to poison the other's cache. */
|
|
110
|
+
const verifiedSubKeyCertificates = new Set();
|
|
111
|
+
function certificateSignature(cert) {
|
|
112
|
+
const whole = JSON.stringify([cert.id, cert.pubkey, cert.created_at, cert.kind, cert.tags, cert.content, cert.sig]);
|
|
113
|
+
if (verifiedSubKeyCertificates.has(whole))
|
|
114
|
+
return true;
|
|
115
|
+
if (!verifyEventUncached(cert))
|
|
116
|
+
return false;
|
|
117
|
+
verifiedSubKeyCertificates.add(whole);
|
|
118
|
+
if (verifiedSubKeyCertificates.size > 4_096)
|
|
119
|
+
verifiedSubKeyCertificates.delete(verifiedSubKeyCertificates.values().next().value);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Verify a sub-key certificate: that the credentialled device named by
|
|
124
|
+
* `credential` certified `subKeyPubkey` as its sub-key for `signerId`, with
|
|
125
|
+
* an expiry that matches `credential` exactly and a mint time no later than
|
|
126
|
+
* the message it rode in on.
|
|
127
|
+
*
|
|
128
|
+
* Every check below is cheap-first, strict and canonical, and never throws,
|
|
129
|
+
* matching `verifyDeviceCredential`; the signature is checked last, since it
|
|
130
|
+
* is the most expensive and every tag check above invalidates it anyway.
|
|
131
|
+
*/
|
|
132
|
+
export function verifySubKeyCertificate(cert, opts) {
|
|
133
|
+
if (cert.kind !== KINDS.CREDENTIAL)
|
|
134
|
+
return { ok: false, reason: 'wrong kind' };
|
|
135
|
+
if (cert.content !== '')
|
|
136
|
+
return { ok: false, reason: 'non-empty content' };
|
|
137
|
+
// Exactly four tags, in this fixed order, each exactly [name, value] (see
|
|
138
|
+
// the module comment): a certificate with an extra, missing or reordered
|
|
139
|
+
// tag, or an extra element tacked onto one tag, is refused before any of
|
|
140
|
+
// its values are even read, so nothing downstream can be tricked by
|
|
141
|
+
// content nobody here is looking for.
|
|
142
|
+
if (cert.tags.length !== 4)
|
|
143
|
+
return { ok: false, reason: 'wrong tag count' };
|
|
144
|
+
if (cert.tags.some((t) => t.length !== 2))
|
|
145
|
+
return { ok: false, reason: 'wrong tag shape' };
|
|
146
|
+
const [dTag, deviceTag, expirationTag, scopeTag] = cert.tags;
|
|
147
|
+
if (dTag[0] !== 'd')
|
|
148
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
149
|
+
if (deviceTag[0] !== 'device')
|
|
150
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
151
|
+
if (expirationTag[0] !== 'expiration')
|
|
152
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
153
|
+
if (scopeTag[0] !== 'scope')
|
|
154
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
155
|
+
if (scopeTag[1] !== SUB_KEY_CERTIFICATE_SCOPE)
|
|
156
|
+
return { ok: false, reason: 'wrong scope' };
|
|
157
|
+
const signerId = dTag[1];
|
|
158
|
+
if (!hexEquals(signerId, opts.signerId))
|
|
159
|
+
return { ok: false, reason: 'wrong signer' };
|
|
160
|
+
const subKeyPubkey = deviceTag[1];
|
|
161
|
+
if (!hexEquals(subKeyPubkey, opts.subKeyPubkey)) {
|
|
162
|
+
return { ok: false, reason: 'device tag does not match the message signer' };
|
|
163
|
+
}
|
|
164
|
+
const credentialDevice = opts.credential.tags.find((t) => t[0] === 'device')?.[1];
|
|
165
|
+
if (credentialDevice === undefined)
|
|
166
|
+
return { ok: false, reason: 'credential missing device tag' };
|
|
167
|
+
if (!hexEquals(cert.pubkey, credentialDevice))
|
|
168
|
+
return { ok: false, reason: 'wrong device' };
|
|
169
|
+
const expirationValue = expirationTag[1];
|
|
170
|
+
if (!CANONICAL_DECIMAL.test(expirationValue))
|
|
171
|
+
return { ok: false, reason: 'non-canonical expiration' };
|
|
172
|
+
const credentialExpiration = opts.credential.tags.find((t) => t[0] === 'expiration')?.[1];
|
|
173
|
+
if (credentialExpiration === undefined)
|
|
174
|
+
return { ok: false, reason: 'credential missing expiration tag' };
|
|
175
|
+
// Exact string comparison, not numeric: "1802582000" and "01802582000" (or
|
|
176
|
+
// any other non-canonical spelling of the same number) must not be
|
|
177
|
+
// treated as equal - CANONICAL_DECIMAL above already refuses the
|
|
178
|
+
// certificate's own tag if it is not canonical, so this compares two
|
|
179
|
+
// already-canonical-or-refused strings, catching a credential whose own
|
|
180
|
+
// tag differs even numerically-identically-formatted.
|
|
181
|
+
if (expirationValue !== credentialExpiration)
|
|
182
|
+
return { ok: false, reason: 'expiration does not match the credential' };
|
|
183
|
+
const expiresAt = Number(expirationValue);
|
|
184
|
+
if (expiresAt <= opts.now)
|
|
185
|
+
return { ok: false, reason: 'expired' };
|
|
186
|
+
if (opts.at !== undefined && cert.created_at > opts.at + (opts.skewSeconds ?? 0)) {
|
|
187
|
+
return { ok: false, reason: 'certificate minted after the message it rode in on' };
|
|
188
|
+
}
|
|
189
|
+
if (!certificateSignature(cert))
|
|
190
|
+
return { ok: false, reason: 'bad signature' };
|
|
191
|
+
return { ok: true, device: normaliseHex(cert.pubkey), subKeyPubkey: normaliseHex(subKeyPubkey) };
|
|
192
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Event } from 'nostr-tools/pure';
|
|
2
|
+
import type { Filter } from 'nostr-tools/filter';
|
|
3
|
+
/**
|
|
4
|
+
* The relay transport seam the kit's codecs need: publish, subscribe, close.
|
|
5
|
+
*
|
|
6
|
+
* This is the kit's own declaration, not an import of KithMoot's
|
|
7
|
+
* `RelayTransport` (KithMoot's `src/relay-pool.ts`). The relay pool itself
|
|
8
|
+
* has not moved to the kit yet (see EXTRACTION.md): epoch, invitation and
|
|
9
|
+
* persistent-invitation only ever call `publish`/`subscribe`/`close` on the
|
|
10
|
+
* transport they are given, so the kit needs only that much of the shape,
|
|
11
|
+
* not KithMoot's richer `describe()`/`rekey()` extensions.
|
|
12
|
+
*
|
|
13
|
+
* A caller can pass KithMoot's `NostrRelayPool` (which implements a superset
|
|
14
|
+
* of this interface) straight through, or the kit's own `SimTransport`
|
|
15
|
+
* (`test/sim-relay.ts`), or any other implementation with the same three
|
|
16
|
+
* methods.
|
|
17
|
+
*/
|
|
18
|
+
export interface RelayTransport {
|
|
19
|
+
publish(event: Event): Promise<void>;
|
|
20
|
+
/** `via` is the relay URL that delivered the event, when the transport
|
|
21
|
+
* knows it. */
|
|
22
|
+
subscribe(filters: Filter[], onEvent: (event: Event, via?: string) => void, onEose?: () => void): () => void;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Event } from 'nostr-tools/pure';
|
|
2
|
+
/** Circle-layer wire types: a subset of KithMoot's `src/types.ts`, copied
|
|
3
|
+
* byte-identical per declaration from the pinned source commit (see
|
|
4
|
+
* EXTRACTION.md). KithMoot's `types.ts` also defines roster, media and
|
|
5
|
+
* descriptor types that stay in KithMoot; those import `Reachability`
|
|
6
|
+
* (KithMoot's `src/reachability.ts`), which does not move here (see
|
|
7
|
+
* EXTRACTION.md's "What did not move" section). None of the types below
|
|
8
|
+
* need it. */
|
|
9
|
+
/** A device credential is an ordinary signed Nostr event, never published bare. */
|
|
10
|
+
export type DeviceCredential = Event;
|
|
11
|
+
/** Kindred tiers, closest first: family, mutual verified bond, one-way
|
|
12
|
+
* recognition, no requirement at all. */
|
|
13
|
+
export type AccessTier = 'open' | 'ken' | 'kith' | 'kin';
|
|
14
|
+
/** What a room requires of its agents. `owned-by-members`: an agent is
|
|
15
|
+
* admitted to the roster only with a verified ownership proof from a
|
|
16
|
+
* participant who is in the room. See `AgentOwnership`. */
|
|
17
|
+
export type AgentRule = 'owned-by-members';
|
|
18
|
+
/** A room's admission rule. `admitted` lists the issuer pubkeys the room
|
|
19
|
+
* trusts to vouch for guests; irrelevant when `tier` is `open`. `agents`
|
|
20
|
+
* is a separate rule about what an agent has to show; absent means
|
|
21
|
+
* nothing, which is how every room worked before it existed. */
|
|
22
|
+
export interface RoomPolicy {
|
|
23
|
+
tier: AccessTier;
|
|
24
|
+
admitted?: string[];
|
|
25
|
+
agents?: AgentRule;
|
|
26
|
+
/** When present, the only participants admitted, whatever the tier
|
|
27
|
+
* says. A direct message is a room whose policy lists two. See
|
|
28
|
+
* `docs/messages.md`. */
|
|
29
|
+
members?: string[];
|
|
30
|
+
/** A quiet room: its chat rides the kind 1059 firehose as dead drops to
|
|
31
|
+
* keys derived from the epoch key, on a cadence, so a relay cannot tell
|
|
32
|
+
* whether anything was said, by whom, or when. Only with `members`,
|
|
33
|
+
* because everybody derives every member's keys. Rides here so that
|
|
34
|
+
* everyone who joins agrees on how the room talks, exactly as they
|
|
35
|
+
* agree on who may enter. See `quiet.ts`. */
|
|
36
|
+
quiet?: true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A signed claim that `issuer` recognises `participant` at `tier` in `room`,
|
|
40
|
+
* until `expiresAt`. Never issued for `open`, since open needs no proof.
|
|
41
|
+
*
|
|
42
|
+
* `room` is what stops a proof being a bearer token: without it, one proof
|
|
43
|
+
* admits its holder to every room that happens to trust the same issuer, and
|
|
44
|
+
* an issuer who vouched for a guest at one moot has not vouched for them at
|
|
45
|
+
* all of them. The cost of that binding is stated plainly: a kindred proof is
|
|
46
|
+
* a room grant here, not a portable statement about a relationship, so an
|
|
47
|
+
* issuer mints one per room. In this protocol the party who vouches is the
|
|
48
|
+
* party who sent the join link, so it already knows the room id.
|
|
49
|
+
*/
|
|
50
|
+
export type KindredProof = {
|
|
51
|
+
tier: Exclude<AccessTier, 'open'>;
|
|
52
|
+
participant: string;
|
|
53
|
+
issuer: string;
|
|
54
|
+
/** The room id this proof is valid in. */
|
|
55
|
+
room: string;
|
|
56
|
+
/** 32 random bytes, hex, unique to this proof. Signed over, so two proofs
|
|
57
|
+
* on identical terms are still distinguishable - which is what a revocation
|
|
58
|
+
* list, or an audit, needs to name one of them. */
|
|
59
|
+
nonce: string;
|
|
60
|
+
sig: string;
|
|
61
|
+
expiresAt: number;
|
|
62
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Event } from 'nostr-tools/pure';
|
|
2
|
+
/**
|
|
3
|
+
* Verify an event's signature, ignoring any cached verdict it arrives with.
|
|
4
|
+
*
|
|
5
|
+
* `verifyEvent` caches its result on the event object under `verifiedSymbol`
|
|
6
|
+
* and returns that cached value on any later call, without re-checking
|
|
7
|
+
* anything. The property is an own enumerable symbol, so it survives a
|
|
8
|
+
* shallow copy (`{ ...event }`, which is exactly how a caller might rebuild
|
|
9
|
+
* an event after editing a field) - and an attacker who hands us an object
|
|
10
|
+
* rather than a JSON string can simply set it themselves. Either way the
|
|
11
|
+
* signature check silently becomes a no-op.
|
|
12
|
+
*
|
|
13
|
+
* Every inbound event in this library goes through here rather than calling
|
|
14
|
+
* `verifyEvent` directly, so the defence cannot be forgotten by the next
|
|
15
|
+
* decoder somebody writes. The copy also keeps the caller's object
|
|
16
|
+
* unmodified: we never stamp a verdict onto something we did not sign.
|
|
17
|
+
*/
|
|
18
|
+
export declare function verifyEventUncached(event: Event): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Keep relay fan-out from verifying the same signed event once per relay and
|
|
21
|
+
* replay. The cached value includes every signed field, so an object that
|
|
22
|
+
* reuses a known id while changing its body or signature is rejected. The
|
|
23
|
+
* fixed-size FIFO also prevents an untrusted relay from
|
|
24
|
+
* growing keeper memory without bound.
|
|
25
|
+
*/
|
|
26
|
+
export declare function boundedEventVerifier(limit?: number, verify?: (event: Event) => boolean): (event: Event) => boolean;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { verifyEvent, verifiedSymbol } from 'nostr-tools/pure';
|
|
2
|
+
/**
|
|
3
|
+
* Verify an event's signature, ignoring any cached verdict it arrives with.
|
|
4
|
+
*
|
|
5
|
+
* `verifyEvent` caches its result on the event object under `verifiedSymbol`
|
|
6
|
+
* and returns that cached value on any later call, without re-checking
|
|
7
|
+
* anything. The property is an own enumerable symbol, so it survives a
|
|
8
|
+
* shallow copy (`{ ...event }`, which is exactly how a caller might rebuild
|
|
9
|
+
* an event after editing a field) - and an attacker who hands us an object
|
|
10
|
+
* rather than a JSON string can simply set it themselves. Either way the
|
|
11
|
+
* signature check silently becomes a no-op.
|
|
12
|
+
*
|
|
13
|
+
* Every inbound event in this library goes through here rather than calling
|
|
14
|
+
* `verifyEvent` directly, so the defence cannot be forgotten by the next
|
|
15
|
+
* decoder somebody writes. The copy also keeps the caller's object
|
|
16
|
+
* unmodified: we never stamp a verdict onto something we did not sign.
|
|
17
|
+
*/
|
|
18
|
+
export function verifyEventUncached(event) {
|
|
19
|
+
const unverified = { ...event };
|
|
20
|
+
delete unverified[verifiedSymbol];
|
|
21
|
+
return verifyEvent(unverified);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Keep relay fan-out from verifying the same signed event once per relay and
|
|
25
|
+
* replay. The cached value includes every signed field, so an object that
|
|
26
|
+
* reuses a known id while changing its body or signature is rejected. The
|
|
27
|
+
* fixed-size FIFO also prevents an untrusted relay from
|
|
28
|
+
* growing keeper memory without bound.
|
|
29
|
+
*/
|
|
30
|
+
export function boundedEventVerifier(limit = 8_192, verify = verifyEventUncached) {
|
|
31
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
32
|
+
throw new Error('Event verification cache size must be a positive integer');
|
|
33
|
+
const verified = new Map();
|
|
34
|
+
return event => {
|
|
35
|
+
const cached = verified.get(event.id);
|
|
36
|
+
if (cached !== undefined) {
|
|
37
|
+
return cached === JSON.stringify([event.id, event.pubkey, event.created_at, event.kind, event.tags, event.content, event.sig]);
|
|
38
|
+
}
|
|
39
|
+
if (!verify(event))
|
|
40
|
+
return false;
|
|
41
|
+
verified.set(event.id, JSON.stringify([event.id, event.pubkey, event.created_at, event.kind, event.tags, event.content, event.sig]));
|
|
42
|
+
if (verified.size > limit)
|
|
43
|
+
verified.delete(verified.keys().next().value);
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
}
|