@forgesworn/fold-kit 0.1.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 CHANGED
@@ -93,7 +93,10 @@ the rest).
93
93
  `parseRoomPolicy` - the legacy v1 join URL and room id/key derivation
94
94
  - `createDeviceCredential`, `verifyDeviceCredential`,
95
95
  `PERSON_CREDENTIAL_MAX_SECONDS` - room-scope and person-scope device
96
- credentials
96
+ credentials. `createDeviceCredential` throws `RestampedCredentialExpiryError`
97
+ for a person-scope credential a restamping signer would make unverifiable
98
+ everywhere (see CHANGELOG.md) - catch it and retry with a shorter
99
+ `expiresAt` margin
97
100
  - `issueKindredProof`, `evaluateAccess` - kindred-tier admission
98
101
  - `sanitiseDisplayName`, `MAX_DISPLAY_NAME_LENGTH` - defused display names
99
102
  - `safeRelayUrls`, `safeIceUrls`, `assertNetworkHintBounds`, and the related
@@ -127,6 +130,28 @@ the rest).
127
130
  - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
128
131
  `RESERVED_CHANNELS` - the authority's signed channel list
129
132
 
133
+ ### Scoped labels and sub-key certificates (a consuming app's own keys)
134
+
135
+ - `deriveScoped` - derive an app-defined `{ id, key }` pair from an epoch key
136
+ under an app's own labelled namespace (e.g. `myapp/v1/board/<id>/update`),
137
+ so an app can ride the same epoch as the roster and the chat - and rotate
138
+ on the same rekey - without ever deriving a key that could be mistaken for
139
+ a KithMoot channel. Refuses this kit's own protocol namespace and any
140
+ label outside `SCOPED_LABEL_PATTERN`.
141
+ - `createSubKeyCertificate`, `verifySubKeyCertificate`,
142
+ `SUB_KEY_CERTIFICATE_SCOPE` - a small, locally-signed statement that a
143
+ credentialled device minted a particular app-derived key (a `deriveScoped`
144
+ output, typically) for a particular scoped id. Rides inside a signed
145
+ message's ciphertext alongside the device credential it depends on; never
146
+ published on its own, and never accepted by `verifyDeviceCredential` (its
147
+ `scope: "sub"` is a value that function already refuses). Verification is
148
+ strict and canonical: exactly four two-element tags in a fixed order,
149
+ empty `content`, an `expiration` in canonical decimal form compared as the
150
+ exact tag string (not numerically) against the device credential passed
151
+ in - `verifySubKeyCertificate` reads that credential's own `device` and
152
+ `expiration` tags itself, rather than trusting a caller to have copied
153
+ them out correctly.
154
+
130
155
  ### Channel (main entry, and `deriveChannel` only)
131
156
 
132
157
  - `deriveChannel`, `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO`,
@@ -167,6 +192,7 @@ npm test # run the Vitest suite, including both vector files
167
192
  npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is not tsc-checked)
168
193
  npm run vectors # run only the vector-verification suites
169
194
  npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
195
+ npm run generate-fold # regenerate vectors/fold-vectors.json (needs a prior build)
170
196
  npm run bundle-check # esbuild browser bundle check (needs a prior build)
171
197
  npm run check # typecheck + test + diff-source
172
198
  ```
@@ -28,6 +28,22 @@ export interface CreateCredentialOptions {
28
28
  }
29
29
  /** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
30
30
  export declare const PERSON_CREDENTIAL_MAX_SECONDS: number;
31
+ /**
32
+ * Thrown by `createDeviceCredential` when a remote signer's restamped
33
+ * `created_at` would make the requested expiry run longer than 30 days from
34
+ * it - see the comment at the throw site, and forgesworn/kithmoot issue 205
35
+ * (CHANGELOG.md has the full writeup and a link).
36
+ * Typed (rather than a plain `Error`) so a caller can distinguish this from
37
+ * the other, unrelated failure modes above it (wrong key, wrong terms, bad
38
+ * signature) and retry with a shorter margin specifically in this case.
39
+ */
40
+ export declare class RestampedCredentialExpiryError extends Error {
41
+ /** How far over the 30-day cap the signed `created_at` pushed the
42
+ * requested expiry, in seconds. Always positive. A caller retrying with
43
+ * a shorter margin needs to shorten by at least this much. */
44
+ readonly overBySeconds: number;
45
+ constructor(overBySeconds: number);
46
+ }
31
47
  /**
32
48
  * Authorise a device to act for a participant in one room, until an expiry.
33
49
  *
@@ -3,6 +3,26 @@ import { verifyEventUncached } from './verify.js';
3
3
  import { hexEquals, normaliseHex } from './hex.js';
4
4
  /** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
5
5
  export const PERSON_CREDENTIAL_MAX_SECONDS = 30 * 24 * 60 * 60;
6
+ /**
7
+ * Thrown by `createDeviceCredential` when a remote signer's restamped
8
+ * `created_at` would make the requested expiry run longer than 30 days from
9
+ * it - see the comment at the throw site, and forgesworn/kithmoot issue 205
10
+ * (CHANGELOG.md has the full writeup and a link).
11
+ * Typed (rather than a plain `Error`) so a caller can distinguish this from
12
+ * the other, unrelated failure modes above it (wrong key, wrong terms, bad
13
+ * signature) and retry with a shorter margin specifically in this case.
14
+ */
15
+ export class RestampedCredentialExpiryError extends Error {
16
+ /** How far over the 30-day cap the signed `created_at` pushed the
17
+ * requested expiry, in seconds. Always positive. A caller retrying with
18
+ * a shorter margin needs to shorten by at least this much. */
19
+ overBySeconds;
20
+ constructor(overBySeconds) {
21
+ super('the signer restamped created_at, and the requested expiry now runs longer than 30 days from it');
22
+ this.name = 'RestampedCredentialExpiryError';
23
+ this.overBySeconds = overBySeconds;
24
+ }
25
+ }
6
26
  /**
7
27
  * Authorise a device to act for a participant in one room, until an expiry.
8
28
  *
@@ -58,6 +78,18 @@ export async function createDeviceCredential(opts) {
58
78
  if (!verifyEventUncached(signed)) {
59
79
  throw new Error('the signer returned a credential that does not verify');
60
80
  }
81
+ // forgesworn/kithmoot#205: a remote signer (a bunker, a phone) may restamp
82
+ // `created_at` to its own, earlier clock. `verifyDeviceCredential` measures
83
+ // the 30-day cap from the SIGNED `created_at`, not from `now` above - so a
84
+ // person credential requested near the cap can come back already unable to
85
+ // verify anywhere ("longer than 30 days"), even though nothing here was
86
+ // asked for more than 30 days. Re-check with the verifier's own
87
+ // measurement and fail loudly at mint time, rather than handing back a
88
+ // credential doomed to be refused everywhere it is presented. Callers that
89
+ // hit this can retry with a shorter `expiresAt` margin.
90
+ if (person && opts.expiresAt - signed.created_at > PERSON_CREDENTIAL_MAX_SECONDS) {
91
+ throw new RestampedCredentialExpiryError(opts.expiresAt - signed.created_at - PERSON_CREDENTIAL_MAX_SECONDS);
92
+ }
61
93
  return signed;
62
94
  }
63
95
  /**
package/dist/index.d.ts CHANGED
@@ -5,8 +5,12 @@ export { localIdentity } from './identity.js';
5
5
  export { KINDS } from './kinds.js';
6
6
  export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof } from './types.js';
7
7
  export type { RelayTransport } from './transport.js';
8
- export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, } from './credential.js';
8
+ export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
9
9
  export type { CreateCredentialOptions, VerifyResult } from './credential.js';
10
+ export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
11
+ export type { ScopedKeys } from './scoped.js';
12
+ export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
13
+ export type { CreateSubKeyCertificateOptions, VerifySubKeyCertificateOptions, VerifySubKeyCertificateResult, } from './sub-cert.js';
10
14
  export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
11
15
  export { MAX_RELAY_HINTS, MAX_ICE_HINTS, MAX_NETWORK_HINT_LENGTH, isSafeRelayUrl, safeRelayUrls, isSafeIceUrl, safeIceUrls, assertNetworkHintBounds, } from './network-hints.js';
12
16
  export { sanitiseDisplayName, MAX_DISPLAY_NAME_LENGTH } from './display-name.js';
package/dist/index.js CHANGED
@@ -4,7 +4,9 @@ export { hexEquals, normaliseHex } from './hex.js';
4
4
  export { verifyEventUncached, boundedEventVerifier } from './verify.js';
5
5
  export { localIdentity } from './identity.js';
6
6
  export { KINDS } from './kinds.js';
7
- export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, } from './credential.js';
7
+ export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
8
+ export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
9
+ export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
8
10
  export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
9
11
  export { MAX_RELAY_HINTS, MAX_ICE_HINTS, MAX_NETWORK_HINT_LENGTH, isSafeRelayUrl, safeRelayUrls, isSafeIceUrl, safeIceUrls, assertNetworkHintBounds, } from './network-hints.js';
10
12
  export { sanitiseDisplayName, MAX_DISPLAY_NAME_LENGTH } from './display-name.js';
@@ -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;
@@ -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
+ }
package/llms.txt CHANGED
@@ -49,6 +49,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
49
49
  - `createDeviceCredential(opts)` → `Promise<DeviceCredential>` - room-scope or person-scope (`scope: 'person'`)
50
50
  - `verifyDeviceCredential(event, opts)` → `VerifyResult` - pass `acceptPerson: true` to accept a person-scope credential where a room credential is expected
51
51
  - `PERSON_CREDENTIAL_MAX_SECONDS` - 30-day cap on a person-scope credential
52
+ - `RestampedCredentialExpiryError` - thrown by `createDeviceCredential` when a restamping signer would make a person-scope credential unverifiable everywhere; has `overBySeconds`; catch and retry with a shorter `expiresAt` margin (see CHANGELOG.md)
52
53
  - `issueKindredProof(opts)` → `KindredProof`
53
54
  - `evaluateAccess(policy, participant, proof, now, roomId)` → `{ admitted, reason }`
54
55
  - `sanitiseDisplayName(raw)` → `string | undefined`, `MAX_DISPLAY_NAME_LENGTH`
@@ -77,6 +78,11 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
77
78
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
78
79
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
79
80
 
81
+ ### Scoped labels and sub-key certificates
82
+
83
+ - `deriveScoped(epoch, label)` → `{ id, key }` - HKDF from the epoch KEY under an app's own labelled namespace (e.g. `myapp/v1/board/<id>/update`); refuses this kit's own protocol namespace and any label outside `SCOPED_LABEL_PATTERN`
84
+ - `createSubKeyCertificate(opts)` → `Promise<DeviceCredential>` / `verifySubKeyCertificate(event, opts)` → result - a locally-signed, never-published statement binding an app-derived key to the credentialled device that minted it; `scope: 'sub'` is a value `verifyDeviceCredential` already refuses, so the two forms can never be confused. `verifySubKeyCertificate` takes `opts.credential` (the already-verified device credential) and reads its `device`/`expiration` tags itself; verification is strict (exactly four two-element tags, empty content) and canonical (expiration compared as the exact tag string). `SUB_KEY_CERTIFICATE_SCOPE`.
85
+
80
86
  ### Channel derivation
81
87
 
82
88
  - `deriveChannel(roomId, roomKey, channel?)` → `{ id, key }` - a named channel's id/key, derived from the room key; `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO`, `MAX_CHANNEL_NAME_LENGTH`. The chat log and event codecs themselves stay in the consuming app.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -30,6 +30,7 @@
30
30
  "test:watch": "vitest",
31
31
  "typecheck": "tsc -p tsconfig.typecheck.json",
32
32
  "diff-source": "node scripts/diff-source.mjs",
33
+ "generate-fold": "node scripts/generate-fold.mjs",
33
34
  "bundle-check": "node scripts/bundle-check.mjs",
34
35
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
35
36
  "vectors": "vitest run vectors/",
@@ -1758,7 +1758,7 @@
1758
1758
  {
1759
1759
  "name": "refused-over-30-days",
1760
1760
  "kind": "negative",
1761
- "note": "M15 (refused side): the real `createDeviceCredential`'s mint-time check passes (expiry is exactly 2592000 seconds past the requested `now`, not more) - but the injected identity restamps the signed event's `created_at` ten seconds earlier than `now`, so the credential that actually results runs 2592010 seconds from its own real `created_at`. `verifyDeviceCredential` refuses it on read.",
1761
+ "note": "M15 (refused side); updated for forgesworn/kithmoot#205: the real createDeviceCredential now measures the same way verifyDeviceCredential does - against the SIGNED created_at, not the requested now - and throws RestampedCredentialExpiryError at mint time instead of returning a credential. output.event/output.result keep M15's original coverage: the exact over-cap event a pre-#205 mint would have returned (built deterministically, the same bytes as before), and verifyDeviceCredential's own refusal of it, which is unchanged by #205 (its measurement was already correct - only the mint side was checking the wrong clock).",
1762
1762
  "input": {
1763
1763
  "participantSkHex": "826f7335cca1f25633dbc6efc43131a651feb9b7d419c9b1fdc35b2e115d2180",
1764
1764
  "devicePubkey": "b4814eb28f92350f362af5eb7a1dc6b9bdd1d27e789f5d97ad3b8c7653753076",
@@ -1772,6 +1772,7 @@
1772
1772
  }
1773
1773
  },
1774
1774
  "output": {
1775
+ "mintThrew": "the signer restamped created_at, and the requested expiry now runs longer than 30 days from it",
1775
1776
  "event": {
1776
1777
  "kind": 20460,
1777
1778
  "created_at": 1799989990,