@orangecheck/sdk 1.17.0 → 1.18.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.
@@ -1,4 +1,4 @@
1
- import { I as IdentityBinding, S as Scheme, A as AttestationEnvelope } from './types-DsrYs3EB.mjs';
1
+ import { I as IdentityBinding, S as Scheme, A as AttestationEnvelope } from './types-BdJoNWgs.mjs';
2
2
 
3
3
  interface CanonicalInput {
4
4
  address: string;
@@ -12,8 +12,10 @@ interface BuildOptions {
12
12
  nonce?: string;
13
13
  issuedAt?: string;
14
14
  }
15
+ declare function readBondExtension(value: string): number | null;
15
16
  declare function buildCanonicalMessage({ address, identities }: CanonicalInput, extensions?: Extensions, options?: BuildOptions): string;
16
17
  declare function generateAttestationId(message: string): Promise<string>;
18
+ declare function readMessageValue(message: string, key: string): string | undefined;
17
19
  declare function createAttestationEnvelope(message: string, signature: string, scheme: Scheme, address: string, identities: IdentityBinding[], extensions?: Extensions): Promise<AttestationEnvelope>;
18
20
 
19
- export { type BuildOptions, type CanonicalInput, type Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, randomNonce16BHexLower };
21
+ export { type BuildOptions, type CanonicalInput, type Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, randomNonce16BHexLower, readBondExtension, readMessageValue };
@@ -1,4 +1,4 @@
1
- import { I as IdentityBinding, S as Scheme, A as AttestationEnvelope } from './types-DsrYs3EB.js';
1
+ import { I as IdentityBinding, S as Scheme, A as AttestationEnvelope } from './types-BdJoNWgs.js';
2
2
 
3
3
  interface CanonicalInput {
4
4
  address: string;
@@ -12,8 +12,10 @@ interface BuildOptions {
12
12
  nonce?: string;
13
13
  issuedAt?: string;
14
14
  }
15
+ declare function readBondExtension(value: string): number | null;
15
16
  declare function buildCanonicalMessage({ address, identities }: CanonicalInput, extensions?: Extensions, options?: BuildOptions): string;
16
17
  declare function generateAttestationId(message: string): Promise<string>;
18
+ declare function readMessageValue(message: string, key: string): string | undefined;
17
19
  declare function createAttestationEnvelope(message: string, signature: string, scheme: Scheme, address: string, identities: IdentityBinding[], extensions?: Extensions): Promise<AttestationEnvelope>;
18
20
 
19
- export { type BuildOptions, type CanonicalInput, type Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, randomNonce16BHexLower };
21
+ export { type BuildOptions, type CanonicalInput, type Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, randomNonce16BHexLower, readBondExtension, readMessageValue };
package/dist/canonical.js CHANGED
@@ -62,6 +62,11 @@ function parseIdentities(identitiesStr) {
62
62
  }
63
63
  return bindings;
64
64
  }
65
+ function readBondExtension(value) {
66
+ if (!/^[0-9]+$/.test(value)) return null;
67
+ const n = Number(value);
68
+ return Number.isSafeInteger(n) ? n : null;
69
+ }
65
70
  function buildCanonicalMessage({ address, identities }, extensions = {}, options = {}) {
66
71
  const nonce = options.nonce ?? randomNonce16BHexLower();
67
72
  if (!/^[0-9a-f]{32}$/.test(nonce)) {
@@ -84,6 +89,9 @@ function buildCanonicalMessage({ address, identities }, extensions = {}, options
84
89
  continue;
85
90
  }
86
91
  const key = k.toLowerCase();
92
+ if (key === "bond" && readBondExtension(String(v)) === null) {
93
+ throw new Error(`invalid bond: ${String(v)} is not a whole number of sats up to 2^53 - 1`);
94
+ }
87
95
  normExt[key] = String(v);
88
96
  }
89
97
  const extLines = Object.keys(normExt).sort().map((k) => `${k}: ${normExt[k]}`);
@@ -96,10 +104,15 @@ async function generateAttestationId(message) {
96
104
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
97
105
  return toLowerHex(new Uint8Array(hashBuffer));
98
106
  }
107
+ function readMessageValue(message, key) {
108
+ const needle = key + ":";
109
+ const hit = message.split("\n").map((l) => l.trim()).find((l) => l.toLowerCase().startsWith(needle));
110
+ if (!hit) return void 0;
111
+ return hit.slice(hit.indexOf(":") + 1).trim();
112
+ }
99
113
  async function createAttestationEnvelope(message, signature, scheme, address, identities, extensions = {}) {
100
114
  const attestation_id = await generateAttestationId(message);
101
- const issuedAtMatch = message.match(/issued_at: ([^\n]+)/);
102
- const issued_at = issuedAtMatch?.[1] ?? (/* @__PURE__ */ new Date()).toISOString();
115
+ const issued_at = readMessageValue(message, "issued_at") ?? (/* @__PURE__ */ new Date()).toISOString();
103
116
  const expires_at = extensions.expires;
104
117
  const relay_hints = extensions.relay_hints?.split(",").map((r) => r.trim());
105
118
  const publish_targets = extensions.publish?.split(",").map((t) => t.trim());
@@ -130,5 +143,7 @@ exports.formatIdentities = formatIdentities;
130
143
  exports.generateAttestationId = generateAttestationId;
131
144
  exports.parseIdentities = parseIdentities;
132
145
  exports.randomNonce16BHexLower = randomNonce16BHexLower;
146
+ exports.readBondExtension = readBondExtension;
147
+ exports.readMessageValue = readMessageValue;
133
148
  //# sourceMappingURL=canonical.js.map
134
149
  //# sourceMappingURL=canonical.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/canonical.ts"],"names":[],"mappings":"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","file":"canonical.js","sourcesContent":["/**\n * Canonical message builder per SPEC.md §2\n *\n * - LF line endings, exactly one trailing LF\n * - 7 fixed core lines in strict order and wording\n * - Optional extensions appended as key: value with lexicographically sorted lowercase keys\n */\n\nimport type { AttestationEnvelope, IdentityBinding, Scheme } from './types';\n\n/**\n * Input for building a canonical message\n */\nexport interface CanonicalInput {\n /** Bitcoin address */\n address: string;\n /** Optional identity bindings */\n identities?: IdentityBinding[];\n}\n\n/**\n * Extension key-value pairs for canonical message\n */\nexport type Extensions = Record<string, string | undefined>;\n\n/**\n * Convert bytes to lowercase hex string\n * @param bytes - Byte array to convert\n * @returns Lowercase hex string\n */\nfunction toLowerHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generate a random 16-byte nonce as lowercase hex\n * Uses Web Crypto API when available, falls back to Math.random()\n * @returns 32-character lowercase hex string\n */\nexport function randomNonce16BHexLower(): string {\n const arr = new Uint8Array(16);\n\n try {\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(arr);\n return toLowerHex(arr);\n }\n } catch {\n // Fall through to non-cryptographic fallback\n }\n\n // No silent Math.random fallback: a nonce is a load-bearing security\n // primitive. A deployment without `crypto.getRandomValues` MUST fix its\n // environment rather than sign with weak randomness.\n throw new Error('crypto.getRandomValues unavailable — refusing to mint a weak nonce');\n}\n\n/**\n * Reject characters that would break the line-oriented canonical grammar\n * or the comma-separated identities list. If any of these slipped through,\n * an attacker could smuggle a forged `address:` or extension line into the\n * signed payload: `identities: alice\\naddress: bc1qEVIL…` would parse as a\n * second address line. Catch it at the boundary, not at verification time.\n *\n * Also rejects a colon in the identifier so a bad actor can't turn\n * `github:alice:extra` into a different protocol binding on parse.\n */\nconst IDENTITY_PROTOCOL_RE = /^[a-z][a-z0-9_-]*$/;\nconst IDENTITY_FORBIDDEN_CHARS = /[\\r\\n,]/;\n\nfunction assertSafeIdentity({ protocol, identifier }: IdentityBinding): void {\n if (!IDENTITY_PROTOCOL_RE.test(protocol)) {\n throw new Error(\n `invalid identity protocol: ${JSON.stringify(protocol)} — must match /^[a-z][a-z0-9_-]*$/`\n );\n }\n if (!identifier || IDENTITY_FORBIDDEN_CHARS.test(identifier)) {\n throw new Error(\n `invalid identity identifier: ${JSON.stringify(identifier)} — must be non-empty and contain no newlines or commas`\n );\n }\n}\n\n/**\n * Format identities as comma-separated protocol:identifier pairs, sorted lexicographically\n * @param identities - Array of identity bindings\n * @returns Comma-separated string of protocol:identifier pairs\n */\nexport function formatIdentities(identities: IdentityBinding[] = []): string {\n if (identities.length === 0) {\n return '';\n }\n for (const id of identities) assertSafeIdentity(id);\n\n // Format as protocol:identifier and sort lexicographically\n const formatted = identities\n .map(({ protocol, identifier }) => `${protocol}:${identifier}`)\n .sort();\n\n return formatted.join(',');\n}\n\n/**\n * Parse identities from comma-separated protocol:identifier format\n * @param identitiesStr - Comma-separated identity string\n * @returns Array of identity bindings\n * @throws {Error} If identity format is invalid\n */\nexport function parseIdentities(identitiesStr: string): IdentityBinding[] {\n if (!identitiesStr || identitiesStr.trim() === '') {\n return [];\n }\n // The identities field lives on one line of the canonical message. If it\n // contains a raw LF/CR, it's either corrupt or an attempt to smuggle a\n // fake core line into the parser. Refuse instead of trying to recover.\n if (IDENTITY_FORBIDDEN_CHARS.test(identitiesStr.replace(/,/g, ''))) {\n throw new Error('identities field contains forbidden characters (newline)');\n }\n\n const bindings = identitiesStr.split(',').map((binding) => {\n const colonIndex = binding.indexOf(':');\n if (colonIndex === -1) {\n throw new Error(`Invalid identity binding format: ${binding}`);\n }\n return {\n protocol: binding.substring(0, colonIndex).trim(),\n identifier: binding.substring(colonIndex + 1).trim(),\n };\n });\n\n for (const b of bindings) {\n if (!b.protocol || !b.identifier) {\n throw new Error(`Invalid identity binding: ${b.protocol}:${b.identifier}`);\n }\n assertSafeIdentity(b);\n }\n return bindings;\n}\n\n/**\n * Optional deterministic overrides for message building. Only intended for\n * tests / conformance vectors; production callers should let the builder\n * mint a fresh nonce + timestamp per message.\n */\nexport interface BuildOptions {\n /** 32-char lowercase hex. Default: freshly-generated cryptographic nonce. */\n nonce?: string;\n /** RFC-3339 UTC. Default: `new Date().toISOString()`. */\n issuedAt?: string;\n}\n\n/**\n * Build a canonical message following OrangeCheck Protocol v0 specification\n * @param input - Canonical input with address and identities\n * @param extensions - Optional extension key-value pairs\n * @param options - Optional deterministic overrides (testing only)\n * @returns Canonical message string with LF line endings\n */\nexport function buildCanonicalMessage(\n { address, identities }: CanonicalInput,\n extensions: Extensions = {},\n options: BuildOptions = {}\n): string {\n const nonce = options.nonce ?? randomNonce16BHexLower();\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error(`invalid nonce: must be 32 lowercase hex chars`);\n }\n const issuedAt = options.issuedAt ?? new Date().toISOString();\n\n // Format identities\n const identitiesStr = formatIdentities(identities);\n\n // Core (strict wording + order per SPEC.md §2)\n const core = [\n 'orangecheck',\n `identities: ${identitiesStr}`,\n `address: ${address}`,\n 'purpose: portable reputation attestation (non-custodial)',\n `nonce: ${nonce}`,\n `issued_at: ${issuedAt}`,\n 'ack: I attest control of this address and bind it to my identities.',\n ];\n\n // Normalize and sort extensions\n const normExt: Record<string, string> = {};\n for (const [k, v] of Object.entries(extensions)) {\n if (v == null || v === '') {\n continue;\n }\n const key = k.toLowerCase();\n normExt[key] = String(v);\n }\n\n const extLines = Object.keys(normExt)\n .sort()\n .map((k) => `${k}: ${normExt[k]!}`);\n\n const all = [...core, ...extLines];\n\n // Join with LF and ensure exactly one trailing LF\n return all.join('\\n') + '\\n';\n}\n\n/**\n * Generate attestation ID from canonical message\n *\n * attestation_id = SHA-256(canonical_message)\n *\n * @param message - Canonical message string\n * @returns Lowercase hex SHA-256 hash\n */\nexport async function generateAttestationId(message: string): Promise<string> {\n // Convert message to UTF-8 bytes\n const encoder = new TextEncoder();\n const data = encoder.encode(message);\n\n // Compute SHA-256 hash\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n // Convert to lowercase hex\n return toLowerHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Create an attestation envelope for publishing\n *\n * This wraps the canonical message with metadata for Nostr publishing\n *\n * @param message - Canonical message\n * @param signature - BIP-322 or legacy signature\n * @param scheme - Signature scheme used\n * @param address - Bitcoin address\n * @param identities - Identity bindings\n * @param extensions - Optional extensions\n * @returns Attestation envelope ready for publishing\n */\nexport async function createAttestationEnvelope(\n message: string,\n signature: string,\n scheme: Scheme,\n address: string,\n identities: IdentityBinding[],\n extensions: Extensions = {}\n): Promise<AttestationEnvelope> {\n const attestation_id = await generateAttestationId(message);\n\n // Extract issued_at from message\n const issuedAtMatch = message.match(/issued_at: ([^\\n]+)/);\n const issued_at = issuedAtMatch?.[1] ?? new Date().toISOString();\n\n // Extract expires from extensions if present\n const expires_at = extensions.expires;\n\n // Extract relay hints from extensions if present\n const relay_hints = extensions.relay_hints?.split(',').map((r) => r.trim());\n\n // Extract publish targets from extensions if present\n const publish_targets = extensions.publish?.split(',').map((t) => t.trim());\n\n // Base64url encode the message\n const encoder = new TextEncoder();\n const messageBytes = encoder.encode(message);\n const base64 = btoa(String.fromCharCode(...messageBytes));\n const message_b64url = base64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n\n // Construct verification URL\n const verification_url = `https://attest.ochk.io/attest/${attestation_id}`;\n\n return {\n attestation_id,\n scheme,\n address,\n identities,\n message,\n message_b64url,\n signature,\n issued_at,\n expires_at,\n verification_url,\n publish_targets,\n relay_hints,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/canonical.ts"],"names":[],"mappings":"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","file":"canonical.js","sourcesContent":["/**\n * Canonical message builder per SPEC.md §2\n *\n * - LF line endings, exactly one trailing LF\n * - 7 fixed core lines in strict order and wording\n * - Optional extensions appended as key: value with lexicographically sorted lowercase keys\n */\n\nimport type { AttestationEnvelope, IdentityBinding, Scheme } from './types';\n\n/**\n * Input for building a canonical message\n */\nexport interface CanonicalInput {\n /** Bitcoin address */\n address: string;\n /** Optional identity bindings */\n identities?: IdentityBinding[];\n}\n\n/**\n * Extension key-value pairs for canonical message\n */\nexport type Extensions = Record<string, string | undefined>;\n\n/**\n * Convert bytes to lowercase hex string\n * @param bytes - Byte array to convert\n * @returns Lowercase hex string\n */\nfunction toLowerHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generate a random 16-byte nonce as lowercase hex\n * Uses Web Crypto API when available, falls back to Math.random()\n * @returns 32-character lowercase hex string\n */\nexport function randomNonce16BHexLower(): string {\n const arr = new Uint8Array(16);\n\n try {\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(arr);\n return toLowerHex(arr);\n }\n } catch {\n // Fall through to non-cryptographic fallback\n }\n\n // No silent Math.random fallback: a nonce is a load-bearing security\n // primitive. A deployment without `crypto.getRandomValues` MUST fix its\n // environment rather than sign with weak randomness.\n throw new Error('crypto.getRandomValues unavailable — refusing to mint a weak nonce');\n}\n\n/**\n * Reject characters that would break the line-oriented canonical grammar\n * or the comma-separated identities list. If any of these slipped through,\n * an attacker could smuggle a forged `address:` or extension line into the\n * signed payload: `identities: alice\\naddress: bc1qEVIL…` would parse as a\n * second address line. Catch it at the boundary, not at verification time.\n *\n * Also rejects a colon in the identifier so a bad actor can't turn\n * `github:alice:extra` into a different protocol binding on parse.\n */\nconst IDENTITY_PROTOCOL_RE = /^[a-z][a-z0-9_-]*$/;\nconst IDENTITY_FORBIDDEN_CHARS = /[\\r\\n,]/;\n\nfunction assertSafeIdentity({ protocol, identifier }: IdentityBinding): void {\n if (!IDENTITY_PROTOCOL_RE.test(protocol)) {\n throw new Error(\n `invalid identity protocol: ${JSON.stringify(protocol)} — must match /^[a-z][a-z0-9_-]*$/`\n );\n }\n if (!identifier || IDENTITY_FORBIDDEN_CHARS.test(identifier)) {\n throw new Error(\n `invalid identity identifier: ${JSON.stringify(identifier)} — must be non-empty and contain no newlines or commas`\n );\n }\n}\n\n/**\n * Format identities as comma-separated protocol:identifier pairs, sorted lexicographically\n * @param identities - Array of identity bindings\n * @returns Comma-separated string of protocol:identifier pairs\n */\nexport function formatIdentities(identities: IdentityBinding[] = []): string {\n if (identities.length === 0) {\n return '';\n }\n for (const id of identities) assertSafeIdentity(id);\n\n // Format as protocol:identifier and sort lexicographically\n const formatted = identities\n .map(({ protocol, identifier }) => `${protocol}:${identifier}`)\n .sort();\n\n return formatted.join(',');\n}\n\n/**\n * Parse identities from comma-separated protocol:identifier format\n * @param identitiesStr - Comma-separated identity string\n * @returns Array of identity bindings\n * @throws {Error} If identity format is invalid\n */\nexport function parseIdentities(identitiesStr: string): IdentityBinding[] {\n if (!identitiesStr || identitiesStr.trim() === '') {\n return [];\n }\n // The identities field lives on one line of the canonical message. If it\n // contains a raw LF/CR, it's either corrupt or an attempt to smuggle a\n // fake core line into the parser. Refuse instead of trying to recover.\n if (IDENTITY_FORBIDDEN_CHARS.test(identitiesStr.replace(/,/g, ''))) {\n throw new Error('identities field contains forbidden characters (newline)');\n }\n\n const bindings = identitiesStr.split(',').map((binding) => {\n const colonIndex = binding.indexOf(':');\n if (colonIndex === -1) {\n throw new Error(`Invalid identity binding format: ${binding}`);\n }\n return {\n protocol: binding.substring(0, colonIndex).trim(),\n identifier: binding.substring(colonIndex + 1).trim(),\n };\n });\n\n for (const b of bindings) {\n if (!b.protocol || !b.identifier) {\n throw new Error(`Invalid identity binding: ${b.protocol}:${b.identifier}`);\n }\n assertSafeIdentity(b);\n }\n return bindings;\n}\n\n/**\n * Optional deterministic overrides for message building. Only intended for\n * tests / conformance vectors; production callers should let the builder\n * mint a fresh nonce + timestamp per message.\n */\nexport interface BuildOptions {\n /** 32-char lowercase hex. Default: freshly-generated cryptographic nonce. */\n nonce?: string;\n /** RFC-3339 UTC. Default: `new Date().toISOString()`. */\n issuedAt?: string;\n}\n\n/**\n * The amount a `bond:` value gives, or null when SPEC §2.2 does not allow it:\n * base-10 ASCII digits only, at most 2^53 - 1. parseInt would read \"5000abc\"\n * as 5000 and \"1e9\" as 1.\n */\nexport function readBondExtension(value: string): number | null {\n if (!/^[0-9]+$/.test(value)) return null;\n const n = Number(value);\n return Number.isSafeInteger(n) ? n : null;\n}\n\n/**\n * Build a canonical message following OrangeCheck Protocol v0 specification\n * @param input - Canonical input with address and identities\n * @param extensions - Optional extension key-value pairs\n * @param options - Optional deterministic overrides (testing only)\n * @returns Canonical message string with LF line endings\n */\nexport function buildCanonicalMessage(\n { address, identities }: CanonicalInput,\n extensions: Extensions = {},\n options: BuildOptions = {}\n): string {\n const nonce = options.nonce ?? randomNonce16BHexLower();\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error(`invalid nonce: must be 32 lowercase hex chars`);\n }\n const issuedAt = options.issuedAt ?? new Date().toISOString();\n\n // Format identities\n const identitiesStr = formatIdentities(identities);\n\n // Core (strict wording + order per SPEC.md §2)\n const core = [\n 'orangecheck',\n `identities: ${identitiesStr}`,\n `address: ${address}`,\n 'purpose: portable reputation attestation (non-custodial)',\n `nonce: ${nonce}`,\n `issued_at: ${issuedAt}`,\n 'ack: I attest control of this address and bind it to my identities.',\n ];\n\n // Normalize and sort extensions\n const normExt: Record<string, string> = {};\n for (const [k, v] of Object.entries(extensions)) {\n if (v == null || v === '') {\n continue;\n }\n const key = k.toLowerCase();\n // A bond is written as SPEC §2.2 reads it, so no verifier refuses it as bond_malformed.\n if (key === 'bond' && readBondExtension(String(v)) === null) {\n throw new Error(`invalid bond: ${String(v)} is not a whole number of sats up to 2^53 - 1`);\n }\n normExt[key] = String(v);\n }\n\n const extLines = Object.keys(normExt)\n .sort()\n .map((k) => `${k}: ${normExt[k]!}`);\n\n const all = [...core, ...extLines];\n\n // Join with LF and ensure exactly one trailing LF\n return all.join('\\n') + '\\n';\n}\n\n/**\n * Generate attestation ID from canonical message\n *\n * attestation_id = SHA-256(canonical_message)\n *\n * @param message - Canonical message string\n * @returns Lowercase hex SHA-256 hash\n */\nexport async function generateAttestationId(message: string): Promise<string> {\n // Convert message to UTF-8 bytes\n const encoder = new TextEncoder();\n const data = encoder.encode(message);\n\n // Compute SHA-256 hash\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n // Convert to lowercase hex\n return toLowerHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * A signed line's value as verify() reads it: the first trimmed line whose key,\n * in any case, is `key`, split at its first colon. Not exported from the package.\n */\nexport function readMessageValue(message: string, key: string): string | undefined {\n const needle = key + ':';\n const hit = message\n .split('\\n')\n .map((l) => l.trim())\n .find((l) => l.toLowerCase().startsWith(needle));\n if (!hit) return undefined;\n return hit.slice(hit.indexOf(':') + 1).trim();\n}\n\n/**\n * Create an attestation envelope for publishing\n *\n * This wraps the canonical message with metadata for Nostr publishing\n *\n * @param message - Canonical message\n * @param signature - BIP-322 or legacy signature\n * @param scheme - Signature scheme used\n * @param address - Bitcoin address\n * @param identities - Identity bindings\n * @param extensions - Optional extensions\n * @returns Attestation envelope ready for publishing\n */\nexport async function createAttestationEnvelope(\n message: string,\n signature: string,\n scheme: Scheme,\n address: string,\n identities: IdentityBinding[],\n extensions: Extensions = {}\n): Promise<AttestationEnvelope> {\n const attestation_id = await generateAttestationId(message);\n\n // The signed line verify() reads, not the first \"issued_at: \" anywhere in the text.\n const issued_at = readMessageValue(message, 'issued_at') ?? new Date().toISOString();\n\n // Extract expires from extensions if present\n const expires_at = extensions.expires;\n\n // Extract relay hints from extensions if present\n const relay_hints = extensions.relay_hints?.split(',').map((r) => r.trim());\n\n // Extract publish targets from extensions if present\n const publish_targets = extensions.publish?.split(',').map((t) => t.trim());\n\n // Base64url encode the message\n const encoder = new TextEncoder();\n const messageBytes = encoder.encode(message);\n const base64 = btoa(String.fromCharCode(...messageBytes));\n const message_b64url = base64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n\n // Construct verification URL\n const verification_url = `https://attest.ochk.io/attest/${attestation_id}`;\n\n return {\n attestation_id,\n scheme,\n address,\n identities,\n message,\n message_b64url,\n signature,\n issued_at,\n expires_at,\n verification_url,\n publish_targets,\n relay_hints,\n };\n}\n"]}
@@ -60,6 +60,11 @@ function parseIdentities(identitiesStr) {
60
60
  }
61
61
  return bindings;
62
62
  }
63
+ function readBondExtension(value) {
64
+ if (!/^[0-9]+$/.test(value)) return null;
65
+ const n = Number(value);
66
+ return Number.isSafeInteger(n) ? n : null;
67
+ }
63
68
  function buildCanonicalMessage({ address, identities }, extensions = {}, options = {}) {
64
69
  const nonce = options.nonce ?? randomNonce16BHexLower();
65
70
  if (!/^[0-9a-f]{32}$/.test(nonce)) {
@@ -82,6 +87,9 @@ function buildCanonicalMessage({ address, identities }, extensions = {}, options
82
87
  continue;
83
88
  }
84
89
  const key = k.toLowerCase();
90
+ if (key === "bond" && readBondExtension(String(v)) === null) {
91
+ throw new Error(`invalid bond: ${String(v)} is not a whole number of sats up to 2^53 - 1`);
92
+ }
85
93
  normExt[key] = String(v);
86
94
  }
87
95
  const extLines = Object.keys(normExt).sort().map((k) => `${k}: ${normExt[k]}`);
@@ -94,10 +102,15 @@ async function generateAttestationId(message) {
94
102
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
95
103
  return toLowerHex(new Uint8Array(hashBuffer));
96
104
  }
105
+ function readMessageValue(message, key) {
106
+ const needle = key + ":";
107
+ const hit = message.split("\n").map((l) => l.trim()).find((l) => l.toLowerCase().startsWith(needle));
108
+ if (!hit) return void 0;
109
+ return hit.slice(hit.indexOf(":") + 1).trim();
110
+ }
97
111
  async function createAttestationEnvelope(message, signature, scheme, address, identities, extensions = {}) {
98
112
  const attestation_id = await generateAttestationId(message);
99
- const issuedAtMatch = message.match(/issued_at: ([^\n]+)/);
100
- const issued_at = issuedAtMatch?.[1] ?? (/* @__PURE__ */ new Date()).toISOString();
113
+ const issued_at = readMessageValue(message, "issued_at") ?? (/* @__PURE__ */ new Date()).toISOString();
101
114
  const expires_at = extensions.expires;
102
115
  const relay_hints = extensions.relay_hints?.split(",").map((r) => r.trim());
103
116
  const publish_targets = extensions.publish?.split(",").map((t) => t.trim());
@@ -122,6 +135,6 @@ async function createAttestationEnvelope(message, signature, scheme, address, id
122
135
  };
123
136
  }
124
137
 
125
- export { buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, randomNonce16BHexLower };
138
+ export { buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, randomNonce16BHexLower, readBondExtension, readMessageValue };
126
139
  //# sourceMappingURL=canonical.mjs.map
127
140
  //# sourceMappingURL=canonical.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/canonical.ts"],"names":[],"mappings":"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","file":"canonical.mjs","sourcesContent":["/**\n * Canonical message builder per SPEC.md §2\n *\n * - LF line endings, exactly one trailing LF\n * - 7 fixed core lines in strict order and wording\n * - Optional extensions appended as key: value with lexicographically sorted lowercase keys\n */\n\nimport type { AttestationEnvelope, IdentityBinding, Scheme } from './types';\n\n/**\n * Input for building a canonical message\n */\nexport interface CanonicalInput {\n /** Bitcoin address */\n address: string;\n /** Optional identity bindings */\n identities?: IdentityBinding[];\n}\n\n/**\n * Extension key-value pairs for canonical message\n */\nexport type Extensions = Record<string, string | undefined>;\n\n/**\n * Convert bytes to lowercase hex string\n * @param bytes - Byte array to convert\n * @returns Lowercase hex string\n */\nfunction toLowerHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generate a random 16-byte nonce as lowercase hex\n * Uses Web Crypto API when available, falls back to Math.random()\n * @returns 32-character lowercase hex string\n */\nexport function randomNonce16BHexLower(): string {\n const arr = new Uint8Array(16);\n\n try {\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(arr);\n return toLowerHex(arr);\n }\n } catch {\n // Fall through to non-cryptographic fallback\n }\n\n // No silent Math.random fallback: a nonce is a load-bearing security\n // primitive. A deployment without `crypto.getRandomValues` MUST fix its\n // environment rather than sign with weak randomness.\n throw new Error('crypto.getRandomValues unavailable — refusing to mint a weak nonce');\n}\n\n/**\n * Reject characters that would break the line-oriented canonical grammar\n * or the comma-separated identities list. If any of these slipped through,\n * an attacker could smuggle a forged `address:` or extension line into the\n * signed payload: `identities: alice\\naddress: bc1qEVIL…` would parse as a\n * second address line. Catch it at the boundary, not at verification time.\n *\n * Also rejects a colon in the identifier so a bad actor can't turn\n * `github:alice:extra` into a different protocol binding on parse.\n */\nconst IDENTITY_PROTOCOL_RE = /^[a-z][a-z0-9_-]*$/;\nconst IDENTITY_FORBIDDEN_CHARS = /[\\r\\n,]/;\n\nfunction assertSafeIdentity({ protocol, identifier }: IdentityBinding): void {\n if (!IDENTITY_PROTOCOL_RE.test(protocol)) {\n throw new Error(\n `invalid identity protocol: ${JSON.stringify(protocol)} — must match /^[a-z][a-z0-9_-]*$/`\n );\n }\n if (!identifier || IDENTITY_FORBIDDEN_CHARS.test(identifier)) {\n throw new Error(\n `invalid identity identifier: ${JSON.stringify(identifier)} — must be non-empty and contain no newlines or commas`\n );\n }\n}\n\n/**\n * Format identities as comma-separated protocol:identifier pairs, sorted lexicographically\n * @param identities - Array of identity bindings\n * @returns Comma-separated string of protocol:identifier pairs\n */\nexport function formatIdentities(identities: IdentityBinding[] = []): string {\n if (identities.length === 0) {\n return '';\n }\n for (const id of identities) assertSafeIdentity(id);\n\n // Format as protocol:identifier and sort lexicographically\n const formatted = identities\n .map(({ protocol, identifier }) => `${protocol}:${identifier}`)\n .sort();\n\n return formatted.join(',');\n}\n\n/**\n * Parse identities from comma-separated protocol:identifier format\n * @param identitiesStr - Comma-separated identity string\n * @returns Array of identity bindings\n * @throws {Error} If identity format is invalid\n */\nexport function parseIdentities(identitiesStr: string): IdentityBinding[] {\n if (!identitiesStr || identitiesStr.trim() === '') {\n return [];\n }\n // The identities field lives on one line of the canonical message. If it\n // contains a raw LF/CR, it's either corrupt or an attempt to smuggle a\n // fake core line into the parser. Refuse instead of trying to recover.\n if (IDENTITY_FORBIDDEN_CHARS.test(identitiesStr.replace(/,/g, ''))) {\n throw new Error('identities field contains forbidden characters (newline)');\n }\n\n const bindings = identitiesStr.split(',').map((binding) => {\n const colonIndex = binding.indexOf(':');\n if (colonIndex === -1) {\n throw new Error(`Invalid identity binding format: ${binding}`);\n }\n return {\n protocol: binding.substring(0, colonIndex).trim(),\n identifier: binding.substring(colonIndex + 1).trim(),\n };\n });\n\n for (const b of bindings) {\n if (!b.protocol || !b.identifier) {\n throw new Error(`Invalid identity binding: ${b.protocol}:${b.identifier}`);\n }\n assertSafeIdentity(b);\n }\n return bindings;\n}\n\n/**\n * Optional deterministic overrides for message building. Only intended for\n * tests / conformance vectors; production callers should let the builder\n * mint a fresh nonce + timestamp per message.\n */\nexport interface BuildOptions {\n /** 32-char lowercase hex. Default: freshly-generated cryptographic nonce. */\n nonce?: string;\n /** RFC-3339 UTC. Default: `new Date().toISOString()`. */\n issuedAt?: string;\n}\n\n/**\n * Build a canonical message following OrangeCheck Protocol v0 specification\n * @param input - Canonical input with address and identities\n * @param extensions - Optional extension key-value pairs\n * @param options - Optional deterministic overrides (testing only)\n * @returns Canonical message string with LF line endings\n */\nexport function buildCanonicalMessage(\n { address, identities }: CanonicalInput,\n extensions: Extensions = {},\n options: BuildOptions = {}\n): string {\n const nonce = options.nonce ?? randomNonce16BHexLower();\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error(`invalid nonce: must be 32 lowercase hex chars`);\n }\n const issuedAt = options.issuedAt ?? new Date().toISOString();\n\n // Format identities\n const identitiesStr = formatIdentities(identities);\n\n // Core (strict wording + order per SPEC.md §2)\n const core = [\n 'orangecheck',\n `identities: ${identitiesStr}`,\n `address: ${address}`,\n 'purpose: portable reputation attestation (non-custodial)',\n `nonce: ${nonce}`,\n `issued_at: ${issuedAt}`,\n 'ack: I attest control of this address and bind it to my identities.',\n ];\n\n // Normalize and sort extensions\n const normExt: Record<string, string> = {};\n for (const [k, v] of Object.entries(extensions)) {\n if (v == null || v === '') {\n continue;\n }\n const key = k.toLowerCase();\n normExt[key] = String(v);\n }\n\n const extLines = Object.keys(normExt)\n .sort()\n .map((k) => `${k}: ${normExt[k]!}`);\n\n const all = [...core, ...extLines];\n\n // Join with LF and ensure exactly one trailing LF\n return all.join('\\n') + '\\n';\n}\n\n/**\n * Generate attestation ID from canonical message\n *\n * attestation_id = SHA-256(canonical_message)\n *\n * @param message - Canonical message string\n * @returns Lowercase hex SHA-256 hash\n */\nexport async function generateAttestationId(message: string): Promise<string> {\n // Convert message to UTF-8 bytes\n const encoder = new TextEncoder();\n const data = encoder.encode(message);\n\n // Compute SHA-256 hash\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n // Convert to lowercase hex\n return toLowerHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Create an attestation envelope for publishing\n *\n * This wraps the canonical message with metadata for Nostr publishing\n *\n * @param message - Canonical message\n * @param signature - BIP-322 or legacy signature\n * @param scheme - Signature scheme used\n * @param address - Bitcoin address\n * @param identities - Identity bindings\n * @param extensions - Optional extensions\n * @returns Attestation envelope ready for publishing\n */\nexport async function createAttestationEnvelope(\n message: string,\n signature: string,\n scheme: Scheme,\n address: string,\n identities: IdentityBinding[],\n extensions: Extensions = {}\n): Promise<AttestationEnvelope> {\n const attestation_id = await generateAttestationId(message);\n\n // Extract issued_at from message\n const issuedAtMatch = message.match(/issued_at: ([^\\n]+)/);\n const issued_at = issuedAtMatch?.[1] ?? new Date().toISOString();\n\n // Extract expires from extensions if present\n const expires_at = extensions.expires;\n\n // Extract relay hints from extensions if present\n const relay_hints = extensions.relay_hints?.split(',').map((r) => r.trim());\n\n // Extract publish targets from extensions if present\n const publish_targets = extensions.publish?.split(',').map((t) => t.trim());\n\n // Base64url encode the message\n const encoder = new TextEncoder();\n const messageBytes = encoder.encode(message);\n const base64 = btoa(String.fromCharCode(...messageBytes));\n const message_b64url = base64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n\n // Construct verification URL\n const verification_url = `https://attest.ochk.io/attest/${attestation_id}`;\n\n return {\n attestation_id,\n scheme,\n address,\n identities,\n message,\n message_b64url,\n signature,\n issued_at,\n expires_at,\n verification_url,\n publish_targets,\n relay_hints,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/canonical.ts"],"names":[],"mappings":"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","file":"canonical.mjs","sourcesContent":["/**\n * Canonical message builder per SPEC.md §2\n *\n * - LF line endings, exactly one trailing LF\n * - 7 fixed core lines in strict order and wording\n * - Optional extensions appended as key: value with lexicographically sorted lowercase keys\n */\n\nimport type { AttestationEnvelope, IdentityBinding, Scheme } from './types';\n\n/**\n * Input for building a canonical message\n */\nexport interface CanonicalInput {\n /** Bitcoin address */\n address: string;\n /** Optional identity bindings */\n identities?: IdentityBinding[];\n}\n\n/**\n * Extension key-value pairs for canonical message\n */\nexport type Extensions = Record<string, string | undefined>;\n\n/**\n * Convert bytes to lowercase hex string\n * @param bytes - Byte array to convert\n * @returns Lowercase hex string\n */\nfunction toLowerHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generate a random 16-byte nonce as lowercase hex\n * Uses Web Crypto API when available, falls back to Math.random()\n * @returns 32-character lowercase hex string\n */\nexport function randomNonce16BHexLower(): string {\n const arr = new Uint8Array(16);\n\n try {\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(arr);\n return toLowerHex(arr);\n }\n } catch {\n // Fall through to non-cryptographic fallback\n }\n\n // No silent Math.random fallback: a nonce is a load-bearing security\n // primitive. A deployment without `crypto.getRandomValues` MUST fix its\n // environment rather than sign with weak randomness.\n throw new Error('crypto.getRandomValues unavailable — refusing to mint a weak nonce');\n}\n\n/**\n * Reject characters that would break the line-oriented canonical grammar\n * or the comma-separated identities list. If any of these slipped through,\n * an attacker could smuggle a forged `address:` or extension line into the\n * signed payload: `identities: alice\\naddress: bc1qEVIL…` would parse as a\n * second address line. Catch it at the boundary, not at verification time.\n *\n * Also rejects a colon in the identifier so a bad actor can't turn\n * `github:alice:extra` into a different protocol binding on parse.\n */\nconst IDENTITY_PROTOCOL_RE = /^[a-z][a-z0-9_-]*$/;\nconst IDENTITY_FORBIDDEN_CHARS = /[\\r\\n,]/;\n\nfunction assertSafeIdentity({ protocol, identifier }: IdentityBinding): void {\n if (!IDENTITY_PROTOCOL_RE.test(protocol)) {\n throw new Error(\n `invalid identity protocol: ${JSON.stringify(protocol)} — must match /^[a-z][a-z0-9_-]*$/`\n );\n }\n if (!identifier || IDENTITY_FORBIDDEN_CHARS.test(identifier)) {\n throw new Error(\n `invalid identity identifier: ${JSON.stringify(identifier)} — must be non-empty and contain no newlines or commas`\n );\n }\n}\n\n/**\n * Format identities as comma-separated protocol:identifier pairs, sorted lexicographically\n * @param identities - Array of identity bindings\n * @returns Comma-separated string of protocol:identifier pairs\n */\nexport function formatIdentities(identities: IdentityBinding[] = []): string {\n if (identities.length === 0) {\n return '';\n }\n for (const id of identities) assertSafeIdentity(id);\n\n // Format as protocol:identifier and sort lexicographically\n const formatted = identities\n .map(({ protocol, identifier }) => `${protocol}:${identifier}`)\n .sort();\n\n return formatted.join(',');\n}\n\n/**\n * Parse identities from comma-separated protocol:identifier format\n * @param identitiesStr - Comma-separated identity string\n * @returns Array of identity bindings\n * @throws {Error} If identity format is invalid\n */\nexport function parseIdentities(identitiesStr: string): IdentityBinding[] {\n if (!identitiesStr || identitiesStr.trim() === '') {\n return [];\n }\n // The identities field lives on one line of the canonical message. If it\n // contains a raw LF/CR, it's either corrupt or an attempt to smuggle a\n // fake core line into the parser. Refuse instead of trying to recover.\n if (IDENTITY_FORBIDDEN_CHARS.test(identitiesStr.replace(/,/g, ''))) {\n throw new Error('identities field contains forbidden characters (newline)');\n }\n\n const bindings = identitiesStr.split(',').map((binding) => {\n const colonIndex = binding.indexOf(':');\n if (colonIndex === -1) {\n throw new Error(`Invalid identity binding format: ${binding}`);\n }\n return {\n protocol: binding.substring(0, colonIndex).trim(),\n identifier: binding.substring(colonIndex + 1).trim(),\n };\n });\n\n for (const b of bindings) {\n if (!b.protocol || !b.identifier) {\n throw new Error(`Invalid identity binding: ${b.protocol}:${b.identifier}`);\n }\n assertSafeIdentity(b);\n }\n return bindings;\n}\n\n/**\n * Optional deterministic overrides for message building. Only intended for\n * tests / conformance vectors; production callers should let the builder\n * mint a fresh nonce + timestamp per message.\n */\nexport interface BuildOptions {\n /** 32-char lowercase hex. Default: freshly-generated cryptographic nonce. */\n nonce?: string;\n /** RFC-3339 UTC. Default: `new Date().toISOString()`. */\n issuedAt?: string;\n}\n\n/**\n * The amount a `bond:` value gives, or null when SPEC §2.2 does not allow it:\n * base-10 ASCII digits only, at most 2^53 - 1. parseInt would read \"5000abc\"\n * as 5000 and \"1e9\" as 1.\n */\nexport function readBondExtension(value: string): number | null {\n if (!/^[0-9]+$/.test(value)) return null;\n const n = Number(value);\n return Number.isSafeInteger(n) ? n : null;\n}\n\n/**\n * Build a canonical message following OrangeCheck Protocol v0 specification\n * @param input - Canonical input with address and identities\n * @param extensions - Optional extension key-value pairs\n * @param options - Optional deterministic overrides (testing only)\n * @returns Canonical message string with LF line endings\n */\nexport function buildCanonicalMessage(\n { address, identities }: CanonicalInput,\n extensions: Extensions = {},\n options: BuildOptions = {}\n): string {\n const nonce = options.nonce ?? randomNonce16BHexLower();\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error(`invalid nonce: must be 32 lowercase hex chars`);\n }\n const issuedAt = options.issuedAt ?? new Date().toISOString();\n\n // Format identities\n const identitiesStr = formatIdentities(identities);\n\n // Core (strict wording + order per SPEC.md §2)\n const core = [\n 'orangecheck',\n `identities: ${identitiesStr}`,\n `address: ${address}`,\n 'purpose: portable reputation attestation (non-custodial)',\n `nonce: ${nonce}`,\n `issued_at: ${issuedAt}`,\n 'ack: I attest control of this address and bind it to my identities.',\n ];\n\n // Normalize and sort extensions\n const normExt: Record<string, string> = {};\n for (const [k, v] of Object.entries(extensions)) {\n if (v == null || v === '') {\n continue;\n }\n const key = k.toLowerCase();\n // A bond is written as SPEC §2.2 reads it, so no verifier refuses it as bond_malformed.\n if (key === 'bond' && readBondExtension(String(v)) === null) {\n throw new Error(`invalid bond: ${String(v)} is not a whole number of sats up to 2^53 - 1`);\n }\n normExt[key] = String(v);\n }\n\n const extLines = Object.keys(normExt)\n .sort()\n .map((k) => `${k}: ${normExt[k]!}`);\n\n const all = [...core, ...extLines];\n\n // Join with LF and ensure exactly one trailing LF\n return all.join('\\n') + '\\n';\n}\n\n/**\n * Generate attestation ID from canonical message\n *\n * attestation_id = SHA-256(canonical_message)\n *\n * @param message - Canonical message string\n * @returns Lowercase hex SHA-256 hash\n */\nexport async function generateAttestationId(message: string): Promise<string> {\n // Convert message to UTF-8 bytes\n const encoder = new TextEncoder();\n const data = encoder.encode(message);\n\n // Compute SHA-256 hash\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n\n // Convert to lowercase hex\n return toLowerHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * A signed line's value as verify() reads it: the first trimmed line whose key,\n * in any case, is `key`, split at its first colon. Not exported from the package.\n */\nexport function readMessageValue(message: string, key: string): string | undefined {\n const needle = key + ':';\n const hit = message\n .split('\\n')\n .map((l) => l.trim())\n .find((l) => l.toLowerCase().startsWith(needle));\n if (!hit) return undefined;\n return hit.slice(hit.indexOf(':') + 1).trim();\n}\n\n/**\n * Create an attestation envelope for publishing\n *\n * This wraps the canonical message with metadata for Nostr publishing\n *\n * @param message - Canonical message\n * @param signature - BIP-322 or legacy signature\n * @param scheme - Signature scheme used\n * @param address - Bitcoin address\n * @param identities - Identity bindings\n * @param extensions - Optional extensions\n * @returns Attestation envelope ready for publishing\n */\nexport async function createAttestationEnvelope(\n message: string,\n signature: string,\n scheme: Scheme,\n address: string,\n identities: IdentityBinding[],\n extensions: Extensions = {}\n): Promise<AttestationEnvelope> {\n const attestation_id = await generateAttestationId(message);\n\n // The signed line verify() reads, not the first \"issued_at: \" anywhere in the text.\n const issued_at = readMessageValue(message, 'issued_at') ?? new Date().toISOString();\n\n // Extract expires from extensions if present\n const expires_at = extensions.expires;\n\n // Extract relay hints from extensions if present\n const relay_hints = extensions.relay_hints?.split(',').map((r) => r.trim());\n\n // Extract publish targets from extensions if present\n const publish_targets = extensions.publish?.split(',').map((t) => t.trim());\n\n // Base64url encode the message\n const encoder = new TextEncoder();\n const messageBytes = encoder.encode(message);\n const base64 = btoa(String.fromCharCode(...messageBytes));\n const message_b64url = base64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n\n // Construct verification URL\n const verification_url = `https://attest.ochk.io/attest/${attestation_id}`;\n\n return {\n attestation_id,\n scheme,\n address,\n identities,\n message,\n message_b64url,\n signature,\n issued_at,\n expires_at,\n verification_url,\n publish_targets,\n relay_hints,\n };\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { S as Scheme } from './types-DsrYs3EB.mjs';
1
+ import { S as Scheme } from './types-BdJoNWgs.mjs';
2
2
 
3
3
  interface IssueChallengeOptions {
4
4
  address: string;
@@ -1,4 +1,4 @@
1
- import { S as Scheme } from './types-DsrYs3EB.js';
1
+ import { S as Scheme } from './types-BdJoNWgs.js';
2
2
 
3
3
  interface IssueChallengeOptions {
4
4
  address: string;
@@ -1,4 +1,4 @@
1
- import { c as NostrEvent, I as IdentityBinding } from './types-DsrYs3EB.mjs';
1
+ import { c as NostrEvent, I as IdentityBinding } from './types-BdJoNWgs.mjs';
2
2
 
3
3
  interface DnsVerificationResult {
4
4
  verified: boolean;
@@ -1,4 +1,4 @@
1
- import { c as NostrEvent, I as IdentityBinding } from './types-DsrYs3EB.js';
1
+ import { c as NostrEvent, I as IdentityBinding } from './types-BdJoNWgs.js';
2
2
 
3
3
  interface DnsVerificationResult {
4
4
  verified: boolean;
package/dist/index.d.mts CHANGED
@@ -1,9 +1,9 @@
1
- import { a as VerifyOptions, I as IdentityBinding, N as Network, c as NostrEvent, d as StatusCode } from './types-DsrYs3EB.mjs';
2
- export { A as AttestationEnvelope, e as IdentityProtocol, M as Metrics, S as Scheme, V as VerifyInput, b as VerifyOutcome } from './types-DsrYs3EB.mjs';
1
+ import { a as VerifyOptions, I as IdentityBinding, N as Network, c as NostrEvent, d as StatusCode } from './types-BdJoNWgs.mjs';
2
+ export { A as AttestationEnvelope, e as IdentityProtocol, M as Metrics, S as Scheme, V as VerifyInput, b as VerifyOutcome } from './types-BdJoNWgs.mjs';
3
3
  export { signatureIsValid, verify } from './verify.mjs';
4
4
  export { createAttestation, discoverAttestations, extractAttestationIdFromUrl, formatIdentitiesForDisplay, getAttestationsForAddress, getAttestationsForIdentity, getVerificationUrl, publishAttestation, verifyAttestationById } from './attestation.mjs';
5
5
  export { Challenge, IssueChallengeOptions, VerifyChallengeOptions, VerifyChallengeReason, VerifyChallengeResult, issueChallenge, verifyChallenge } from './challenge.mjs';
6
- export { BuildOptions, Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities } from './canonical.mjs';
6
+ export { BuildOptions, Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, readBondExtension } from './canonical.mjs';
7
7
  export { DEFAULT_RELAYS, FANOUT_DEADLINE_MS, createAttestationEvent, parseAttestationFromEvent, publishToRelays, queryByAddress, queryByAttestationId, queryByIdentity, queryRecent, signedEnvelope } from './nostr.mjs';
8
8
  export { DnsVerificationResult, GitHubVerificationResult, NostrVerificationResult, TwitterVerificationResult, VerificationResult, VerifyIdentityOptions, generateGistTemplate, generateTweetText, generateWellKnownContent, getDnsVerificationInstructions, getGistCreationUrl, getGitHubVerificationInstructions, getManualVerificationInstructions, getNostrVerificationInstructions, getTweetUrl, getTwitterComposeUrl, getTwitterVerificationInstructions, getVerificationInstructions, getVerificationSummary, getWellKnownUrl, isDnsIdentityVerified, isGitHubIdentityVerified, isNostrIdentityVerified, isValidDomain, isValidTweetUrl, requiresManualVerification, supportsAutomaticVerification, verifyAllIdentities, verifyDnsIdentity, verifyGitHubIdentity, verifyIdentity, verifyNostrIdentity, verifyTwitterIdentity } from './identity.mjs';
9
9
  export { ScoringAlgorithm, computeAllScores, computeScore } from './scoring.mjs';
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { a as VerifyOptions, I as IdentityBinding, N as Network, c as NostrEvent, d as StatusCode } from './types-DsrYs3EB.js';
2
- export { A as AttestationEnvelope, e as IdentityProtocol, M as Metrics, S as Scheme, V as VerifyInput, b as VerifyOutcome } from './types-DsrYs3EB.js';
1
+ import { a as VerifyOptions, I as IdentityBinding, N as Network, c as NostrEvent, d as StatusCode } from './types-BdJoNWgs.js';
2
+ export { A as AttestationEnvelope, e as IdentityProtocol, M as Metrics, S as Scheme, V as VerifyInput, b as VerifyOutcome } from './types-BdJoNWgs.js';
3
3
  export { signatureIsValid, verify } from './verify.js';
4
4
  export { createAttestation, discoverAttestations, extractAttestationIdFromUrl, formatIdentitiesForDisplay, getAttestationsForAddress, getAttestationsForIdentity, getVerificationUrl, publishAttestation, verifyAttestationById } from './attestation.js';
5
5
  export { Challenge, IssueChallengeOptions, VerifyChallengeOptions, VerifyChallengeReason, VerifyChallengeResult, issueChallenge, verifyChallenge } from './challenge.js';
6
- export { BuildOptions, Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities } from './canonical.js';
6
+ export { BuildOptions, Extensions, buildCanonicalMessage, createAttestationEnvelope, formatIdentities, generateAttestationId, parseIdentities, readBondExtension } from './canonical.js';
7
7
  export { DEFAULT_RELAYS, FANOUT_DEADLINE_MS, createAttestationEvent, parseAttestationFromEvent, publishToRelays, queryByAddress, queryByAttestationId, queryByIdentity, queryRecent, signedEnvelope } from './nostr.js';
8
8
  export { DnsVerificationResult, GitHubVerificationResult, NostrVerificationResult, TwitterVerificationResult, VerificationResult, VerifyIdentityOptions, generateGistTemplate, generateTweetText, generateWellKnownContent, getDnsVerificationInstructions, getGistCreationUrl, getGitHubVerificationInstructions, getManualVerificationInstructions, getNostrVerificationInstructions, getTweetUrl, getTwitterComposeUrl, getTwitterVerificationInstructions, getVerificationInstructions, getVerificationSummary, getWellKnownUrl, isDnsIdentityVerified, isGitHubIdentityVerified, isNostrIdentityVerified, isValidDomain, isValidTweetUrl, requiresManualVerification, supportsAutomaticVerification, verifyAllIdentities, verifyDnsIdentity, verifyGitHubIdentity, verifyIdentity, verifyNostrIdentity, verifyTwitterIdentity } from './identity.js';
9
9
  export { ScoringAlgorithm, computeAllScores, computeScore } from './scoring.js';
package/dist/index.js CHANGED
@@ -70,6 +70,11 @@ function parseIdentities(identitiesStr) {
70
70
  }
71
71
  return bindings;
72
72
  }
73
+ function readBondExtension(value) {
74
+ if (!/^[0-9]+$/.test(value)) return null;
75
+ const n = Number(value);
76
+ return Number.isSafeInteger(n) ? n : null;
77
+ }
73
78
  function buildCanonicalMessage({ address, identities }, extensions = {}, options = {}) {
74
79
  const nonce = options.nonce ?? randomNonce16BHexLower();
75
80
  if (!/^[0-9a-f]{32}$/.test(nonce)) {
@@ -92,6 +97,9 @@ function buildCanonicalMessage({ address, identities }, extensions = {}, options
92
97
  continue;
93
98
  }
94
99
  const key = k.toLowerCase();
100
+ if (key === "bond" && readBondExtension(String(v)) === null) {
101
+ throw new Error(`invalid bond: ${String(v)} is not a whole number of sats up to 2^53 - 1`);
102
+ }
95
103
  normExt[key] = String(v);
96
104
  }
97
105
  const extLines = Object.keys(normExt).sort().map((k) => `${k}: ${normExt[k]}`);
@@ -104,10 +112,15 @@ async function generateAttestationId(message) {
104
112
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
105
113
  return toLowerHex(new Uint8Array(hashBuffer));
106
114
  }
115
+ function readMessageValue(message, key) {
116
+ const needle = key + ":";
117
+ const hit = message.split("\n").map((l) => l.trim()).find((l) => l.toLowerCase().startsWith(needle));
118
+ if (!hit) return void 0;
119
+ return hit.slice(hit.indexOf(":") + 1).trim();
120
+ }
107
121
  async function createAttestationEnvelope(message, signature, scheme, address, identities, extensions = {}) {
108
122
  const attestation_id = await generateAttestationId(message);
109
- const issuedAtMatch = message.match(/issued_at: ([^\n]+)/);
110
- const issued_at = issuedAtMatch?.[1] ?? (/* @__PURE__ */ new Date()).toISOString();
123
+ const issued_at = readMessageValue(message, "issued_at") ?? (/* @__PURE__ */ new Date()).toISOString();
111
124
  const expires_at = extensions.expires;
112
125
  const relay_hints = extensions.relay_hints?.split(",").map((r) => r.trim());
113
126
  const publish_targets = extensions.publish?.split(",").map((t) => t.trim());
@@ -319,13 +332,7 @@ async function signatureIsValid(input) {
319
332
  }
320
333
  function parseCanonicalMessage(msg) {
321
334
  const lines = msg.split("\n").map((l) => l.trim());
322
- const getVal = (prefix) => {
323
- const needle = prefix + ":";
324
- const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
325
- if (!hit) return void 0;
326
- const idx = hit.indexOf(":");
327
- return hit.slice(idx + 1).trim();
328
- };
335
+ const getVal = (key) => readMessageValue(msg, key);
329
336
  const address = getVal("address") || "";
330
337
  const identitiesStr = getVal("identities") || "";
331
338
  const issued_at = getVal("issued_at") || "";
@@ -359,14 +366,15 @@ function parseCanonicalMessage(msg) {
359
366
  }
360
367
  const network = extensions["network"] || "mainnet";
361
368
  const bondStr = extensions["bond"];
362
- const bond = bondStr ? parseInt(bondStr, 10) : void 0;
369
+ const bond = bondStr ? readBondExtension(bondStr) : void 0;
363
370
  return {
364
371
  core: { address, identities, issued_at },
365
372
  expires,
366
373
  repeated,
367
374
  network,
368
375
  extensions,
369
- bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
376
+ bond: bond !== void 0 && bond !== null && bond > 0 ? bond : void 0,
377
+ bondMalformed: bond === null
370
378
  };
371
379
  }
372
380
  async function getAddressUtxos(address, network, opts) {
@@ -535,6 +543,16 @@ async function verify(input, opts = {}) {
535
543
  identities: parsed.core.identities
536
544
  };
537
545
  }
546
+ if (parsed.bondMalformed) {
547
+ codes.push("bond_malformed");
548
+ return {
549
+ ok: false,
550
+ codes,
551
+ network,
552
+ attestation_id,
553
+ identities: parsed.core.identities
554
+ };
555
+ }
538
556
  try {
539
557
  const utxos = await getAddressUtxos(input.addr, network, {
540
558
  esploraMainnetBase: opts.esploraMainnetBase,
@@ -2319,6 +2337,11 @@ var STATUS_META = {
2319
2337
  detail: "The confirmed balance is less than the declared bond amount. The bond extension requires the address to hold at least the specified amount in confirmed UTXOs.",
2320
2338
  severity: "error"
2321
2339
  },
2340
+ bond_malformed: {
2341
+ label: "Bond amount not readable",
2342
+ detail: "The signed bond line is not a whole number of sats written in plain digits, so the attestation does not say how much it bonds.",
2343
+ severity: "error"
2344
+ },
2322
2345
  aud_mismatch: {
2323
2346
  label: "Audience mismatch",
2324
2347
  detail: "The message was signed for a different audience than expected. This may be intentional or indicate the signature is being used in the wrong context.",
@@ -2424,6 +2447,7 @@ exports.queryByAddress = queryByAddress;
2424
2447
  exports.queryByAttestationId = queryByAttestationId;
2425
2448
  exports.queryByIdentity = queryByIdentity;
2426
2449
  exports.queryRecent = queryRecent;
2450
+ exports.readBondExtension = readBondExtension;
2427
2451
  exports.requiresManualVerification = requiresManualVerification;
2428
2452
  exports.signNostrEvent = signNostrEvent;
2429
2453
  exports.signatureIsValid = signatureIsValid;