signet-protocol 1.10.1 → 1.11.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.
Files changed (48) hide show
  1. package/dist/bot-ownership.d.ts +61 -0
  2. package/dist/bot-ownership.d.ts.map +1 -0
  3. package/dist/bot-ownership.js +88 -0
  4. package/dist/bot-ownership.js.map +1 -0
  5. package/dist/crypto.d.ts +2 -0
  6. package/dist/crypto.d.ts.map +1 -1
  7. package/dist/crypto.js +4 -0
  8. package/dist/crypto.js.map +1 -1
  9. package/dist/experimental.d.ts +19 -0
  10. package/dist/experimental.d.ts.map +1 -0
  11. package/dist/experimental.js +16 -0
  12. package/dist/experimental.js.map +1 -0
  13. package/dist/index.js +2 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/url-auth.d.ts.map +1 -1
  16. package/dist/url-auth.js +2 -1
  17. package/dist/url-auth.js.map +1 -1
  18. package/dist/utils.d.ts +46 -0
  19. package/dist/utils.d.ts.map +1 -1
  20. package/dist/utils.js +127 -0
  21. package/dist/utils.js.map +1 -1
  22. package/dist/vault-checkpoint.d.ts +50 -0
  23. package/dist/vault-checkpoint.d.ts.map +1 -0
  24. package/dist/vault-checkpoint.js +84 -0
  25. package/dist/vault-checkpoint.js.map +1 -0
  26. package/dist/vault-keys.d.ts +19 -0
  27. package/dist/vault-keys.d.ts.map +1 -0
  28. package/dist/vault-keys.js +57 -0
  29. package/dist/vault-keys.js.map +1 -0
  30. package/dist/vault-recovery.d.ts +99 -0
  31. package/dist/vault-recovery.d.ts.map +1 -0
  32. package/dist/vault-recovery.js +305 -0
  33. package/dist/vault-recovery.js.map +1 -0
  34. package/dist/vault-relay.d.ts +29 -0
  35. package/dist/vault-relay.d.ts.map +1 -0
  36. package/dist/vault-relay.js +131 -0
  37. package/dist/vault-relay.js.map +1 -0
  38. package/package.json +8 -4
  39. package/src/bot-ownership.ts +77 -0
  40. package/src/crypto.ts +5 -0
  41. package/src/experimental.ts +25 -0
  42. package/src/index.ts +3 -0
  43. package/src/url-auth.ts +2 -1
  44. package/src/utils.ts +122 -0
  45. package/src/vault-checkpoint.ts +98 -0
  46. package/src/vault-keys.ts +59 -0
  47. package/src/vault-recovery.ts +284 -0
  48. package/src/vault-relay.ts +112 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vault-relay.d.ts","sourceRoot":"","sources":["../src/vault-relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAA;AAC1D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAGtD,oFAAoF;AACpF,eAAO,MAAM,0BAA0B,MAAM,CAAA;AA2B7C;yDACyD;AACzD,qBAAa,eAAgB,SAAQ,KAAK;IACX,QAAQ,CAAC,YAAY,EAAE,MAAM,EAAE;gBAAhD,OAAO,EAAE,MAAM,EAAW,YAAY,EAAE,MAAM,EAAE;CAI7D;AAED;;;mCAGmC;AACnC,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,mBAAmB,CAAC,EAC1F,MAAM,EAAE,WAAW,EAAE,SAAS,SAAQ,EAAE,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAsBxG;AAED;;;;;;;;;;uDAUuD;AACvD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,WAAW,CA6BxG"}
@@ -0,0 +1,131 @@
1
+ import { RelayClient } from './relay.js';
2
+ import { VAULT_EVENT_KIND, vaultCheckpointShapedTag } from './vault-checkpoint.js';
3
+ /** Matching events one relay may contribute to one query. More fails that relay. */
4
+ export const MAX_VAULT_EVENTS_PER_RELAY = 128;
5
+ const MAX_VAULT_RELAYS = 8;
6
+ /** Must agree with the RelayClient constructor: a URL accepted here but rejected
7
+ * there would be skipped silently. Plain ws:// is loopback-only (localhost and
8
+ * 127.0.0.1; RelayClient does not accept [::1]). */
9
+ function validRelay(raw) {
10
+ try {
11
+ const url = new URL(raw);
12
+ return !url.username && !url.password && !url.hash && (url.protocol === 'wss:'
13
+ || (url.protocol === 'ws:' && ['localhost', '127.0.0.1'].includes(url.hostname)));
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** Client-side re-check of the relay filter. Relays are untrusted, so an event
20
+ * outside the requested author/kind/id/d-tag is dropped before it counts
21
+ * towards any limit. */
22
+ function matchesFilter(filter, event) {
23
+ if (!event || typeof event.id !== 'string' || typeof event.pubkey !== 'string' || !Array.isArray(event.tags))
24
+ return false;
25
+ if (filter.ids && !filter.ids.includes(event.id))
26
+ return false;
27
+ if (filter.authors && !filter.authors.includes(event.pubkey))
28
+ return false;
29
+ if (filter.kinds && !filter.kinds.includes(event.kind))
30
+ return false;
31
+ const dTags = filter['#d'];
32
+ if (dTags && !event.tags.some(t => Array.isArray(t) && t[0] === 'd' && dTags.includes(t[1])))
33
+ return false;
34
+ return true;
35
+ }
36
+ /** A relay query that did not complete on every relay. `failedRelays` names the
37
+ * relays that errored, timed out or exceeded the cap. */
38
+ export class VaultRelayError extends Error {
39
+ failedRelays;
40
+ constructor(message, failedRelays) {
41
+ super(message);
42
+ this.failedRelays = failedRelays;
43
+ this.name = 'VaultRelayError';
44
+ }
45
+ }
46
+ /** EOSE is required: a timed-out empty query is not evidence of absence.
47
+ * Events are filtered against `filter` (and `accept`, when given) and
48
+ * de-duplicated before the per-relay cap applies, so unrelated events cannot
49
+ * crowd out the requested ones. */
50
+ export function fetchVaultEvents(relay, filter, timeoutMs = 10000, accept) {
51
+ return new Promise((resolve, reject) => {
52
+ const events = new Map();
53
+ let subId;
54
+ let finished = false;
55
+ const finish = (error) => {
56
+ if (finished)
57
+ return;
58
+ finished = true;
59
+ clearTimeout(timer);
60
+ if (subId)
61
+ relay.closeSubscription(subId);
62
+ if (error)
63
+ reject(error);
64
+ else
65
+ resolve([...events.values()]);
66
+ };
67
+ const timer = setTimeout(() => finish(new Error('Vault query did not complete')), timeoutMs);
68
+ try {
69
+ subId = relay.subscribe([filter], event => {
70
+ if (finished || !matchesFilter(filter, event) || (accept && !accept(event)) || events.has(event.id))
71
+ return;
72
+ if (events.size >= MAX_VAULT_EVENTS_PER_RELAY)
73
+ finish(new Error('Vault query exceeds event limit'));
74
+ else
75
+ events.set(event.id, event);
76
+ }, () => finish());
77
+ if (finished)
78
+ relay.closeSubscription(subId);
79
+ }
80
+ catch {
81
+ finish(new Error('Vault query failed'));
82
+ }
83
+ });
84
+ }
85
+ /** Relay-backed reader. At most eight valid relay URLs are queried.
86
+ *
87
+ * A checkpoint query, empty or not, completes only when EVERY queried relay
88
+ * answered with EOSE: one relay's "nothing" is not evidence of absence, and one
89
+ * relay's heads may omit a device head another relay holds. Otherwise it throws
90
+ * a `VaultRelayError` naming the failed relays, and recovery reports
91
+ * `unavailable` with `failedRelays`. A permanently dead configured relay keeps
92
+ * reads unavailable until it is removed from the set. A chunk fetch by exact
93
+ * ID succeeds as soon as any relay returns it (its hash is pinned); "not found"
94
+ * again needs every relay. Checkpoint queries drop events without a
95
+ * checkpoint-shaped d-tag before the per-relay cap. */
96
+ export function createVaultRelayReader(relays, open) {
97
+ const urls = [...new Set(relays.filter(validRelay))].slice(0, MAX_VAULT_RELAYS);
98
+ const query = async (filter, accept, enough) => {
99
+ if (!urls.length)
100
+ throw new VaultRelayError('No valid vault relays', []);
101
+ const events = new Map();
102
+ const failed = [];
103
+ await Promise.all(urls.map(async (url) => {
104
+ let relay;
105
+ try {
106
+ relay = new RelayClient(url);
107
+ await relay.connect();
108
+ const found = await fetchVaultEvents(relay, filter, undefined, accept);
109
+ for (const e of found)
110
+ events.set(e.id, e);
111
+ }
112
+ catch {
113
+ failed.push(url);
114
+ }
115
+ finally {
116
+ relay?.disconnect();
117
+ }
118
+ }));
119
+ const result = [...events.values()];
120
+ if (failed.length && !(enough && enough(result))) {
121
+ throw new VaultRelayError(`Vault relays did not all answer: ${failed.length} of ${urls.length} failed`, failed.sort());
122
+ }
123
+ return result;
124
+ };
125
+ return {
126
+ checkpoints: (author, dTag) => query({ kinds: [VAULT_EVENT_KIND], authors: [author], ...(dTag ? { '#d': [dTag] } : {}), limit: MAX_VAULT_EVENTS_PER_RELAY }, event => vaultCheckpointShapedTag(event) !== null),
127
+ chunk: async (id) => (await query({ ids: [id], limit: 1 }, undefined, found => found.some(e => e.id === id))).find(e => e.id === id) ?? null,
128
+ open,
129
+ };
130
+ }
131
+ //# sourceMappingURL=vault-relay.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vault-relay.js","sourceRoot":"","sources":["../src/vault-relay.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signet-protocol",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Decentralised identity verification protocol for Nostr",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts"
12
+ },
13
+ "./experimental": {
14
+ "import": "./dist/experimental.js",
15
+ "types": "./dist/experimental.d.ts"
12
16
  }
13
17
  },
14
18
  "files": [
@@ -55,20 +59,20 @@
55
59
  "dependencies": {
56
60
  "@forgesworn/range-proof": "^2.0.0",
57
61
  "@forgesworn/ring-sig": "^3.0.0",
58
- "@forgesworn/shamir-words": "^1.0.0",
62
+ "@forgesworn/shamir-words": "^1.2.0-alpha.1",
59
63
  "@noble/curves": "^2.0.1",
60
64
  "@noble/hashes": "^2.0.1",
61
65
  "@scure/bip39": "^2.0.1",
62
66
  "jurisdiction-kit": "^1.0.0",
63
67
  "nostr-attestations": "^2.3.1",
64
- "nsec-tree": "^1.4.1",
68
+ "nsec-tree": "^1.6.0-alpha.1",
65
69
  "spoken-token": "^2.0.3"
66
70
  },
67
71
  "devDependencies": {
68
72
  "@types/node": "^25.3.3",
69
73
  "tsx": "^4.21.0",
70
74
  "typescript": "^6.0.2",
71
- "vitest": "^4.1.4"
75
+ "vitest": "^5.0.0"
72
76
  },
73
77
  "funding": {
74
78
  "type": "lightning",
@@ -0,0 +1,77 @@
1
+ /** Draft bot ownership profile of nostr-attestations. Builders never publish. */
2
+ import { createAttestation, createRevocation, buildDTag } from 'nostr-attestations';
3
+ import { verifyEventSync } from './crypto.js';
4
+ import { isSafeLabel } from './utils.js';
5
+ import type { NostrEvent, UnsignedEvent } from './types.js';
6
+ export const BOT_OWNERSHIP_TYPE = 'bot-ownership';
7
+ export const BOT_OWNERSHIP_POLICY = Object.freeze({ defaultDays: 30, minDays: 1, maxDays: 90,
8
+ renewBeforeDays: 10, retrySeconds: 86400, clockToleranceSeconds: 300 });
9
+ const DAY = 86400, HEX = /^[0-9a-f]{64}$/;
10
+ const stamp = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
11
+ const labelOk = (label: unknown): label is string => typeof label === 'string' && !!label.trim() && label.length <= 100
12
+ && isSafeLabel(label);
13
+ function validateParties(ownerPubkey: string, botPubkey: string, now: number) {
14
+ if (!HEX.test(ownerPubkey) || !HEX.test(botPubkey) || ownerPubkey === botPubkey || !stamp(now)) throw new Error('Invalid bot ownership parties or time');
15
+ }
16
+ /** The caller selects a persona, obtains explicit creation consent, and signs
17
+ * this ordinary Nostr event through its existing local or NIP-46 backend. */
18
+ export function buildBotOwnership(args: { ownerPubkey: string; botPubkey: string; label: string; now: number; expiresAt?: number }): UnsignedEvent {
19
+ validateParties(args.ownerPubkey, args.botPubkey, args.now);
20
+ const expiresAt = args.expiresAt ?? args.now + BOT_OWNERSHIP_POLICY.defaultDays * DAY;
21
+ if (!labelOk(args.label) || !stamp(expiresAt) || expiresAt < args.now + DAY || expiresAt > args.now + 90 * DAY) throw new Error('Bot ownership requires a label and a lifetime from one to ninety days');
22
+ return { ...createAttestation({ type: BOT_OWNERSHIP_TYPE, subject: args.botPubkey,
23
+ validFrom: args.now, validTo: expiresAt, expiration: expiresAt,
24
+ content: JSON.stringify({ v: 1, label: args.label.trim() }) }), pubkey: args.ownerPubkey, created_at: args.now };
25
+ }
26
+ export function buildBotOwnershipRevocation(args: { ownerPubkey: string; botPubkey: string; now: number }): UnsignedEvent {
27
+ validateParties(args.ownerPubkey, args.botPubkey, args.now);
28
+ return { ...createRevocation({ type: BOT_OWNERSHIP_TYPE, identifier: args.botPubkey, subject: args.botPubkey,
29
+ effective: args.now }), pubkey: args.ownerPubkey, created_at: args.now };
30
+ }
31
+ export interface BotOwnershipClaim { ownerPubkey: string; botPubkey: string; label: string; issuedAt: number; expiresAt: number; eventId: string }
32
+ export type BotOwnershipResult = { status: 'invalid' } | { status: 'revoked'; ownerPubkey: string; botPubkey: string; eventId: string }
33
+ | { status: 'valid' | 'lapsed'; claim: BotOwnershipClaim };
34
+ /** No ownership trust is inherited by the bot. Callers pin the expected persona
35
+ * and bot and separately establish that they selected the newest event. */
36
+ export async function readBotOwnership(event: NostrEvent, expected: { ownerPubkey: string; botPubkey: string; now: number; maxLifetimeDays?: number }): Promise<BotOwnershipResult> {
37
+ return readBotOwnershipSync(event, expected);
38
+ }
39
+ /** Same validation for synchronous transport/storage codecs. */
40
+ export function readBotOwnershipSync(event: NostrEvent, expected: { ownerPubkey: string; botPubkey: string; now: number; maxLifetimeDays?: number }): BotOwnershipResult {
41
+ const invalid = { status: 'invalid' } as const;
42
+ const maximum = expected.maxLifetimeDays ?? BOT_OWNERSHIP_POLICY.maxDays;
43
+ if (!stamp(expected.now) || !Number.isSafeInteger(maximum) || maximum < 1 || maximum > 90
44
+ || !event || event.kind !== 31000 || event.pubkey !== expected.ownerPubkey || !HEX.test(expected.ownerPubkey)
45
+ || !HEX.test(expected.botPubkey) || expected.ownerPubkey === expected.botPubkey
46
+ || !stamp(event.created_at) || event.created_at > expected.now + BOT_OWNERSHIP_POLICY.clockToleranceSeconds
47
+ || typeof event.content !== 'string' || event.content.length > 1000 || !Array.isArray(event.tags) || event.tags.length > 16
48
+ || !event.tags.every(tag => Array.isArray(tag) && tag.length >= 2 && tag.length <= 4 && tag.every(value => typeof value === 'string' && value.length <= 256))) return invalid;
49
+ const single = (name: string) => {
50
+ const values = event.tags.filter(tag => tag[0] === name);
51
+ return values.length === 1 ? values[0][1] : undefined;
52
+ };
53
+ if (single('d') !== buildDTag(BOT_OWNERSHIP_TYPE, expected.botPubkey) || single('type') !== BOT_OWNERSHIP_TYPE
54
+ || single('p') !== expected.botPubkey || !verifyEventSync(event)) return invalid;
55
+ const statuses = event.tags.filter(tag => tag[0] === 'status');
56
+ if (statuses.length) {
57
+ if (statuses.length !== 1 || statuses[0][1] !== 'revoked' || event.content !== '') return invalid;
58
+ return { status: 'revoked', ownerPubkey: event.pubkey, botPubkey: expected.botPubkey, eventId: event.id };
59
+ }
60
+ const start = single('valid_from'), end = single('valid_to'), expiration = single('expiration');
61
+ if (start !== String(event.created_at) || !end || !/^\d+$/.test(end) || end !== expiration) return invalid;
62
+ const declared = Number(end);
63
+ if (!stamp(declared) || declared < event.created_at + DAY) return invalid;
64
+ let body: { v?: unknown; label?: unknown };
65
+ try { body = JSON.parse(event.content); } catch { return invalid; }
66
+ if (!body || body.v !== 1 || !labelOk(body.label)) return invalid;
67
+ // A user may choose a stricter ceiling. It never extends the signed expiry.
68
+ const expiresAt = Math.min(declared, event.created_at + maximum * DAY, event.created_at + 90 * DAY);
69
+ return { status: expected.now > expiresAt + BOT_OWNERSHIP_POLICY.clockToleranceSeconds ? 'lapsed' : 'valid',
70
+ claim: { ownerPubkey: event.pubkey, botPubkey: expected.botPubkey, label: body.label.trim(), issuedAt: event.created_at, expiresAt, eventId: event.id } };
71
+ }
72
+ /** Scheduling only: hardware signing still needs its normal approval. */
73
+ export function botOwnershipRenewalDue(result: BotOwnershipResult, now: number, lastAttemptAt?: number): boolean {
74
+ if (!stamp(now) || (lastAttemptAt !== undefined && (!stamp(lastAttemptAt) || now - lastAttemptAt < BOT_OWNERSHIP_POLICY.retrySeconds))) return false;
75
+ return (result.status === 'valid' || result.status === 'lapsed') && now >= result.claim.issuedAt
76
+ && result.claim.expiresAt - now <= BOT_OWNERSHIP_POLICY.renewBeforeDays * DAY;
77
+ }
package/src/crypto.ts CHANGED
@@ -65,6 +65,11 @@ export async function signEvent(
65
65
 
66
66
  /** Verify a signed Nostr event's signature */
67
67
  export async function verifyEvent(event: NostrEvent): Promise<boolean> {
68
+ return verifyEventSync(event);
69
+ }
70
+
71
+ /** Synchronous verification for codecs that must reject invalid events inline. */
72
+ export function verifyEventSync(event: NostrEvent): boolean {
68
73
  try {
69
74
  const expectedId = getEventId(event);
70
75
  if (expectedId !== event.id) return false;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * signet-protocol/experimental — UNSTABLE, UNRELEASED draft contracts.
3
+ *
4
+ * The private vault checkpoint (docs/private-vault-v1.md) and the bot ownership
5
+ * profile (docs/bot-ownership-v1.md) are still under protocol review. Anything
6
+ * exported here may change or be removed in any release, including a minor or
7
+ * patch, without a deprecation period. Do not depend on it for production data.
8
+ * None of these names are exported from the package root.
9
+ */
10
+ // Private vault: dedicated dataset keys (flat derivation; index means rotation).
11
+ export { vaultPurpose, parseVaultPurpose, vaultKeyContext, deriveVaultIdentity, vaultIdentityFromMnemonic } from './vault-keys.js'
12
+ export type { VaultDataset } from './vault-keys.js'
13
+ export { VAULT_EVENT_KIND, MAX_VAULT_CHUNKS, MAX_VAULT_DEVICES, MAX_VAULT_CONTROL_BYTES,
14
+ MAX_VAULT_CHUNK_BYTES, vaultCheckpointTag, vaultContentHash, parseVaultCheckpoint,
15
+ matchesVaultChunk, vaultCheckpointShapedTag } from './vault-checkpoint.js'
16
+ export type { VaultCheckpoint, VaultChunkRef } from './vault-checkpoint.js'
17
+ export { readVaultSnapshot, readVaultRotations, readVaultHeads, readVaultHeadRotations,
18
+ VAULT_CLOCK_TOLERANCE_SECONDS } from './vault-recovery.js'
19
+ export type { VaultReadResult, VaultReader, VaultHeadsResult } from './vault-recovery.js'
20
+
21
+ export { createVaultRelayReader, fetchVaultEvents, MAX_VAULT_EVENTS_PER_RELAY, VaultRelayError } from './vault-relay.js'
22
+
23
+ export { BOT_OWNERSHIP_TYPE, BOT_OWNERSHIP_POLICY, buildBotOwnership, buildBotOwnershipRevocation,
24
+ readBotOwnership, readBotOwnershipSync, botOwnershipRenewalDue } from './bot-ownership.js';
25
+ export type { BotOwnershipClaim, BotOwnershipResult } from './bot-ownership.js';
package/src/index.ts CHANGED
@@ -518,3 +518,6 @@ export {
518
518
  parseMigrationEvent,
519
519
  followMigrationChain,
520
520
  } from './migration.js';
521
+
522
+ // Private vault and bot-ownership contracts are unreleased drafts: import them
523
+ // from 'signet-protocol/experimental'. Nothing from them is exported here.
package/src/url-auth.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { LoginRequest } from './qr-router.js';
10
+ import { UNSAFE_TEXT_CHARS } from './utils.js';
10
11
 
11
12
  /** Check if a URL is https:// or http://localhost (dev). */
12
13
  function isValidAuthUrl(url: string): boolean {
@@ -152,5 +153,5 @@ export function buildAuthDeniedUrl(callbackUrl: string): string {
152
153
  export function getUrlAuthSiteName(search: string): string {
153
154
  const params = new URLSearchParams(search);
154
155
  const name = params.get('name') ?? '';
155
- return name.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]/g, '').slice(0, 64);
156
+ return name.replace(new RegExp(UNSAFE_TEXT_CHARS.source, 'gu'), '').slice(0, 64);
156
157
  }
package/src/utils.ts CHANGED
@@ -18,3 +18,125 @@ export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
18
18
  }
19
19
  return diff === 0;
20
20
  }
21
+
22
+ /**
23
+ * One character class for text that is shown to people: C0 controls, DEL, C1
24
+ * controls, Arabic letter mark (U+061C), zero-width and LRM/RLM (U+200B-U+200F),
25
+ * line/paragraph separators and bidi embeddings/overrides (U+2028-U+202E), and
26
+ * bidi isolates (U+2066-U+2069). Not global: build a `g` copy to strip.
27
+ */
28
+ export const UNSAFE_TEXT_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2066-\u2069]/u;
29
+
30
+ /**
31
+ * `UNSAFE_TEXT_CHARS` minus U+200D (zero-width joiner), which is not a bidi
32
+ * control and is needed for emoji sequences, plus other invisible/deprecated-
33
+ * format characters a label has no legitimate use for: soft hyphen (U+00AD),
34
+ * Mongolian vowel separator (U+180E), word joiner and the deprecated invisible
35
+ * math operators (U+2060-U+2064), and the BOM / zero width no-break space
36
+ * (U+FEFF). This is a fixed denylist only; it does not cover every Unicode
37
+ * "format" (Cf) or Default_Ignorable code point (variation selectors, emoji
38
+ * tag characters, Hangul fillers, Arabic number-sign marks, deprecated music
39
+ * format controls, ...). Use `isSafeLabel` to validate labels \u2014 it checks
40
+ * this denylist plus those two Unicode categories, carving out only the
41
+ * narrow, positionally-constrained exceptions emoji sequences require.
42
+ */
43
+ export const UNSAFE_LABEL_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b\u200c\u200e\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/u;
44
+
45
+ const CF_OR_DEFAULT_IGNORABLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
46
+ const EXTENDED_PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
47
+ const KEYCAP_BASE = /[#*0-9]/;
48
+ // A single unpaired UTF-16 surrogate is not a valid code point. In `u`-mode a
49
+ // correctly paired surrogate combines into one astral code point first and
50
+ // never matches this class; only a lone (unpaired) half does.
51
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
52
+ const ZWJ = 0x200d;
53
+ const VS15 = 0xfe0e, VS16 = 0xfe0f;
54
+ const SKIN_TONE_MIN = 0x1f3fb, SKIN_TONE_MAX = 0x1f3ff;
55
+ const TAG_CHAR_MIN = 0xe0020, TAG_CHAR_MAX = 0xe007e, TAG_CANCEL = 0xe007f, FLAG_BASE = 0x1f3f4;
56
+ // RGI emoji tag sequences: only these three UK subdivision flags are
57
+ // well-formed. Each entry is the tag-character run (base and cancel tag
58
+ // excluded) spelling the ISO 3166-2 subdivision code in lowercase ASCII,
59
+ // shifted into the U+E0000 tag-character plane.
60
+ const RGI_FLAG_TAG_SEQUENCES: readonly (readonly number[])[] = ['gbeng', 'gbsct', 'gbwls'].map(code =>
61
+ Array.from(code, ch => 0xe0000 + (ch.codePointAt(0) as number)));
62
+
63
+ /**
64
+ * Whether `codePoints[index]` is "an emoji element" for the zero-width-joiner
65
+ * rule below: an Extended_Pictographic code point itself, or U+FE0F / an
66
+ * emoji skin-tone modifier (U+1F3FB-1F3FF) that directly follows one.
67
+ */
68
+ function endsInEmojiElement(codePoints: string[], index: number): boolean {
69
+ if (index < 0) return false;
70
+ const ch = codePoints[index];
71
+ if (EXTENDED_PICTOGRAPHIC.test(ch)) return true;
72
+ const cp = ch.codePointAt(0) as number;
73
+ if (cp !== VS16 && !(cp >= SKIN_TONE_MIN && cp <= SKIN_TONE_MAX)) return false;
74
+ const prev = codePoints[index - 1];
75
+ return prev !== undefined && EXTENDED_PICTOGRAPHIC.test(prev);
76
+ }
77
+
78
+ /**
79
+ * A label is unsafe if it contains a lone (unpaired) UTF-16 surrogate, any
80
+ * `UNSAFE_LABEL_CHARS` character, or any Unicode format (Cf) or
81
+ * Default_Ignorable_Code_Point code point, EXCEPT the narrow in-context uses
82
+ * emoji sequences need:
83
+ *
84
+ * 1. U+200D (ZWJ) only between two emoji elements: the code point before it
85
+ * ends in an emoji element (see `endsInEmojiElement`) and the code point
86
+ * after it is Extended_Pictographic. A leading, trailing or doubled ZWJ,
87
+ * or one between ordinary letters, is rejected.
88
+ * 2. U+FE0E / U+FE0F (text/emoji variation selectors) only directly after
89
+ * an Extended_Pictographic code point or a keycap base ([#*0-9]). Every
90
+ * other variation selector (U+FE00-FE0D, U+E0100-E01EF) is rejected
91
+ * everywhere.
92
+ * 3. Tag characters U+E0020-E007E only inside one of the three RGI UK
93
+ * subdivision emoji flag sequences: U+1F3F4 (waving black flag) followed
94
+ * by the exact tag-character spelling of "gbeng", "gbsct" or "gbwls"
95
+ * then the cancel tag U+E007F, with nothing else in between. Any other
96
+ * tag run — a different or misspelled subdivision code, extra or missing
97
+ * tag characters, a hidden payload, a missing/extra cancel tag, or tag
98
+ * characters with no U+1F3F4 base — is rejected, as is U+E0001 anywhere.
99
+ *
100
+ * Iterates by code point, not UTF-16 code unit.
101
+ */
102
+ export function isSafeLabel(label: string): boolean {
103
+ if (LONE_SURROGATE.test(label) || UNSAFE_LABEL_CHARS.test(label)) return false;
104
+ const codePoints = Array.from(label);
105
+ // Indices that are part of one of the three well-formed RGI subdivision
106
+ // flag tag sequences: the exact tag-character spelling plus its cancel tag,
107
+ // immediately following a U+1F3F4.
108
+ const reservedTagIndex = new Set<number>();
109
+ for (let i = 0; i < codePoints.length; i++) {
110
+ if (codePoints[i].codePointAt(0) !== FLAG_BASE) continue;
111
+ for (const seq of RGI_FLAG_TAG_SEQUENCES) {
112
+ const cancelIndex = i + 1 + seq.length;
113
+ if (cancelIndex >= codePoints.length) continue;
114
+ const matchesSpelling = seq.every((cp, k) => codePoints[i + 1 + k].codePointAt(0) === cp);
115
+ if (matchesSpelling && codePoints[cancelIndex].codePointAt(0) === TAG_CANCEL) {
116
+ for (let k = i + 1; k <= cancelIndex; k++) reservedTagIndex.add(k);
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ for (let i = 0; i < codePoints.length; i++) {
122
+ const ch = codePoints[i];
123
+ if (!CF_OR_DEFAULT_IGNORABLE.test(ch)) continue;
124
+ const cp = ch.codePointAt(0) as number;
125
+ if (cp === ZWJ) {
126
+ const next = codePoints[i + 1];
127
+ if (endsInEmojiElement(codePoints, i - 1) && next !== undefined && EXTENDED_PICTOGRAPHIC.test(next)) continue;
128
+ return false;
129
+ }
130
+ if (cp === VS15 || cp === VS16) {
131
+ const prev = codePoints[i - 1];
132
+ if (prev !== undefined && (EXTENDED_PICTOGRAPHIC.test(prev) || KEYCAP_BASE.test(prev))) continue;
133
+ return false;
134
+ }
135
+ if ((cp >= TAG_CHAR_MIN && cp <= TAG_CHAR_MAX) || cp === TAG_CANCEL) {
136
+ if (reservedTagIndex.has(i)) continue;
137
+ return false;
138
+ }
139
+ return false;
140
+ }
141
+ return true;
142
+ }
@@ -0,0 +1,98 @@
1
+ /** Shared private-vault checkpoint contract. No relay I/O or application data schema. */
2
+ import { hmac } from '@noble/hashes/hmac.js'
3
+ import { sha256 } from '@noble/hashes/sha2.js'
4
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
5
+ import { parseVaultPurpose } from './vault-keys.js'
6
+
7
+ export const VAULT_EVENT_KIND = 30078
8
+ export const MAX_VAULT_CHUNKS = 32
9
+ export const MAX_VAULT_DEVICES = 16
10
+ export const MAX_VAULT_CONTROL_BYTES = 12 * 1024
11
+ export const MAX_VAULT_CHUNK_BYTES = 100_000
12
+ const HEX = /^[0-9a-f]{64}$/
13
+ const encoder = new TextEncoder()
14
+ const uint = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0
15
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
16
+
17
+ export interface VaultChunkRef {
18
+ /** Exact signed event ID, never a mutable latest-by-tag lookup. */
19
+ eventId: string
20
+ author: string
21
+ /** SHA-256 of the UTF-8 encrypted event content. */
22
+ contentHash: string
23
+ contentBytes: number
24
+ }
25
+
26
+ export interface VaultCheckpoint {
27
+ v: 1
28
+ purpose: string
29
+ rotation: number
30
+ sequence: number
31
+ /** SHA-256 of the complete plaintext, checked before accepting a restored copy. */
32
+ revision: string
33
+ /** Device whose independently replaceable checkpoint this is. */
34
+ publisher?: string
35
+ devicePubkeys: string[]
36
+ /** Ordered chunk references; empty datasets still have one encrypted chunk. */
37
+ chunks: VaultChunkRef[]
38
+ /** Optional pointer, exactly rotation + 1; recovery follows it from index zero. */
39
+ nextRotation?: number
40
+ }
41
+
42
+ /** Publicly computable opaque tag; confidentiality comes from encryption and private keys. */
43
+ export function vaultCheckpointTag(author: string, publisher?: string): string {
44
+ if (!HEX.test(author)) throw new Error('Invalid vault author')
45
+ if (publisher !== undefined && !HEX.test(publisher)) throw new Error('Invalid vault publisher')
46
+ return bytesToHex(hmac(sha256, hexToBytes(author), encoder.encode(`signet-vault-route\u0000checkpoint\u0000${publisher ?? '0'}`))).slice(0, 32)
47
+ }
48
+
49
+ const CHECKPOINT_TAG = /^[0-9a-f]{32}$/
50
+
51
+ /** The single d-tag of a checkpoint-shaped event, or null. Every checkpoint tag
52
+ * is `vaultCheckpointTag` output: 32 lowercase hex characters. Necessary, not
53
+ * sufficient: the publisher is encrypted, so the shape cannot prove the tag. */
54
+ export function vaultCheckpointShapedTag(event: { tags?: unknown }): string | null {
55
+ if (!event || !Array.isArray(event.tags)) return null
56
+ const dTags = event.tags.filter((t): t is unknown[] => Array.isArray(t) && t[0] === 'd')
57
+ if (dTags.length !== 1 || dTags[0].length !== 2 || typeof dTags[0][1] !== 'string' || !CHECKPOINT_TAG.test(dTags[0][1])) return null
58
+ return dTags[0][1]
59
+ }
60
+
61
+ export function vaultContentHash(content: string): string {
62
+ return bytesToHex(sha256(encoder.encode(content)))
63
+ }
64
+
65
+ /** Accept no partial checkpoint. A bad reference must never silently lose data. */
66
+ export function parseVaultCheckpoint(raw: string, expected: { purpose: string; rotation: number }): VaultCheckpoint | null {
67
+ if (typeof raw !== 'string' || encoder.encode(raw).length > MAX_VAULT_CONTROL_BYTES) return null
68
+ let v: unknown
69
+ try { v = JSON.parse(raw) } catch { return null }
70
+ if (!object(v) || v.v !== 1 || typeof v.purpose !== 'string' || !parseVaultPurpose(v.purpose)
71
+ || v.purpose !== expected.purpose || v.rotation !== expected.rotation || !uint(v.rotation) || v.rotation > 0xffff_ffff
72
+ || !uint(v.sequence) || typeof v.revision !== 'string' || !HEX.test(v.revision)) return null
73
+ if (!Array.isArray(v.devicePubkeys) || !v.devicePubkeys.length || v.devicePubkeys.length > MAX_VAULT_DEVICES
74
+ || v.devicePubkeys.some(p => typeof p !== 'string' || !HEX.test(p))
75
+ || new Set(v.devicePubkeys).size !== v.devicePubkeys.length) return null
76
+ if (v.publisher !== undefined && (typeof v.publisher !== 'string' || !v.devicePubkeys.includes(v.publisher))) return null
77
+ if (!Array.isArray(v.chunks) || !v.chunks.length || v.chunks.length > MAX_VAULT_CHUNKS) return null
78
+ const chunks: VaultChunkRef[] = []
79
+ for (const c of v.chunks) {
80
+ if (!object(c) || typeof c.eventId !== 'string' || !HEX.test(c.eventId)
81
+ || typeof c.author !== 'string' || !v.devicePubkeys.includes(c.author)
82
+ || typeof c.contentHash !== 'string' || !HEX.test(c.contentHash)
83
+ || !uint(c.contentBytes) || c.contentBytes === 0 || c.contentBytes > MAX_VAULT_CHUNK_BYTES) return null
84
+ chunks.push({ eventId: c.eventId, author: c.author, contentHash: c.contentHash, contentBytes: c.contentBytes })
85
+ }
86
+ if (new Set(chunks.map(c => c.eventId)).size !== chunks.length) return null
87
+ if (v.nextRotation !== undefined && (v.nextRotation !== v.rotation + 1 || v.nextRotation > 0xffff_ffff)) return null
88
+ return { v: 1, purpose: v.purpose, rotation: v.rotation, sequence: v.sequence,
89
+ revision: v.revision, ...(v.publisher === undefined ? {} : { publisher: v.publisher as string }), devicePubkeys: [...v.devicePubkeys] as string[], chunks,
90
+ ...(v.nextRotation === undefined ? {} : { nextRotation: v.nextRotation as number }) }
91
+ }
92
+
93
+ /** Integrity check before decrypting. Signature and event-ID verification is also required. */
94
+ export function matchesVaultChunk(ref: VaultChunkRef, event: { id: string; pubkey: string; kind: number; content: string }): boolean {
95
+ return event.id === ref.eventId && event.pubkey === ref.author && event.kind === VAULT_EVENT_KIND
96
+ && encoder.encode(event.content).length === ref.contentBytes
97
+ && vaultContentHash(event.content) === ref.contentHash
98
+ }
@@ -0,0 +1,59 @@
1
+ /** Private dataset identities, derived directly from the tree root.
2
+ * Identity ordinals belong in the purpose; the derivation index is rotation.
3
+ * This does not change any existing persona derivation.
4
+ */
5
+ import { derive, fromMnemonic } from 'nsec-tree'
6
+ import type { Identity, TreeRoot } from 'nsec-tree'
7
+
8
+ export type VaultDataset = 'profiles' | 'contacts:owner' | 'contacts:bots' | 'credentials' | 'settings'
9
+ | { dependant: number }
10
+
11
+ const FIXED = new Set(['profiles', 'contacts:owner', 'contacts:bots', 'credentials', 'settings'])
12
+ const MAX_INDEX = 0xffff_ffff
13
+
14
+ function ordinal(value: number): void {
15
+ if (!Number.isInteger(value) || value < 0 || value > MAX_INDEX) {
16
+ throw new Error('Vault ordinal must be an unsigned 32-bit integer')
17
+ }
18
+ }
19
+
20
+ export function vaultPurpose(dataset: VaultDataset): string {
21
+ if (typeof dataset === 'string' && FIXED.has(dataset)) return `signet:vault:${dataset}`
22
+ if (dataset && typeof dataset === 'object' && 'dependant' in dataset) {
23
+ ordinal(dataset.dependant)
24
+ return `signet:vault:contacts:dependant-${dataset.dependant}`
25
+ }
26
+ throw new Error('Unknown vault dataset')
27
+ }
28
+
29
+ /** Strict parser: aliases such as dependant-01 would derive a different key. */
30
+ export function parseVaultPurpose(purpose: string): VaultDataset | null {
31
+ if (typeof purpose !== 'string' || !purpose.startsWith('signet:vault:')) return null
32
+ const suffix = purpose.slice('signet:vault:'.length)
33
+ if (FIXED.has(suffix)) return suffix as VaultDataset
34
+ const match = /^contacts:dependant-(0|[1-9][0-9]*)$/.exec(suffix)
35
+ if (!match) return null
36
+ const dependant = Number(match[1])
37
+ return Number.isInteger(dependant) && dependant <= MAX_INDEX ? { dependant } : null
38
+ }
39
+
40
+ /** Suitable for Heartwood's per-request context; never a registry persona. */
41
+ export function vaultKeyContext(dataset: VaultDataset, rotation = 0): { purpose: string; index: number } {
42
+ ordinal(rotation)
43
+ return { purpose: vaultPurpose(dataset), index: rotation }
44
+ }
45
+
46
+ /** Caller owns and must zeroise the returned child. The supplied root is retained. */
47
+ export function deriveVaultIdentity(root: TreeRoot, dataset: VaultDataset, rotation = 0): Identity {
48
+ const { purpose, index } = vaultKeyContext(dataset, rotation)
49
+ return derive(root, purpose, index)
50
+ }
51
+
52
+ /** Keep root construction and use in one module instance (roots are opaque).
53
+ * Caller must zeroise the returned child; the temporary root is always destroyed.
54
+ */
55
+ export function vaultIdentityFromMnemonic(mnemonic: string, dataset: VaultDataset, rotation = 0): Identity {
56
+ const root = fromMnemonic(mnemonic)
57
+ try { return deriveVaultIdentity(root, dataset, rotation) }
58
+ finally { root.destroy() }
59
+ }