@private.me/xbind 3.0.1 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -14
- package/dist-standalone/_deps/mldsa-wasm/dist/mldsa.js +1920 -1
- package/dist-standalone/_deps/shared/cjs/errors.js +729 -1
- package/dist-standalone/_deps/shared/cjs/index.js +463 -1
- package/dist-standalone/_deps/shared/cjs/types.js +315 -1
- package/dist-standalone/_deps/shared/errors.js +244 -1
- package/dist-standalone/_deps/shared/index.js +72 -1
- package/dist-standalone/_deps/shared/types.js +86 -1
- package/dist-standalone/_deps/ux-helpers/cjs/errors.js +1 -1
- package/dist-standalone/_deps/ux-helpers/cjs/index.js +1 -1
- package/dist-standalone/_deps/ux-helpers/cjs/pagination.js +1 -1
- package/dist-standalone/_deps/ux-helpers/cjs/progress.js +1 -1
- package/dist-standalone/_deps/ux-helpers/cjs/search.js +1 -1
- package/dist-standalone/_deps/ux-helpers/cjs/types.js +1 -1
- package/dist-standalone/_deps/ux-helpers/errors.js +1 -1
- package/dist-standalone/_deps/ux-helpers/index.js +1 -1
- package/dist-standalone/_deps/ux-helpers/pagination.js +1 -1
- package/dist-standalone/_deps/ux-helpers/progress.js +1 -1
- package/dist-standalone/_deps/ux-helpers/search.js +1 -1
- package/dist-standalone/_deps/xchange/auto-accept.js +1 -1
- package/dist-standalone/_deps/xchange/cjs/auto-accept.js +1 -1
- package/dist-standalone/_deps/xchange/cjs/errors.js +1 -1
- package/dist-standalone/_deps/xchange/cjs/index.js +1 -1
- package/dist-standalone/_deps/xchange/cjs/invite-client.js +1 -1
- package/dist-standalone/_deps/xchange/cjs/lazy-init.js +1 -1
- package/dist-standalone/_deps/xchange/cjs/trust-integration.js +1 -1
- package/dist-standalone/_deps/xchange/cjs/xchange.js +1 -1
- package/dist-standalone/_deps/xchange/errors.js +1 -1
- package/dist-standalone/_deps/xchange/index.js +1 -1
- package/dist-standalone/_deps/xchange/invite-client.js +1 -1
- package/dist-standalone/_deps/xchange/lazy-init.js +1 -1
- package/dist-standalone/_deps/xchange/trust-integration.js +1 -1
- package/dist-standalone/_deps/xchange/xchange.js +1 -1
- package/dist-standalone/_deps/xregistry/cjs/discovery.js +1 -1
- package/dist-standalone/_deps/xregistry/cjs/errors.js +1 -1
- package/dist-standalone/_deps/xregistry/cjs/index.js +1 -1
- package/dist-standalone/_deps/xregistry/cjs/registry.js +1 -1
- package/dist-standalone/_deps/xregistry/cjs/schema.js +1 -1
- package/dist-standalone/_deps/xregistry/cjs/types.js +1 -1
- package/dist-standalone/_deps/xregistry/discovery.js +1 -1
- package/dist-standalone/_deps/xregistry/errors.js +1 -1
- package/dist-standalone/_deps/xregistry/index.js +1 -1
- package/dist-standalone/_deps/xregistry/registry.js +1 -1
- package/dist-standalone/_deps/xregistry/schema.js +1 -1
- package/dist-standalone/_deps/xregistry/types.js +1 -1
- package/dist-standalone/agent-call.js +659 -1
- package/dist-standalone/agent-sdk.js +328 -1
- package/dist-standalone/agent.js +1800 -1
- package/dist-standalone/approval.js +193 -1
- package/dist-standalone/async-iterators.js +382 -1
- package/dist-standalone/auth.js +219 -1
- package/dist-standalone/auto-accept.js +229 -1
- package/dist-standalone/backup-config.js +201 -1
- package/dist-standalone/backup.js +326 -1
- package/dist-standalone/batch-operations.js +388 -1
- package/dist-standalone/cancellation.js +477 -1
- package/dist-standalone/checkpoint.js +186 -1
- package/dist-standalone/circuit-breaker.js +468 -1
- package/dist-standalone/cjs/agent-call.js +701 -1
- package/dist-standalone/cjs/agent-sdk.js +332 -1
- package/dist-standalone/cjs/agent.js +1837 -1
- package/dist-standalone/cjs/approval.js +199 -1
- package/dist-standalone/cjs/async-iterators.js +392 -1
- package/dist-standalone/cjs/auth.js +225 -1
- package/dist-standalone/cjs/auto-accept.js +233 -1
- package/dist-standalone/cjs/backup-config.js +207 -1
- package/dist-standalone/cjs/backup.js +330 -1
- package/dist-standalone/cjs/batch-operations.js +397 -1
- package/dist-standalone/cjs/cancellation.js +490 -1
- package/dist-standalone/cjs/checkpoint.js +193 -1
- package/dist-standalone/cjs/circuit-breaker.js +476 -1
- package/dist-standalone/cjs/cli/init.js +492 -1
- package/dist-standalone/cjs/config-validation.js +522 -1
- package/dist-standalone/cjs/connect.js +312 -1
- package/dist-standalone/cjs/connection-pool.js +506 -1
- package/dist-standalone/cjs/correlation-id.js +339 -1
- package/dist-standalone/cjs/crypto-utils.js +176 -1
- package/dist-standalone/cjs/debug-mode.js +534 -1
- package/dist-standalone/cjs/did-document.js +101 -1
- package/dist-standalone/cjs/did-privateme.js +130 -1
- package/dist-standalone/cjs/did-web.js +201 -1
- package/dist-standalone/cjs/discovery.js +462 -1
- package/dist-standalone/cjs/dual-mode.js +251 -1
- package/dist-standalone/cjs/email-templates.js +313 -1
- package/dist-standalone/cjs/email-transport.js +239 -1
- package/dist-standalone/cjs/envelope.js +538 -1
- package/dist-standalone/cjs/errors.js +913 -1
- package/dist-standalone/cjs/event-emitter.js +461 -1
- package/dist-standalone/cjs/gateway-state.js +55 -1
- package/dist-standalone/cjs/gateway-transport.js +120 -1
- package/dist-standalone/cjs/graceful-degradation.js +403 -1
- package/dist-standalone/cjs/guardrails.js +223 -1
- package/dist-standalone/cjs/health-check.js +336 -1
- package/dist-standalone/cjs/http-compat.js +272 -1
- package/dist-standalone/cjs/http-status-map.js +571 -1
- package/dist-standalone/cjs/identity.js +645 -1
- package/dist-standalone/cjs/index.js +406 -1
- package/dist-standalone/cjs/invitation.js +421 -1
- package/dist-standalone/cjs/invite.js +328 -1
- package/dist-standalone/cjs/key-agreement.js +335 -1
- package/dist-standalone/cjs/lazy-init.js +300 -1
- package/dist-standalone/cjs/logger.js +291 -1
- package/dist-standalone/cjs/mdns-discovery.js +202 -1
- package/dist-standalone/cjs/nonce-store.js +80 -1
- package/dist-standalone/cjs/pairing-manager.js +223 -1
- package/dist-standalone/cjs/plugin-system.js +264 -1
- package/dist-standalone/cjs/plugins/logging.js +168 -1
- package/dist-standalone/cjs/plugins/metrics.js +181 -1
- package/dist-standalone/cjs/plugins/validation.js +302 -1
- package/dist-standalone/cjs/policy.js +320 -1
- package/dist-standalone/cjs/progress-callbacks.js +583 -1
- package/dist-standalone/cjs/redis-nonce-store.js +76 -1
- package/dist-standalone/cjs/registry-middleware.js +50 -1
- package/dist-standalone/cjs/retry-strategies.js +544 -1
- package/dist-standalone/cjs/retry-transport.js +102 -1
- package/dist-standalone/cjs/runtime/browser.js +533 -1
- package/dist-standalone/cjs/runtime/edge.js +526 -1
- package/dist-standalone/cjs/runtime/react-native.js +394 -1
- package/dist-standalone/cjs/security-policy.js +245 -1
- package/dist-standalone/cjs/serialization.js +1040 -1
- package/dist-standalone/cjs/split-channel.js +225 -1
- package/dist-standalone/cjs/subscription-proof.js +230 -1
- package/dist-standalone/cjs/succession.js +148 -1
- package/dist-standalone/cjs/timeouts.js +412 -1
- package/dist-standalone/cjs/trace-context.js +424 -1
- package/dist-standalone/cjs/trace-spans.js +495 -1
- package/dist-standalone/cjs/transport.js +63 -1
- package/dist-standalone/cjs/trust-registry.js +991 -1
- package/dist-standalone/cjs/types/error-response.js +56 -1
- package/dist-standalone/cjs/vault-auth.js +178 -1
- package/dist-standalone/cjs/vault-store-loader.js +194 -1
- package/dist-standalone/cjs/verify.js +25 -1
- package/dist-standalone/cjs/version-info.js +543 -1
- package/dist-standalone/cjs/xfetch.js +340 -1
- package/dist-standalone/cli/init.js +455 -1
- package/dist-standalone/cli/setup.js +514 -1
- package/dist-standalone/cli/types.js +27 -1
- package/dist-standalone/cli/xbind.js +148 -1
- package/dist-standalone/config-validation.js +513 -1
- package/dist-standalone/connect.js +274 -1
- package/dist-standalone/connection-pool.js +500 -1
- package/dist-standalone/correlation-id.js +326 -1
- package/dist-standalone/crypto-utils.js +157 -1
- package/dist-standalone/debug-mode.js +510 -1
- package/dist-standalone/did-document.js +96 -1
- package/dist-standalone/did-privateme.js +121 -1
- package/dist-standalone/did-web.js +196 -1
- package/dist-standalone/discovery.js +458 -1
- package/dist-standalone/dual-mode.js +247 -1
- package/dist-standalone/email-templates.js +309 -1
- package/dist-standalone/email-transport.js +232 -1
- package/dist-standalone/envelope.js +525 -1
- package/dist-standalone/errors.js +896 -1
- package/dist-standalone/event-emitter.js +456 -1
- package/dist-standalone/gateway-state.js +51 -1
- package/dist-standalone/gateway-transport.js +116 -1
- package/dist-standalone/graceful-degradation.js +396 -1
- package/dist-standalone/guardrails.js +216 -1
- package/dist-standalone/health-check.js +332 -1
- package/dist-standalone/http-compat.js +267 -1
- package/dist-standalone/http-status-map.js +561 -1
- package/dist-standalone/identity.js +619 -1
- package/dist-standalone/index.js +78 -1
- package/dist-standalone/invitation.js +415 -1
- package/dist-standalone/invite.js +324 -1
- package/dist-standalone/key-agreement.js +325 -1
- package/dist-standalone/lazy-init.js +295 -1
- package/dist-standalone/logger.js +285 -1
- package/dist-standalone/mdns-discovery.js +195 -1
- package/dist-standalone/nonce-store.js +76 -1
- package/dist-standalone/pairing-manager.js +219 -1
- package/dist-standalone/plugin-system.js +257 -1
- package/dist-standalone/plugins/logging.js +163 -1
- package/dist-standalone/plugins/metrics.js +176 -1
- package/dist-standalone/plugins/validation.js +297 -1
- package/dist-standalone/policy.js +315 -1
- package/dist-standalone/progress-callbacks.js +576 -1
- package/dist-standalone/redis-nonce-store.js +72 -1
- package/dist-standalone/registry-middleware.js +47 -1
- package/dist-standalone/retry-strategies.js +534 -1
- package/dist-standalone/retry-transport.js +98 -1
- package/dist-standalone/runtime/browser.js +516 -1
- package/dist-standalone/runtime/edge.js +511 -1
- package/dist-standalone/runtime/react-native.js +383 -1
- package/dist-standalone/security-policy.js +239 -1
- package/dist-standalone/serialization.js +1031 -1
- package/dist-standalone/split-channel.js +219 -1
- package/dist-standalone/subscription-proof.js +224 -1
- package/dist-standalone/succession.js +142 -1
- package/dist-standalone/timeouts.js +398 -1
- package/dist-standalone/trace-context.js +414 -1
- package/dist-standalone/trace-spans.js +488 -1
- package/dist-standalone/transport.js +59 -1
- package/dist-standalone/trust-registry.js +950 -1
- package/dist-standalone/types/error-response.js +52 -1
- package/dist-standalone/vault-auth.js +174 -1
- package/dist-standalone/vault-store-loader.js +187 -1
- package/dist-standalone/verify.js +16 -1
- package/dist-standalone/version-info.js +530 -1
- package/dist-standalone/xfetch.js +335 -1
- package/package.json +4 -13
- package/share1.dat +0 -0
- package/dist-standalone/_deps/mldsa-wasm/LICENSE +0 -24
- package/dist-standalone/_deps/mldsa-wasm/package.json +0 -46
- package/dist-standalone/_deps/shared/cjs/package.json +0 -1
- package/dist-standalone/_deps/ux-helpers/cjs/package.json +0 -1
- package/dist-standalone/_deps/xchange/cjs/package.json +0 -1
- package/dist-standalone/_deps/xregistry/cjs/package.json +0 -1
- package/dist-standalone/cjs/package.json +0 -3
- package/dist-standalone/package.json +0 -10
package/dist-standalone/agent.js
CHANGED
|
@@ -1 +1,1800 @@
|
|
|
1
|
-
import{ok,err}from"./_deps/shared/index.js";import{fromBase64,toBase64,generateUUID,formatShareHeader,parseShareHeader}from"./crypto-utils.js";import{loadCryptoPackage,getCrypto}from"./vault-store-loader.js";import{QuotaExceededError,VaultStoreError}from"./errors.js";import{generateIdentity,verify,importPublicKey,identityFromSeed,exportPKCS8,exportX25519PKCS8,extractRawEd25519,extractRawX25519}from"./identity.js";import{createEnvelope,createEnvelopeV2,createEnvelopeV3,createEnvelopeV4,decryptPayload,validateEnvelope,generateSharedKey}from"./envelope.js";import{generateXchangeKey,xchangeEncrypt,xchangeDecrypt}from"./_deps/xchange/index.js";import{verifyMlDsa65}from"./identity.js";import{senderKeyAgreement,receiverKeyAgreement,senderHybridKeyAgreement,receiverHybridKeyAgreement,importX25519PublicKey}from"./key-agreement.js";import{splitForChannel,reconstructFromChannel,DEFAULT_SPLIT_CONFIG}from"./split-channel.js";import{MemoryNonceStore}from"./nonce-store.js";import{HttpsTransportAdapter}from"./transport.js";import{MemoryTrustRegistry,HttpTrustRegistry}from"./trust-registry.js";import{ProgressReporter}from"./_deps/ux-helpers/index.js";import{DefaultSecurityPolicy,describeSecurityMode}from"./security-policy.js";import{DEFAULT_BACKUP_CONFIG}from"./backup-config.js";const DEFAULT_RELAY_URL=process.env.XBIND_RELAY_URL||"https://private.me/relay",DEFAULT_REGISTRY_URL=process.env.XBIND_REGISTRY_URL||"https://private.me/registry";export function parseAgentError(e){const t=e.split(":");return 1===t.length?{code:t[0]??e}:{code:t[0]??e,subCode:t.slice(1).join(":")}}const TIMESTAMP_WINDOW_MS=3e4;function toArrayBuffer(e){const t=new ArrayBuffer(e.byteLength);return new Uint8Array(t).set(e),t}function compareBytes(e,t){const r=Math.min(e.length,t.length);for(let i=0;i<r;i++){const r=e[i]??0,a=t[i]??0;if(r!==a)return r-a}return e.length-t.length}function concatBytes(e,t){const r=new Uint8Array(e.length+t.length);return r.set(e),r.set(t,e.length),r}export class Agent{identity;name;registry;transports;nonceStore;timestampWindowMs;securityPolicy;backupConfig;shareAccumulator=new Map;lastDetail="";lastSecurityDecision;cleanupTimer;cryptoModule=null;get lastErrorDetail(){return this.lastDetail}get lastSecurity(){return this.lastSecurityDecision}constructor(e,t,r,i,a,s,n,o){this.identity=e,this.name=t,this.registry=r,this.transports=i,this.nonceStore=a,this.timestampWindowMs=s,this.securityPolicy=n??new DefaultSecurityPolicy,this.backupConfig=o??DEFAULT_BACKUP_CONFIG}get did(){return this.identity.did}getTransports(){return this.transports}async ensureCrypto(){if(this.cryptoModule)return this.cryptoModule;const e=getCrypto();if(e)return this.cryptoModule=e,e;const t=await loadCryptoPackage(this.identity);if(!t.ok){if("VAULT_QUOTA_EXCEEDED"===t.error){const e="https://private.me/subscribe?product=xbind&tier=pro";throw new QuotaExceededError(`Monthly usage quota exceeded (Free tier: 100K operations/month). Upgrade to Pro tier for unlimited access at $5 per 100K operations. Visit: ${e}`,e)}throw new VaultStoreError(t.error,`Failed to load crypto package: ${t.error}`)}return this.cryptoModule=t.value,t.value}static isSupported(){try{return void 0!==globalThis.crypto&&void 0!==globalThis.crypto.subtle&&"function"==typeof globalThis.crypto.subtle.generateKey&&"function"==typeof globalThis.crypto.subtle.sign&&"function"==typeof globalThis.crypto.subtle.verify&&"function"==typeof globalThis.crypto.subtle.encrypt&&"function"==typeof globalThis.crypto.getRandomValues}catch{return!1}}static async fromIdentity(e,t){const r=t.nonceStore??new MemoryNonceStore,i=t.timestampWindowMs??3e4,a=Array.isArray(t.transport)?t.transport:[t.transport],s=new Agent(e,t.name??e.did,t.registry,a,r,i,t.securityPolicy,t.backupConfig);return ok(s)}static fromParts(e,t,r,i){const a=Array.isArray(r)?r:[r];return new Agent(e,i?.name??e.did,t,a,i?.nonceStore??new MemoryNonceStore,i?.timestampWindowMs??3e4,i?.securityPolicy,i?.backupConfig)}static async fromSeed(e,t){const r=await identityFromSeed(e,{postQuantumSig:t.postQuantumSig});if(!r.ok)return err("IDENTITY_FAILED:KEYGEN");const i=await t.registry.register(r.value.did,r.value.rawPublicKey,t.name??r.value.did,t.scopes,r.value.rawX25519PublicKey,r.value.mlKemPublicKey,r.value.mlDsaPublicKey,t.xchange??!1);if(!i.ok&&"ALREADY_REGISTERED"!==i.error){const e="NETWORK_ERROR"===i.error?"REGISTRATION_FAILED:NETWORK_ERROR":"REGISTRATION_FAILED";return err(e)}const a=t.nonceStore??new MemoryNonceStore,s=t.timestampWindowMs??3e4,n=Array.isArray(t.transport)?t.transport:[t.transport];return ok(new Agent(r.value,t.name??r.value.did,t.registry,n,a,s,t.securityPolicy,t.backupConfig))}static async lazy(e){const{createLazyAgent:t}=await import("./lazy-init.js");return t(e)}isReady(){return void 0!==this.identity&&void 0!==this.registry&&this.transports.length>0}static async create(e){const t=await generateIdentity({postQuantumSig:e.postQuantumSig});if(!t.ok)return err("IDENTITY_FAILED:KEYGEN");const r=await e.registry.register(t.value.did,t.value.rawPublicKey,e.name,e.scopes,t.value.rawX25519PublicKey,t.value.mlKemPublicKey,t.value.mlDsaPublicKey,e.xchange??!1);if(!r.ok){const e="ALREADY_REGISTERED"===r.error?"REGISTRATION_FAILED:ALREADY_REGISTERED":"NETWORK_ERROR"===r.error?"REGISTRATION_FAILED:NETWORK_ERROR":"REGISTRATION_FAILED";return err(e)}const i=e.nonceStore??new MemoryNonceStore,a=e.timestampWindowMs??3e4,s=Array.isArray(e.transport)?e.transport:[e.transport],n=new Agent(t.value,e.name,e.registry,s,i,a,e.securityPolicy,e.backupConfig);try{await n.ensureCrypto()}catch(e){return err(e instanceof QuotaExceededError?"QUOTA_EXCEEDED":e instanceof VaultStoreError?"IDENTITY_FAILED:VAULT_STORE":"IDENTITY_FAILED")}return ok(n)}static async quickstart(e){const t=new MemoryTrustRegistry,r=new HttpsTransportAdapter({baseUrl:DEFAULT_RELAY_URL}),i=await generateIdentity({postQuantumSig:!1});if(!i.ok)throw new Error("Failed to generate ephemeral identity");const a=e?.name??`agent-${Date.now()}`,s=await t.register(i.value.did,i.value.rawPublicKey,a,void 0,i.value.rawX25519PublicKey,i.value.mlKemPublicKey,i.value.mlDsaPublicKey,!1);if(!s.ok)throw new Error(`Failed to register ephemeral identity: ${s.error}`);const n=new Agent(i.value,a,t,[r],new MemoryNonceStore,3e4,void 0,void 0);return n.cleanupTimer=setTimeout(async()=>{await t.revoke(i.value.did)},36e5),n}static async from(e={}){const t=e.identity??"persistent",r=e.identityTTL??36e5,i="ephemeral"===t,a="string"==typeof e.registry?new HttpTrustRegistry({baseUrl:e.registry}):e.registry??(i?new MemoryTrustRegistry:new HttpTrustRegistry({baseUrl:DEFAULT_REGISTRY_URL})),s=e.transport?Array.isArray(e.transport)?e.transport:[e.transport]:[new HttpsTransportAdapter({baseUrl:DEFAULT_RELAY_URL})],n=await generateIdentity({postQuantumSig:e.postQuantumSig??!1});if(!n.ok)throw new Error("Failed to generate identity");const o=i?`ephemeral-agent-${Date.now()}`:`agent-${Date.now()}`,c=await a.register(n.value.did,n.value.rawPublicKey,o,void 0,n.value.rawX25519PublicKey,n.value.mlKemPublicKey,n.value.mlDsaPublicKey,!1);if(!c.ok)throw new Error(`Failed to register identity: ${c.error}`);const l=new Agent(n.value,o,a,s,new MemoryNonceStore,3e4,e.securityPolicy,e.backupConfig);return i&&(l.cleanupTimer=setTimeout(async()=>{await a.revoke(n.value.did)},r)),l}async send(e){const t=new ProgressReporter(e.onProgress);t.start("Resolving recipient identity...");const r=await this.registry.resolve(e.to);if(!r.ok)return err("REVOKED"===r.error?"RECIPIENT_REVOKED":"RECIPIENT_NOT_FOUND");t.update("Checking recipient authorization...",10);if(!await this.registry.hasReceiveScope(e.to,e.scope))return this.lastDetail=`recipient=${e.to}, scope=${e.scope}`,err("RECEIVER_SCOPE_DENIED");t.update("Preparing message...",15);const i=(new TextEncoder).encode(JSON.stringify(e.payload));t.update("Determining security level...",20);const a=this.securityPolicy.classify({action:e.action??"send",params:"object"==typeof e.payload&&null!==e.payload?e.payload:{},sender:this.did,recipient:e.to,scope:e.scope,securityOverride:e.security});this.lastSecurityDecision=a,t.update(`Security: ${describeSecurityMode(a.mode)} — ${a.reason}`,25);const s=void 0!==e.splitChannel?e.splitChannel:"split"===a.mode.type;if(s&&(e.xchange||"xchange"===a.mode.type)){t.update("Checking Xchange support...",20);if(await this.canUseXchange(e.to))return this.sendXchange(e,i,t)}t.update("Establishing key agreement...",30);const n=await this.trySenderECDH(e.to);return n?s?this.sendSplitChannel(e,i,n.sharedKey,n.ephemeralPublicKey,n.kemCiphertext,n.recipientHasMlDsa,t):n.kemCiphertext&&n.recipientHasMlDsa&&this.identity.mlDsaSecretKey?this.sendWithHybridV3(e,i,n.sharedKey,n.ephemeralPublicKey,n.kemCiphertext,t):n.kemCiphertext?this.sendWithHybrid(e,i,n.sharedKey,n.ephemeralPublicKey,n.kemCiphertext,t):this.sendWithECDH(e,i,n.sharedKey,n.ephemeralPublicKey,t):err("KEY_AGREEMENT_FAILED:RECIPIENT_HAS_NO_X25519_KEY")}async receive(e,t){this.lastDetail="";const r=new ProgressReporter(t?.onProgress);r.start("Verifying envelope signature...");const i=await this.verifyEnvelope(e);if(!i.ok)return i;const{senderRawKey:a,payloadBytes:s}=i.value;let n;if(4===e.v)return err("VERIFICATION_FAILED:UNSUPPORTED_VERSION");if(r.update("Deriving shared key...",30),2!==e.v&&3!==e.v||!("kemCiphertext"in e)){if(!e.ephemeralPub){if(t?.allowCleartext){let t;try{t=JSON.parse((new TextDecoder).decode(s))}catch{return err("DECRYPT_FAILED:PARSE")}return r.complete(),ok({sender:e.sender,payload:t,scope:e.scope,timestamp:e.timestamp})}return err("DECRYPT_FAILED:NO_EPHEMERAL_KEY")}{if("string"!=typeof e.ephemeralPub)return this.lastDetail="ephemeralPub not string",err("VERIFICATION_FAILED:INVALID_ENVELOPE");const t=fromBase64(e.ephemeralPub),i=await receiverKeyAgreement(this.identity.x25519PrivateKey,t);if(i.ok)n=i.value;else{if(this.identity.rotatedKeys&&this.identity.rotatedKeys.length>0)for(const e of this.identity.rotatedKeys){const i=await receiverKeyAgreement(e.x25519PrivateKey,t);if(i.ok){n=i.value,r.update("Decrypting with rotated keys...",45);break}}if(!n)return err("DECRYPT_FAILED:KEY_AGREEMENT")}}}else{if(!this.identity.mlKemSecretKey)return err("DECRYPT_FAILED:KEY_AGREEMENT");if("string"!=typeof e.ephemeralPub||"string"!=typeof e.kemCiphertext)return this.lastDetail="ephemeralPub or kemCiphertext not string",err("VERIFICATION_FAILED:INVALID_ENVELOPE");const t=fromBase64(e.ephemeralPub),i=fromBase64(e.kemCiphertext);if(!this.identity.mlKemPublicKey||!this.identity.mlKemSecretKey)return this.lastDetail="ML-KEM keys not available in identity",err("DECRYPT_FAILED:MISSING_MLKEM_KEYS");const a=await receiverHybridKeyAgreement(this.identity.x25519PrivateKey,this.identity.rawX25519PublicKey,t,i,this.identity.mlKemSecretKey,this.identity.mlKemPublicKey);if(a.ok)n=a.value;else{if(this.identity.rotatedKeys&&this.identity.rotatedKeys.length>0)for(const e of this.identity.rotatedKeys){if(!e.mlKemSecretKey)continue;const a=await receiverHybridKeyAgreement(e.x25519PrivateKey,this.identity.rawX25519PublicKey,t,i,e.mlKemSecretKey,this.identity.mlKemPublicKey);if(a.ok){n=a.value,r.update("Decrypting with rotated keys...",45);break}}if(!n)return err("DECRYPT_FAILED:KEY_AGREEMENT")}}if(r.update("Decrypting payload...",60),!n)return err("DECRYPT_FAILED:KEY_AGREEMENT");const o=await decryptPayload(e,n);if(!o.ok)return err("DECRYPT_FAILED:DECRYPTION");let c;r.update("Parsing message...",90);try{c=JSON.parse((new TextDecoder).decode(o.value))}catch{return err("DECRYPT_FAILED:PARSE")}return r.complete(),ok({sender:e.sender,payload:c,scope:e.scope,timestamp:e.timestamp,metadata:e.protocol&&e.documentationUrl?{protocol:e.protocol,documentationUrl:e.documentationUrl}:void 0})}async verifySignature(e){const t=await this.registry.resolve(e.sender);if(!t.ok)return err("VERIFICATION_FAILED:DID_NOT_IN_REGISTRY");const r=await importPublicKey(t.value);if(!r.ok)return err("VERIFICATION_FAILED:KEY_IMPORT_FAILED");const i=fromBase64(e.signature),a=JSON.stringify({v:e.v,alg:e.alg,sender:e.sender,recipient:e.recipient,timestamp:e.timestamp,nonce:e.nonce,scope:e.scope,payload:e.payload}),s=(new TextEncoder).encode(a),n=await verify(r.value,i,s);return n.ok?ok({sender:e.sender,valid:n.value}):err("VERIFICATION_FAILED:SIGNATURE_MISMATCH")}async exportSeeds(){const e=await exportPKCS8(this.identity.privateKey);if(!e.ok)return err("IDENTITY_FAILED");const t=await exportX25519PKCS8(this.identity.x25519PrivateKey);if(!t.ok)return err("IDENTITY_FAILED");const r=extractRawEd25519(e.value);if(!r.ok)return err("IDENTITY_FAILED");const i=extractRawX25519(t.value);return i.ok?ok({ed25519:r.value,x25519:i.value,mlKemSecretKey:this.identity.mlKemSecretKey,mlKemPublicKey:this.identity.mlKemPublicKey}):err("IDENTITY_FAILED")}async splitKey(e){const{splitKeyWithBackup:t}=await import("./backup-config.js"),r=await t(e,this.backupConfig);return r.ok?r:err("ENVELOPE_FAILED:SPLIT")}async reconstructKey(e){const{reconstructKeyFromBackup:t}=await import("./backup-config.js"),r=await t(e);return r.ok?r:err("DECRYPT_FAILED")}async receiveSigned(e){this.lastDetail="";const t=await this.verifyEnvelope(e);if(!t.ok)return t;let r;try{r=JSON.parse((new TextDecoder).decode(t.value.payloadBytes))}catch{return err("DECRYPT_FAILED:PARSE")}return ok({sender:e.sender,payload:r,scope:e.scope,timestamp:e.timestamp,metadata:e.protocol&&e.documentationUrl?{protocol:e.protocol,documentationUrl:e.documentationUrl}:void 0})}async discover(e){const{getToolRegistry:t}=await import("./agent-call.js"),r=t();if(!r)return[];if(!e)return r.listAll();return r.search(e)}middleware(){return async(e,t,r)=>{const i=validateEnvelope(e.body);if(!i.ok)return void t.status(400).json({error:i.error});const a=await this.receive(i.value);if(!a.ok){const e="TIMESTAMP_EXPIRED"===a.error||"REPLAY_DETECTED"===a.error?403:401;return void t.status(e).json({error:a.error})}e.agentMessage=a.value,r()}}cleanup(){this.cleanupTimer&&(clearTimeout(this.cleanupTimer),this.cleanupTimer=void 0)}dispose(){this.cleanup()}async trySenderECDH(e){const t=await this.registry.getEntry(e);if(!t.ok||!t.value.x25519PublicKey)return null;const r=!!t.value.mlDsaPublicKey,i=await importX25519PublicKey(t.value.x25519PublicKey);if(!i.ok)return null;if(t.value.mlKemPublicKey&&this.identity.mlKemSecretKey){const e=await senderHybridKeyAgreement(i.value,t.value.mlKemPublicKey);if(e.ok)return{sharedKey:e.value.sharedKey,ephemeralPublicKey:e.value.ephemeralPublicKey,kemCiphertext:e.value.kemCiphertext,recipientHasMlDsa:r}}const a=await senderKeyAgreement(i.value);return a.ok?{...a.value,recipientHasMlDsa:r}:null}async sendWithECDH(e,t,r,i,a){a?.update("Encrypting message with ECDH...",60);const s=await createEnvelope({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,plaintext:t,privateKey:this.identity.privateKey,sharedKey:r,ephemeralPublicKey:i});if(!s.ok)return err("ENVELOPE_FAILED:ENCRYPT");a?.update("Sending message...",90);const n=await this.transports[0].send(s.value,e.to);return n.ok&&a?.complete(),n}async sendWithHybrid(e,t,r,i,a,s){s?.update("Encrypting message with hybrid KEM...",60);const n=await createEnvelopeV2({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,plaintext:t,privateKey:this.identity.privateKey,sharedKey:r,ephemeralPublicKey:i,kemCiphertext:a});if(!n.ok)return err("ENVELOPE_FAILED:ENCRYPT");s?.update("Sending message...",90);const o=await this.transports[0].send(n.value,e.to);return o.ok&&s?.complete(),o}async sendWithHybridV3(e,t,r,i,a,s){if(!this.identity.mlDsaSecretKey)return err("ENVELOPE_FAILED:PQ_KEY_MISSING");s?.update("Encrypting with post-quantum signatures...",60);const n=await createEnvelopeV3({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,plaintext:t,privateKey:this.identity.privateKey,sharedKey:r,ephemeralPublicKey:i,kemCiphertext:a,mlDsaSecretKey:this.identity.mlDsaSecretKey});if(!n.ok)return this.lastDetail=`v3 envelope error: ${n.error}`,err("ENVELOPE_FAILED:ENCRYPT");s?.update("Sending message...",90);const o=await this.transports[0].send(n.value,e.to);return o.ok&&s?.complete(),o}async sendDirect(e,t,r,i){i?.update("Encrypting message...",60);const a=await createEnvelope({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,plaintext:t,privateKey:this.identity.privateKey,sharedKey:r});if(!a.ok)return err("ENVELOPE_FAILED:ENCRYPT");i?.update("Sending message...",90);const s=await this.transports[0].send(a.value,e.to);return s.ok&&i?.complete(),s}async canUseXchange(e){const t=await this.registry.getEntry(e);return!!t.ok&&!0===t.value.xchange}async sendXchange(e,t,r){const i=e.splitChannelConfig??DEFAULT_SPLIT_CONFIG;this.transports.length<i.totalShares&&console.warn(`Split-channel: ${i.totalShares} shares but only ${this.transports.length} transport(s). For channel separation, provide at least ${i.totalShares} transports.`),r?.update("Generating Xchange key...",40);const a=await generateXchangeKey();if(!a.ok)return err("KEY_AGREEMENT_FAILED");r?.update("Encrypting message...",50);const s=await xchangeEncrypt(t,a.value);if(!s.ok)return err("ENVELOPE_FAILED:ENCRYPT");const n=await this.ensureCrypto(),o=i.totalShares,c=i.threshold,l=n.nextOddPrime(o)-1,d=n.pkcs7Pad(s.value,l),{key:u,signature:p}=await n.generateHMAC(d);let y;r?.update("Splitting message into shares...",60);try{y=n.splitXorIDA(d,o,c)}catch{return err("ENVELOPE_FAILED:SPLIT")}const h=toBase64(u),E=toBase64(p),m=generateUUID(),g=[];r?.update("Sending shares...",70);for(let t=0;t<y.length;t++){const i=y[t],a=formatShareHeader(toBase64(i)),s=(new TextEncoder).encode(a),n=await createEnvelopeV4({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,shareData:s,privateKey:this.identity.privateKey,shareIndex:t,shareTotal:o,shareThreshold:c,shareGroupId:m,shareHmacKey:h,shareHmacSig:E});if(!n.ok){g.push(err("ENVELOPE_FAILED:ENCRYPT"));continue}const l=this.transports[t%this.transports.length],d=await l.send(n.value,e.to);g.push(d);const u=70+Math.floor((t+1)/y.length*20);r?.update(`Sent share ${t+1}/${y.length}...`,u)}return g.filter(e=>e.ok).length<c?err("SEND_FAILED:BELOW_THRESHOLD"):(r?.complete(),ok(void 0))}async sendSplitChannel(e,t,r,i,a,s,n){const o=e.splitChannelConfig??DEFAULT_SPLIT_CONFIG;this.transports.length<o.totalShares&&console.warn(`Split-channel: ${o.totalShares} shares but only ${this.transports.length} transport(s). For channel separation, provide at least ${o.totalShares} transports.`),n?.update("Splitting message into shares...",50);const c=await splitForChannel(t,o);if(!c.ok)return err("ENVELOPE_FAILED:SPLIT");const l=c.value;n?.update("Encrypting and sending shares...",70);return(await this.sendShareEnvelopes(e,l,r,i,a,s,n)).filter(e=>e.ok).length<o.threshold?err("SEND_FAILED:BELOW_THRESHOLD"):(n?.complete(),ok(void 0))}async sendShareEnvelopes(e,t,r,i,a,s,n){const o=[];for(let c=0;c<t.length;c++){const l=t[c],d=(new TextEncoder).encode(l.data);let u;if(u=a&&i&&s&&this.identity.mlDsaSecretKey?await createEnvelopeV3({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,plaintext:d,privateKey:this.identity.privateKey,sharedKey:r,ephemeralPublicKey:i,kemCiphertext:a,mlDsaSecretKey:this.identity.mlDsaSecretKey,shareIndex:l.index,shareTotal:l.total,shareThreshold:l.threshold,shareGroupId:l.groupId,shareHmacKey:l.hmacKey,shareHmacSig:l.hmacSig}):a&&i?await createEnvelopeV2({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,plaintext:d,privateKey:this.identity.privateKey,sharedKey:r,ephemeralPublicKey:i,kemCiphertext:a,shareIndex:l.index,shareTotal:l.total,shareThreshold:l.threshold,shareGroupId:l.groupId,shareHmacKey:l.hmacKey,shareHmacSig:l.hmacSig}):await createEnvelope({senderDid:this.identity.did,recipientDid:e.to,scope:e.scope,plaintext:d,privateKey:this.identity.privateKey,sharedKey:r,ephemeralPublicKey:i,shareIndex:l.index,shareTotal:l.total,shareThreshold:l.threshold,shareGroupId:l.groupId,shareHmacKey:l.hmacKey,shareHmacSig:l.hmacSig}),!u.ok){o.push(err("ENVELOPE_FAILED:ENCRYPT"));continue}const p=this.transports[c%this.transports.length],y=await p.send(u.value,e.to);o.push(y);const h=70+Math.floor((c+1)/t.length*20);n?.update(`Sent share ${c+1}/${t.length}...`,h)}return o}async receiveSplitShare(e){if(void 0===e.shareGroupId)return err("VERIFICATION_FAILED");const t=await this.receiveRaw(e);if(!t.ok)return t;const{sender:r,decryptedText:i,scope:a,timestamp:s}=t.value,n={data:i,index:e.shareIndex??0,total:e.shareTotal??2,threshold:e.shareThreshold??2,groupId:e.shareGroupId,hmacKey:e.shareHmacKey??"",hmacSig:e.shareHmacSig??""};return this.accumulateShare(n,r,a,s)}async receiveXchangeShare(e){this.lastDetail="";const t=await this.verifyEnvelope(e);if(!t.ok)return t;const r={data:(new TextDecoder).decode(t.value.payloadBytes),index:e.shareIndex,total:e.shareTotal,threshold:e.shareThreshold,groupId:e.shareGroupId,hmacKey:e.shareHmacKey,hmacSig:e.shareHmacSig};return this.accumulateXchangeShare(r,e.sender,e.scope,e.timestamp)}async accumulateXchangeShare(e,t,r,i){const a=this.shareAccumulator.get(e.groupId)??[];if(a.some(t=>t.index===e.index)||(a.push(e),this.shareAccumulator.set(e.groupId,a)),a.length<e.threshold)return ok(null);this.shareAccumulator.delete(e.groupId);const s=a.slice(0,e.threshold),n=e.total,o=e.threshold;let c;try{c=s.map(e=>fromBase64(parseShareHeader(e.data)))}catch{return err("DECRYPT_FAILED")}const l=s.map(e=>e.index),d=await this.ensureCrypto();let u,p,y;try{u=d.reconstructXorIDA(c,l,n,o)}catch{return err("DECRYPT_FAILED")}try{p=fromBase64(s[0].hmacKey),y=fromBase64(s[0].hmacSig)}catch{return err("DECRYPT_FAILED")}if(!await d.verifyHMAC(p,u,y))return this.lastDetail="HMAC verification failed before decrypt",err("DECRYPT_FAILED");const h=d.nextOddPrime(n)-1,E=d.pkcs7Unpad(u,h);if(!E.ok)return err("DECRYPT_FAILED");const m=await xchangeDecrypt(E.value);if(!m.ok)return err("DECRYPT_FAILED:DECRYPTION");let g;try{g=JSON.parse((new TextDecoder).decode(m.value))}catch{return err("DECRYPT_FAILED:PARSE")}return ok({sender:t,payload:g,scope:r,timestamp:i})}async accumulateShare(e,t,r,i){const a=this.shareAccumulator.get(e.groupId)??[];if(a.some(t=>t.index===e.index)||(a.push(e),this.shareAccumulator.set(e.groupId,a)),a.length<e.threshold)return ok(null);this.shareAccumulator.delete(e.groupId);const s=await reconstructFromChannel(a);if(!s.ok)return err("DECRYPT_FAILED");let n;try{n=JSON.parse((new TextDecoder).decode(s.value))}catch{return err("DECRYPT_FAILED")}return ok({sender:t,payload:n,scope:r,timestamp:i})}async verifyEnvelope(e){if(!e||"object"!=typeof e)return this.lastDetail="envelope is null or not an object",err("VERIFICATION_FAILED:INVALID_ENVELOPE");if(1!==e.v&&2!==e.v&&3!==e.v&&4!==e.v||"Ed25519"!==e.alg)return this.lastDetail=`v=${String(e.v)}, alg=${String(e.alg)}`,err("VERIFICATION_FAILED:UNSUPPORTED_VERSION");if("number"!=typeof e.timestamp||!Number.isFinite(e.timestamp))return this.lastDetail=`timestamp=${String(e.timestamp)} (must be finite number)`,err("VERIFICATION_FAILED:INVALID_ENVELOPE");const t=Math.abs(Date.now()-e.timestamp);if(t>this.timestampWindowMs)return this.lastDetail=`age=${t}ms, max=${this.timestampWindowMs}ms`,err("TIMESTAMP_EXPIRED");const r=void 0!==e.shareGroupId?{shareGroupId:e.shareGroupId,shareIndex:e.shareIndex}:void 0;if(!await this.nonceStore.check(e.nonce,e.sender,r))return this.lastDetail=`nonce=${e.nonce}`,err("REPLAY_DETECTED");const i=await this.registry.resolve(e.sender);if(!i.ok)return this.lastDetail=`did=${e.sender}`,err("VERIFICATION_FAILED:DID_NOT_IN_REGISTRY");const a=await importPublicKey(i.value);if(!a.ok)return this.lastDetail=`did=${e.sender}`,err("VERIFICATION_FAILED:KEY_IMPORT_FAILED");if(!e.signature||"string"!=typeof e.signature)return this.lastDetail="signature field missing or invalid",err("VERIFICATION_FAILED:SIGNATURE_MISMATCH");const s=fromBase64(e.signature),n=JSON.stringify({v:e.v,alg:e.alg,sender:e.sender,recipient:e.recipient,timestamp:e.timestamp,nonce:e.nonce,scope:e.scope,payload:e.payload}),o=(new TextEncoder).encode(n),c=await verify(a.value,s,o);if(!c.ok||!c.value)return this.lastDetail="signature does not match canonical envelope (v1.1.3+ required)",err("VERIFICATION_FAILED:SIGNATURE_MISMATCH");if(3===e.v&&"pqSignature"in e){if("string"!=typeof e.pqSignature)return this.lastDetail="pqSignature field not a string",err("VERIFICATION_FAILED:INVALID_ENVELOPE");const t=await this.registry.getEntry(e.sender);if(!t.ok||!t.value.mlDsaPublicKey)return this.lastDetail=`did=${e.sender} missing ML-DSA public key`,err("VERIFICATION_FAILED:PQ_KEY_MISSING");const r=fromBase64(e.pqSignature),i=await verifyMlDsa65(t.value.mlDsaPublicKey,r,o);if(!i.ok||!i.value)return this.lastDetail="ML-DSA-65 signature does not match canonical envelope (v1.1.3+ required)",err("VERIFICATION_FAILED:PQ_SIGNATURE_MISMATCH")}if(!await this.registry.hasScope(e.sender,e.scope))return this.lastDetail=`scope=${e.scope}`,err("SCOPE_DENIED");if("string"!=typeof e.payload)return this.lastDetail="payload field not a string",err("VERIFICATION_FAILED:INVALID_ENVELOPE");const l=fromBase64(e.payload);return ok({senderRawKey:i.value,payloadBytes:l})}async receiveRaw(e){const t=await this.verifyEnvelope(e);if(!t.ok)return t;const{senderRawKey:r}=t.value;if(4===e.v)return err("VERIFICATION_FAILED:UNSUPPORTED_VERSION");let i;if(2!==e.v&&3!==e.v||!("kemCiphertext"in e)){if(!e.ephemeralPub)return err("DECRYPT_FAILED:NO_EPHEMERAL_KEY");{if("string"!=typeof e.ephemeralPub)return err("DECRYPT_FAILED:INVALID_ENVELOPE");const t=fromBase64(e.ephemeralPub),r=await receiverKeyAgreement(this.identity.x25519PrivateKey,t);if(r.ok)i=r.value;else{if(this.identity.rotatedKeys&&this.identity.rotatedKeys.length>0)for(const e of this.identity.rotatedKeys){const r=await receiverKeyAgreement(e.x25519PrivateKey,t);if(r.ok){i=r.value;break}}if(!i)return err("DECRYPT_FAILED:KEY_AGREEMENT")}}}else{if(!this.identity.mlKemSecretKey)return err("DECRYPT_FAILED:KEY_AGREEMENT");if("string"!=typeof e.ephemeralPub||"string"!=typeof e.kemCiphertext)return err("DECRYPT_FAILED:INVALID_ENVELOPE");const t=fromBase64(e.ephemeralPub),r=fromBase64(e.kemCiphertext);if(!this.identity.mlKemPublicKey||!this.identity.mlKemSecretKey)return this.lastDetail="ML-KEM keys not available in identity",err("DECRYPT_FAILED:MISSING_MLKEM_KEYS");const a=await receiverHybridKeyAgreement(this.identity.x25519PrivateKey,this.identity.rawX25519PublicKey,t,r,this.identity.mlKemSecretKey,this.identity.mlKemPublicKey);if(a.ok)i=a.value;else{if(this.identity.rotatedKeys&&this.identity.rotatedKeys.length>0)for(const e of this.identity.rotatedKeys){if(!e.mlKemSecretKey)continue;const a=await receiverHybridKeyAgreement(e.x25519PrivateKey,this.identity.rawX25519PublicKey,t,r,e.mlKemSecretKey,this.identity.mlKemPublicKey);if(a.ok){i=a.value;break}}if(!i)return err("DECRYPT_FAILED:KEY_AGREEMENT")}}if(!i)return err("DECRYPT_FAILED:KEY_AGREEMENT");const a=await decryptPayload(e,i);if(!a.ok)return err("DECRYPT_FAILED:DECRYPTION");const s=(new TextDecoder).decode(a.value);return ok({sender:e.sender,decryptedText:s,scope:e.scope,timestamp:e.timestamp})}async createTestEnvelope(e,t,r){const i=await this.registry.getEntry(e);if(!i.ok||!i.value.x25519PublicKey)return null;const a=await importX25519PublicKey(i.value.x25519PublicKey);if(!a.ok)return null;const s=await senderKeyAgreement(a.value);if(!s.ok)return null;const n=(new TextEncoder).encode(JSON.stringify(t)),o=await createEnvelope({senderDid:this.identity.did,recipientDid:e,scope:r,plaintext:n,privateKey:this.identity.privateKey,sharedKey:s.value.sharedKey,ephemeralPublicKey:s.value.ephemeralPublicKey});return o.ok?o.value:null}async invite(e){if(!this.transports||0===this.transports.length)return err("SEND_FAILED");const t=Buffer.from(this.identity.rawPublicKey).toString("base64"),r={from:this.identity.did,to:e.to,payload:{agentName:this.name,message:e.message,publicKey:t,endpoint:""}},i=this.transports[0];if(!i)return err("SEND_FAILED");return(await i.send(r,e.to)).ok?ok(void 0):err("SEND_FAILED")}}export{generateSharedKey};
|
|
1
|
+
import { ok, err } from"./_deps/shared/index.js";
|
|
2
|
+
import { fromBase64, toBase64, generateUUID, formatShareHeader, parseShareHeader, } from './crypto-utils.js';
|
|
3
|
+
import { loadCryptoPackage, getCrypto, } from './vault-store-loader.js';
|
|
4
|
+
import { QuotaExceededError, VaultStoreError } from './errors.js';
|
|
5
|
+
import { generateIdentity, verify, importPublicKey, identityFromSeed, exportPKCS8, exportX25519PKCS8, extractRawEd25519, extractRawX25519, } from './identity.js';
|
|
6
|
+
import { createEnvelope, createEnvelopeV2, createEnvelopeV3, createEnvelopeV4, decryptPayload, validateEnvelope, generateSharedKey, } from './envelope.js';
|
|
7
|
+
import { generateXchangeKey, xchangeEncrypt, xchangeDecrypt } from"./_deps/xchange/index.js";
|
|
8
|
+
import { verifyMlDsa65 } from './identity.js';
|
|
9
|
+
import { senderKeyAgreement, receiverKeyAgreement, senderHybridKeyAgreement, receiverHybridKeyAgreement, importX25519PublicKey, } from './key-agreement.js';
|
|
10
|
+
import { splitForChannel, reconstructFromChannel, DEFAULT_SPLIT_CONFIG, } from './split-channel.js';
|
|
11
|
+
import { MemoryNonceStore } from './nonce-store.js';
|
|
12
|
+
import { HttpsTransportAdapter } from './transport.js';
|
|
13
|
+
import { MemoryTrustRegistry, HttpTrustRegistry } from './trust-registry.js';
|
|
14
|
+
import { ProgressReporter } from"./_deps/ux-helpers/index.js";
|
|
15
|
+
import { DefaultSecurityPolicy, describeSecurityMode } from './security-policy.js';
|
|
16
|
+
import { DEFAULT_BACKUP_CONFIG } from './backup-config.js';
|
|
17
|
+
/* ── Configuration ── */
|
|
18
|
+
/**
|
|
19
|
+
* Default relay URL for message transport.
|
|
20
|
+
* Override via XBIND_RELAY_URL environment variable.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_RELAY_URL = process.env.XBIND_RELAY_URL || 'https://private.me/relay';
|
|
23
|
+
/**
|
|
24
|
+
* Default registry URL for DID resolution.
|
|
25
|
+
* Override via XBIND_REGISTRY_URL environment variable.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_REGISTRY_URL = process.env.XBIND_REGISTRY_URL || 'https://private.me/registry';
|
|
28
|
+
/**
|
|
29
|
+
* Parse an AgentError string into structured detail.
|
|
30
|
+
*
|
|
31
|
+
* Splits colon-separated error codes into base code and sub-code.
|
|
32
|
+
*
|
|
33
|
+
* @param error - An AgentError string (e.g. 'DECRYPT_FAILED:KEY_AGREEMENT').
|
|
34
|
+
* @returns Parsed error detail.
|
|
35
|
+
*/
|
|
36
|
+
export function parseAgentError(error) {
|
|
37
|
+
const parts = error.split(':');
|
|
38
|
+
if (parts.length === 1)
|
|
39
|
+
return { code: parts[0] ?? error };
|
|
40
|
+
return { code: parts[0] ?? error, subCode: parts.slice(1).join(':') };
|
|
41
|
+
}
|
|
42
|
+
const TIMESTAMP_WINDOW_MS = 30_000;
|
|
43
|
+
/* ── Key Agreement ── */
|
|
44
|
+
/** Copy Uint8Array to fresh ArrayBuffer. */
|
|
45
|
+
function toArrayBuffer(data) {
|
|
46
|
+
const buf = new ArrayBuffer(data.byteLength);
|
|
47
|
+
new Uint8Array(buf).set(data);
|
|
48
|
+
return buf;
|
|
49
|
+
}
|
|
50
|
+
// SECURITY FIX (v1.1.6): deriveSharedKey() REMOVED
|
|
51
|
+
//
|
|
52
|
+
// Previous implementation derived AES-256-GCM keys from public-only inputs:
|
|
53
|
+
// AES key = SHA-256(sort(pubA, pubB))
|
|
54
|
+
//
|
|
55
|
+
// Both public keys appear in the envelope (sender/recipient DIDs). Any passive
|
|
56
|
+
// observer can derive the same key and decrypt all messages. Zero forward secrecy.
|
|
57
|
+
//
|
|
58
|
+
// Fix: All senders/receivers MUST use X25519 ECDH for proper key agreement.
|
|
59
|
+
// Recipients without x25519 keys fail with KEY_AGREEMENT_FAILED:RECIPIENT_HAS_NO_X25519_KEY.
|
|
60
|
+
//
|
|
61
|
+
// Removed: deriveSharedKey() function and export (agent.ts:2305)
|
|
62
|
+
// Removed: Sender fallback at line 959
|
|
63
|
+
// Removed: Receiver fallbacks at lines 1036, 2209
|
|
64
|
+
function compareBytes(a, b) {
|
|
65
|
+
const len = Math.min(a.length, b.length);
|
|
66
|
+
for (let i = 0; i < len; i++) {
|
|
67
|
+
const ai = a[i] ?? 0;
|
|
68
|
+
const bi = b[i] ?? 0;
|
|
69
|
+
if (ai !== bi)
|
|
70
|
+
return ai - bi;
|
|
71
|
+
}
|
|
72
|
+
return a.length - b.length;
|
|
73
|
+
}
|
|
74
|
+
function concatBytes(a, b) {
|
|
75
|
+
const result = new Uint8Array(a.length + b.length);
|
|
76
|
+
result.set(a);
|
|
77
|
+
result.set(b, a.length);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
/* ── Agent Class ── */
|
|
81
|
+
/**
|
|
82
|
+
* Top-level Xail Agent SDK API.
|
|
83
|
+
*
|
|
84
|
+
* Provides cryptographically secure agent-to-agent messaging with:
|
|
85
|
+
* - Ed25519 digital signatures
|
|
86
|
+
* - X25519 ECDH forward secrecy
|
|
87
|
+
* - ML-KEM-768 post-quantum encryption
|
|
88
|
+
* - ML-DSA-65 post-quantum signatures (opt-in)
|
|
89
|
+
* - XorIDA information-theoretic split-channel delivery
|
|
90
|
+
*
|
|
91
|
+
* @example Basic Agent Usage
|
|
92
|
+
* ```typescript
|
|
93
|
+
* import { Agent } from '@private.me/xbind';
|
|
94
|
+
*
|
|
95
|
+
* // Create an agent with auto-generated identity
|
|
96
|
+
* const alice = await Agent.create({
|
|
97
|
+
* name: 'alice',
|
|
98
|
+
* registry: 'https://private.me/registry'
|
|
99
|
+
* });
|
|
100
|
+
* if (!alice.ok) throw new Error(alice.error);
|
|
101
|
+
*
|
|
102
|
+
* const bob = await Agent.create({
|
|
103
|
+
* name: 'bob',
|
|
104
|
+
* registry: 'https://private.me/registry'
|
|
105
|
+
* });
|
|
106
|
+
* if (!bob.ok) throw new Error(bob.error);
|
|
107
|
+
*
|
|
108
|
+
* // Send encrypted message
|
|
109
|
+
* const result = await alice.value.send({
|
|
110
|
+
* to: bob.value.identity.did,
|
|
111
|
+
* payload: { message: 'Hello, Bob!' },
|
|
112
|
+
* scope: ['read:profile']
|
|
113
|
+
* });
|
|
114
|
+
*
|
|
115
|
+
* // Receive and decrypt message
|
|
116
|
+
* if (result.ok && result.value.envelope) {
|
|
117
|
+
* const message = await bob.value.receive(result.value.envelope);
|
|
118
|
+
* if (message.ok) {
|
|
119
|
+
* console.log('Received:', message.value.payload);
|
|
120
|
+
* }
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @example Using Existing Identity
|
|
125
|
+
* ```typescript
|
|
126
|
+
* import { Agent, identityFromSeed } from '@private.me/xbind';
|
|
127
|
+
*
|
|
128
|
+
* // Restore identity from seed
|
|
129
|
+
* const seed = process.env.XBIND_SEED;
|
|
130
|
+
* const identity = await identityFromSeed(seed);
|
|
131
|
+
* if (!identity.ok) throw new Error(identity.error);
|
|
132
|
+
*
|
|
133
|
+
* // Create agent from existing identity
|
|
134
|
+
* const agent = new Agent(identity.value, {
|
|
135
|
+
* name: 'my-agent',
|
|
136
|
+
* registry: 'https://private.me/registry'
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export class Agent {
|
|
141
|
+
identity;
|
|
142
|
+
name;
|
|
143
|
+
registry;
|
|
144
|
+
transports;
|
|
145
|
+
nonceStore;
|
|
146
|
+
timestampWindowMs;
|
|
147
|
+
securityPolicy;
|
|
148
|
+
backupConfig;
|
|
149
|
+
/** Accumulates split-channel shares by groupId until threshold is met. */
|
|
150
|
+
shareAccumulator = new Map();
|
|
151
|
+
/** Diagnostic detail from the last failed verification step. */
|
|
152
|
+
lastDetail = '';
|
|
153
|
+
/** Last security decision made by the policy (for debugging/logging). */
|
|
154
|
+
lastSecurityDecision;
|
|
155
|
+
/** Timer for ephemeral agent auto-cleanup. */
|
|
156
|
+
cleanupTimer;
|
|
157
|
+
/** Crypto package (XorIDA algorithms) loaded from Vault Store. */
|
|
158
|
+
cryptoModule = null;
|
|
159
|
+
/**
|
|
160
|
+
* Human-readable diagnostic from the last failed receive/verify call.
|
|
161
|
+
*
|
|
162
|
+
* Populated during verification with context like age vs max window.
|
|
163
|
+
* Empty string if no error has occurred or after a successful call.
|
|
164
|
+
*/
|
|
165
|
+
get lastErrorDetail() {
|
|
166
|
+
return this.lastDetail;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Last security decision made by the security policy.
|
|
170
|
+
*
|
|
171
|
+
* Shows which security mode was selected and why. Useful for debugging
|
|
172
|
+
* and understanding when/why Xorida split-channel was activated.
|
|
173
|
+
*
|
|
174
|
+
* Returns undefined if no send() has been called yet.
|
|
175
|
+
*/
|
|
176
|
+
get lastSecurity() {
|
|
177
|
+
return this.lastSecurityDecision;
|
|
178
|
+
}
|
|
179
|
+
constructor(identity, name, registry, transports, nonceStore, timestampWindowMs, securityPolicy, backupConfig) {
|
|
180
|
+
this.identity = identity;
|
|
181
|
+
this.name = name;
|
|
182
|
+
this.registry = registry;
|
|
183
|
+
this.transports = transports;
|
|
184
|
+
this.nonceStore = nonceStore;
|
|
185
|
+
this.timestampWindowMs = timestampWindowMs;
|
|
186
|
+
this.securityPolicy = securityPolicy ?? new DefaultSecurityPolicy();
|
|
187
|
+
this.backupConfig = backupConfig ?? DEFAULT_BACKUP_CONFIG;
|
|
188
|
+
}
|
|
189
|
+
/** The agent's DID. */
|
|
190
|
+
get did() {
|
|
191
|
+
return this.identity.did;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get the agent's transport adapters.
|
|
195
|
+
* @internal Used by MessageStream for envelope handling.
|
|
196
|
+
*/
|
|
197
|
+
getTransports() {
|
|
198
|
+
return this.transports;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Ensure crypto package is loaded from Vault Store.
|
|
202
|
+
*
|
|
203
|
+
* Loads XorIDA algorithms with:
|
|
204
|
+
* - DID-based authentication
|
|
205
|
+
* - Usage quota verification (Free: 100K/month, Pro: unlimited)
|
|
206
|
+
* - 7-day memory cache
|
|
207
|
+
*
|
|
208
|
+
* @returns Crypto package or throws QuotaExceededError/VaultStoreError
|
|
209
|
+
* @throws {QuotaExceededError} If Free tier quota exceeded (>120K hard cap)
|
|
210
|
+
* @throws {VaultStoreError} If vault fetch/load fails
|
|
211
|
+
* @private
|
|
212
|
+
*/
|
|
213
|
+
async ensureCrypto() {
|
|
214
|
+
// Check cache first
|
|
215
|
+
if (this.cryptoModule) {
|
|
216
|
+
return this.cryptoModule;
|
|
217
|
+
}
|
|
218
|
+
// Try global cache (shared across agents)
|
|
219
|
+
const cached = getCrypto();
|
|
220
|
+
if (cached) {
|
|
221
|
+
this.cryptoModule = cached;
|
|
222
|
+
return cached;
|
|
223
|
+
}
|
|
224
|
+
// Load from Vault Store
|
|
225
|
+
const result = await loadCryptoPackage(this.identity);
|
|
226
|
+
if (!result.ok) {
|
|
227
|
+
// Handle quota exceeded with user-friendly error
|
|
228
|
+
if (result.error === 'VAULT_QUOTA_EXCEEDED') {
|
|
229
|
+
const upgradeUrl = 'https://private.me/subscribe?product=xbind&tier=pro';
|
|
230
|
+
throw new QuotaExceededError(`Monthly usage quota exceeded (Free tier: 100K operations/month). Upgrade to Pro tier for unlimited access at $5 per 100K operations. Visit: ${upgradeUrl}`, upgradeUrl);
|
|
231
|
+
}
|
|
232
|
+
// Other vault errors
|
|
233
|
+
throw new VaultStoreError(result.error, `Failed to load crypto package: ${result.error}`);
|
|
234
|
+
}
|
|
235
|
+
// Cache locally
|
|
236
|
+
this.cryptoModule = result.value;
|
|
237
|
+
return result.value;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Check whether the runtime supports the SDK's crypto requirements.
|
|
241
|
+
*
|
|
242
|
+
* Verifies that `crypto.subtle` is available and supports Ed25519 +
|
|
243
|
+
* AES-256-GCM. Call this before lazy-loading the SDK in middleware
|
|
244
|
+
* to avoid failing on the first `Agent.create()` call.
|
|
245
|
+
*
|
|
246
|
+
* @returns `true` if the runtime has the required Web Crypto APIs.
|
|
247
|
+
*/
|
|
248
|
+
static isSupported() {
|
|
249
|
+
try {
|
|
250
|
+
return (typeof globalThis.crypto !== 'undefined' &&
|
|
251
|
+
typeof globalThis.crypto.subtle !== 'undefined' &&
|
|
252
|
+
typeof globalThis.crypto.subtle.generateKey === 'function' &&
|
|
253
|
+
typeof globalThis.crypto.subtle.sign === 'function' &&
|
|
254
|
+
typeof globalThis.crypto.subtle.verify === 'function' &&
|
|
255
|
+
typeof globalThis.crypto.subtle.encrypt === 'function' &&
|
|
256
|
+
typeof globalThis.crypto.getRandomValues === 'function');
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Create an Agent from a persisted identity (skips keygen + registration).
|
|
264
|
+
*
|
|
265
|
+
* Use this with importFromPKCS8 or importIdentity to restore a
|
|
266
|
+
* previously created agent across process restarts.
|
|
267
|
+
*
|
|
268
|
+
* @param identity - A previously exported AgentIdentity.
|
|
269
|
+
* @param opts - Agent options (registry, transport, etc.).
|
|
270
|
+
* @returns Agent instance or error.
|
|
271
|
+
*/
|
|
272
|
+
static async fromIdentity(identity, opts) {
|
|
273
|
+
const nonceStore = opts.nonceStore ?? new MemoryNonceStore();
|
|
274
|
+
const timestampWindowMs = opts.timestampWindowMs ?? TIMESTAMP_WINDOW_MS;
|
|
275
|
+
const transports = Array.isArray(opts.transport) ? opts.transport : [opts.transport];
|
|
276
|
+
const agent = new Agent(identity, opts.name ?? identity.did, opts.registry, transports, nonceStore, timestampWindowMs, opts.securityPolicy, opts.backupConfig);
|
|
277
|
+
return ok(agent);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Build an Agent from pre-constructed parts (synchronous).
|
|
281
|
+
*
|
|
282
|
+
* Unlike `create()` this does NOT generate keys or register with the
|
|
283
|
+
* registry — the caller is responsible for both. Useful when identity
|
|
284
|
+
* is provisioned in a factory step, registration is handled by an
|
|
285
|
+
* external system, or transport is wired up at runtime.
|
|
286
|
+
*
|
|
287
|
+
* @param identity - A previously generated or imported AgentIdentity.
|
|
288
|
+
* @param registry - Trust registry the agent will query for peers.
|
|
289
|
+
* @param transport - Transport adapter for envelope delivery.
|
|
290
|
+
* @param opts - Optional overrides (name, nonceStore, timestampWindowMs).
|
|
291
|
+
* @returns A fully wired Agent instance.
|
|
292
|
+
*/
|
|
293
|
+
static fromParts(identity, registry, transport, opts) {
|
|
294
|
+
const transports = Array.isArray(transport) ? transport : [transport];
|
|
295
|
+
return new Agent(identity, opts?.name ?? identity.did, registry, transports, opts?.nonceStore ?? new MemoryNonceStore(), opts?.timestampWindowMs ?? TIMESTAMP_WINDOW_MS, opts?.securityPolicy, opts?.backupConfig);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Create an Agent from a deterministic 32-byte seed.
|
|
299
|
+
*
|
|
300
|
+
* Uses HKDF-SHA256 to derive Ed25519 + X25519 keys from the seed.
|
|
301
|
+
* The same seed always produces the same DID and keys.
|
|
302
|
+
*
|
|
303
|
+
* **Just-in-Time Registration (JITR):** Automatically registers identity
|
|
304
|
+
* with the trust registry on first use. Follows industry standards (AWS IoT
|
|
305
|
+
* JITR, OAuth DCR, MCP 2025 spec) for zero-config onboarding. Idempotent:
|
|
306
|
+
* safe to call multiple times (ignores ALREADY_REGISTERED errors).
|
|
307
|
+
*
|
|
308
|
+
* Ideal for IoT, servers, AI agents: zero-config deployment with automatic
|
|
309
|
+
* registration on first connection.
|
|
310
|
+
*
|
|
311
|
+
* @param seed - Exactly 32 bytes of high-entropy key material.
|
|
312
|
+
* @param opts - Agent options (registry, transport, scopes, etc.).
|
|
313
|
+
* @returns Agent instance or error.
|
|
314
|
+
*/
|
|
315
|
+
static async fromSeed(seed, opts) {
|
|
316
|
+
const idResult = await identityFromSeed(seed, { postQuantumSig: opts.postQuantumSig });
|
|
317
|
+
if (!idResult.ok)
|
|
318
|
+
return err('IDENTITY_FAILED:KEYGEN');
|
|
319
|
+
// JITR: Auto-register with trust registry (zero-config onboarding)
|
|
320
|
+
// Includes all keys: Ed25519 (signing), X25519 (encryption), ML-KEM, ML-DSA
|
|
321
|
+
const regResult = await opts.registry.register(idResult.value.did, idResult.value.rawPublicKey, opts.name ?? idResult.value.did, opts.scopes, idResult.value.rawX25519PublicKey, idResult.value.mlKemPublicKey, idResult.value.mlDsaPublicKey, opts.xchange ?? false);
|
|
322
|
+
// Idempotent: Ignore ALREADY_REGISTERED (agent may have registered before)
|
|
323
|
+
// Fail on other errors (network issues, invalid data, etc.)
|
|
324
|
+
if (!regResult.ok && regResult.error !== 'ALREADY_REGISTERED') {
|
|
325
|
+
const sub = regResult.error === 'NETWORK_ERROR'
|
|
326
|
+
? 'REGISTRATION_FAILED:NETWORK_ERROR'
|
|
327
|
+
: 'REGISTRATION_FAILED';
|
|
328
|
+
return err(sub);
|
|
329
|
+
}
|
|
330
|
+
const nonceStore = opts.nonceStore ?? new MemoryNonceStore();
|
|
331
|
+
const timestampWindowMs = opts.timestampWindowMs ?? TIMESTAMP_WINDOW_MS;
|
|
332
|
+
const transports = Array.isArray(opts.transport) ? opts.transport : [opts.transport];
|
|
333
|
+
return ok(new Agent(idResult.value, opts.name ?? idResult.value.did, opts.registry, transports, nonceStore, timestampWindowMs, opts.securityPolicy, opts.backupConfig));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Create a lazy agent that initializes on first use (zero-click onboarding).
|
|
337
|
+
*
|
|
338
|
+
* Defers invite acceptance and identity generation until first send/receive.
|
|
339
|
+
* Reads invite code from XBIND_INVITE_CODE environment variable.
|
|
340
|
+
* Auto-accepts invite and configures registry/transport automatically.
|
|
341
|
+
*
|
|
342
|
+
* @param config - Lazy agent configuration (name required).
|
|
343
|
+
* @returns Lazy agent wrapper (synchronous).
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```ts
|
|
347
|
+
* // Environment: XBIND_INVITE_CODE=XBD-abc123, XBIND_AUTO_ACCEPT=true
|
|
348
|
+
*
|
|
349
|
+
* // Synchronous construction (no await):
|
|
350
|
+
* const agent = Agent.lazy({ name: 'my-service' });
|
|
351
|
+
*
|
|
352
|
+
* // Auto-accept happens on first send/receive:
|
|
353
|
+
* await agent.send({
|
|
354
|
+
* to: 'did:key:z6Mk...',
|
|
355
|
+
* payload: { action: 'test' },
|
|
356
|
+
* scope: 'test',
|
|
357
|
+
* });
|
|
358
|
+
* ```
|
|
359
|
+
*/
|
|
360
|
+
static async lazy(config) {
|
|
361
|
+
// Dynamic import avoids circular dependency
|
|
362
|
+
const { createLazyAgent } = await import('./lazy-init.js');
|
|
363
|
+
return createLazyAgent(config);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Check whether this agent is fully initialized and ready to send/receive.
|
|
367
|
+
*
|
|
368
|
+
* Returns `true` if identity, registry, and transport are all present.
|
|
369
|
+
* Construction always produces a ready agent (invalid inputs cause the
|
|
370
|
+
* factory to return an error instead), so this is primarily useful in
|
|
371
|
+
* middleware that lazily initializes agents and needs a guard.
|
|
372
|
+
*
|
|
373
|
+
* @returns `true` if the agent can send and receive messages.
|
|
374
|
+
*/
|
|
375
|
+
isReady() {
|
|
376
|
+
return (this.identity !== undefined &&
|
|
377
|
+
this.registry !== undefined &&
|
|
378
|
+
this.transports.length > 0);
|
|
379
|
+
}
|
|
380
|
+
/** Create a new agent, generate identity, register with trust registry. */
|
|
381
|
+
static async create(opts) {
|
|
382
|
+
const idResult = await generateIdentity({ postQuantumSig: opts.postQuantumSig });
|
|
383
|
+
if (!idResult.ok)
|
|
384
|
+
return err('IDENTITY_FAILED:KEYGEN');
|
|
385
|
+
const regResult = await opts.registry.register(idResult.value.did, idResult.value.rawPublicKey, opts.name, opts.scopes, idResult.value.rawX25519PublicKey, idResult.value.mlKemPublicKey, idResult.value.mlDsaPublicKey, opts.xchange ?? false);
|
|
386
|
+
if (!regResult.ok) {
|
|
387
|
+
const sub = regResult.error === 'ALREADY_REGISTERED'
|
|
388
|
+
? 'REGISTRATION_FAILED:ALREADY_REGISTERED'
|
|
389
|
+
: regResult.error === 'NETWORK_ERROR'
|
|
390
|
+
? 'REGISTRATION_FAILED:NETWORK_ERROR'
|
|
391
|
+
: 'REGISTRATION_FAILED';
|
|
392
|
+
return err(sub);
|
|
393
|
+
}
|
|
394
|
+
const nonceStore = opts.nonceStore ?? new MemoryNonceStore();
|
|
395
|
+
const timestampWindowMs = opts.timestampWindowMs ?? TIMESTAMP_WINDOW_MS;
|
|
396
|
+
const transports = Array.isArray(opts.transport) ? opts.transport : [opts.transport];
|
|
397
|
+
const agent = new Agent(idResult.value, opts.name, opts.registry, transports, nonceStore, timestampWindowMs, opts.securityPolicy, opts.backupConfig);
|
|
398
|
+
// Preload crypto package from Vault Store (payment-gated)
|
|
399
|
+
// This validates quota and caches crypto for 7 days
|
|
400
|
+
try {
|
|
401
|
+
await agent.ensureCrypto();
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
// Convert vault errors to Result type
|
|
405
|
+
if (error instanceof QuotaExceededError) {
|
|
406
|
+
return err('QUOTA_EXCEEDED');
|
|
407
|
+
}
|
|
408
|
+
if (error instanceof VaultStoreError) {
|
|
409
|
+
return err('IDENTITY_FAILED:VAULT_STORE');
|
|
410
|
+
}
|
|
411
|
+
// Unknown error
|
|
412
|
+
return err('IDENTITY_FAILED');
|
|
413
|
+
}
|
|
414
|
+
return ok(agent);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Quickstart: create an agent with zero configuration.
|
|
418
|
+
*
|
|
419
|
+
* Creates an ephemeral agent with auto-cleanup after 1 hour. No policy
|
|
420
|
+
* restrictions by default (allow all operations). Ideal for getting
|
|
421
|
+
* started, prototyping, and simple use cases.
|
|
422
|
+
*
|
|
423
|
+
* Uses in-memory registry and default transport. Identity auto-expires
|
|
424
|
+
* and is deregistered after TTL.
|
|
425
|
+
*
|
|
426
|
+
* @param opts - Optional configuration (name only)
|
|
427
|
+
* @returns Agent instance (throws on error for simpler API)
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* ```ts
|
|
431
|
+
* // Zero config (ephemeral identity, 1-hour TTL):
|
|
432
|
+
* const agent = await Agent.quickstart();
|
|
433
|
+
*
|
|
434
|
+
* // With custom name:
|
|
435
|
+
* const agent = await Agent.quickstart({ name: 'my-service' });
|
|
436
|
+
*
|
|
437
|
+
* // Agent is ready to use immediately:
|
|
438
|
+
* await agent.send({
|
|
439
|
+
* to: 'did:key:z6Mk...',
|
|
440
|
+
* payload: { action: 'test' },
|
|
441
|
+
* scope: 'test',
|
|
442
|
+
* });
|
|
443
|
+
* ```
|
|
444
|
+
*/
|
|
445
|
+
static async quickstart(opts) {
|
|
446
|
+
// Use in-memory registry for ephemeral agents
|
|
447
|
+
const registry = new MemoryTrustRegistry();
|
|
448
|
+
// Use default transport
|
|
449
|
+
const transport = new HttpsTransportAdapter({
|
|
450
|
+
baseUrl: DEFAULT_RELAY_URL,
|
|
451
|
+
});
|
|
452
|
+
// Generate ephemeral identity (1-hour TTL)
|
|
453
|
+
const idResult = await generateIdentity({ postQuantumSig: false });
|
|
454
|
+
if (!idResult.ok) {
|
|
455
|
+
throw new Error('Failed to generate ephemeral identity');
|
|
456
|
+
}
|
|
457
|
+
// Auto-generate name if not provided
|
|
458
|
+
const name = opts?.name ?? `agent-${Date.now()}`;
|
|
459
|
+
// Register ephemeral identity
|
|
460
|
+
const regResult = await registry.register(idResult.value.did, idResult.value.rawPublicKey, name, undefined, // scopes - no restrictions (allow all)
|
|
461
|
+
idResult.value.rawX25519PublicKey, idResult.value.mlKemPublicKey, idResult.value.mlDsaPublicKey, false);
|
|
462
|
+
if (!regResult.ok) {
|
|
463
|
+
throw new Error(`Failed to register ephemeral identity: ${regResult.error}`);
|
|
464
|
+
}
|
|
465
|
+
// Create agent instance
|
|
466
|
+
const agent = new Agent(idResult.value, name, registry, [transport], new MemoryNonceStore(), TIMESTAMP_WINDOW_MS, undefined, // No policy restrictions (allow all)
|
|
467
|
+
undefined);
|
|
468
|
+
// Auto-cleanup after 1 hour (ephemeral identity)
|
|
469
|
+
const TTL_MS = 3600000; // 1 hour
|
|
470
|
+
agent.cleanupTimer = setTimeout(async () => {
|
|
471
|
+
await registry.revoke(idResult.value.did);
|
|
472
|
+
// Ephemeral agent auto-revoked after TTL (no logging to avoid production noise)
|
|
473
|
+
}, TTL_MS);
|
|
474
|
+
return agent;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Create agent from simplified options (async factory).
|
|
478
|
+
*
|
|
479
|
+
* This is the async-safe way to construct agents with AgentOptions.
|
|
480
|
+
*
|
|
481
|
+
* @param options - Agent configuration
|
|
482
|
+
* @returns Agent instance
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* ```typescript
|
|
486
|
+
* import { Agent } from '@private.me/xbind';
|
|
487
|
+
*
|
|
488
|
+
* // Ephemeral agent (auto-cleanup after 1 hour)
|
|
489
|
+
* const agent = await Agent.from({
|
|
490
|
+
* identity: 'ephemeral',
|
|
491
|
+
* identityTTL: 3600, // seconds
|
|
492
|
+
* });
|
|
493
|
+
*
|
|
494
|
+
* // Use the agent
|
|
495
|
+
* await agent.send({ to, payload, scope: 'read' });
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
static async from(options = {}) {
|
|
499
|
+
const identity = options.identity ?? 'persistent';
|
|
500
|
+
const identityTTL = options.identityTTL ?? 3600000; // 1 hour default (ms)
|
|
501
|
+
const isEphemeral = identity === 'ephemeral';
|
|
502
|
+
// Resolve registry
|
|
503
|
+
const registry = typeof options.registry === 'string'
|
|
504
|
+
? new HttpTrustRegistry({ baseUrl: options.registry })
|
|
505
|
+
: options.registry ?? (isEphemeral ? new MemoryTrustRegistry() : new HttpTrustRegistry({ baseUrl: DEFAULT_REGISTRY_URL }));
|
|
506
|
+
// Resolve transport
|
|
507
|
+
const transports = options.transport
|
|
508
|
+
? Array.isArray(options.transport)
|
|
509
|
+
? options.transport
|
|
510
|
+
: [options.transport]
|
|
511
|
+
: [new HttpsTransportAdapter({ baseUrl: DEFAULT_RELAY_URL })];
|
|
512
|
+
// Generate identity
|
|
513
|
+
const idResult = await generateIdentity({
|
|
514
|
+
postQuantumSig: options.postQuantumSig ?? false,
|
|
515
|
+
});
|
|
516
|
+
if (!idResult.ok) {
|
|
517
|
+
throw new Error('Failed to generate identity');
|
|
518
|
+
}
|
|
519
|
+
// Register identity (ephemeral agents use auto-generated names)
|
|
520
|
+
const name = isEphemeral
|
|
521
|
+
? `ephemeral-agent-${Date.now()}`
|
|
522
|
+
: `agent-${Date.now()}`;
|
|
523
|
+
const regResult = await registry.register(idResult.value.did, idResult.value.rawPublicKey, name, undefined, // scopes
|
|
524
|
+
idResult.value.rawX25519PublicKey, idResult.value.mlKemPublicKey, idResult.value.mlDsaPublicKey, false);
|
|
525
|
+
if (!regResult.ok) {
|
|
526
|
+
throw new Error(`Failed to register identity: ${regResult.error}`);
|
|
527
|
+
}
|
|
528
|
+
// Create agent instance
|
|
529
|
+
const agent = new Agent(idResult.value, name, registry, transports, new MemoryNonceStore(), TIMESTAMP_WINDOW_MS, options.securityPolicy, options.backupConfig);
|
|
530
|
+
// Setup TTL cleanup for ephemeral identities
|
|
531
|
+
if (isEphemeral) {
|
|
532
|
+
agent.cleanupTimer = setTimeout(async () => {
|
|
533
|
+
// Auto-deregister after TTL expires
|
|
534
|
+
await registry.revoke(idResult.value.did);
|
|
535
|
+
}, identityTTL);
|
|
536
|
+
}
|
|
537
|
+
return agent;
|
|
538
|
+
}
|
|
539
|
+
/** Send a message to a recipient. */
|
|
540
|
+
async send(opts) {
|
|
541
|
+
const progress = new ProgressReporter(opts.onProgress);
|
|
542
|
+
progress.start('Resolving recipient identity...');
|
|
543
|
+
const recipientKey = await this.registry.resolve(opts.to);
|
|
544
|
+
if (!recipientKey.ok) {
|
|
545
|
+
return err(recipientKey.error === 'REVOKED'
|
|
546
|
+
? 'RECIPIENT_REVOKED'
|
|
547
|
+
: 'RECIPIENT_NOT_FOUND');
|
|
548
|
+
}
|
|
549
|
+
// Bilateral authorization: check if recipient accepts this scope
|
|
550
|
+
progress.update('Checking recipient authorization...', 10);
|
|
551
|
+
const receiverAccepts = await this.registry.hasReceiveScope(opts.to, opts.scope);
|
|
552
|
+
if (!receiverAccepts) {
|
|
553
|
+
this.lastDetail = `recipient=${opts.to}, scope=${opts.scope}`;
|
|
554
|
+
return err('RECEIVER_SCOPE_DENIED');
|
|
555
|
+
}
|
|
556
|
+
progress.update('Preparing message...', 15);
|
|
557
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(opts.payload));
|
|
558
|
+
// Security policy: classify action risk and determine security mode
|
|
559
|
+
progress.update('Determining security level...', 20);
|
|
560
|
+
const securityDecision = this.securityPolicy.classify({
|
|
561
|
+
action: opts.action ?? 'send',
|
|
562
|
+
params: typeof opts.payload === 'object' && opts.payload !== null
|
|
563
|
+
? opts.payload
|
|
564
|
+
: {},
|
|
565
|
+
sender: this.did,
|
|
566
|
+
recipient: opts.to,
|
|
567
|
+
scope: opts.scope,
|
|
568
|
+
securityOverride: opts.security,
|
|
569
|
+
});
|
|
570
|
+
this.lastSecurityDecision = securityDecision;
|
|
571
|
+
// Log security decision for transparency
|
|
572
|
+
progress.update(`Security: ${describeSecurityMode(securityDecision.mode)} — ${securityDecision.reason}`, 25);
|
|
573
|
+
// Determine whether to use split-channel based on policy decision
|
|
574
|
+
// Backward compatibility: explicit splitChannel flag overrides policy
|
|
575
|
+
const shouldUseSplitChannel = opts.splitChannel !== undefined
|
|
576
|
+
? opts.splitChannel
|
|
577
|
+
: securityDecision.mode.type === 'split';
|
|
578
|
+
// Xchange: opt-in via xchange: true on send or policy decision. Faster (single security layer) but
|
|
579
|
+
// trades per-share encryption and KEM for ~180x speed. Falls back to V3 if
|
|
580
|
+
// recipient doesn't support Xchange.
|
|
581
|
+
if (shouldUseSplitChannel && (opts.xchange || securityDecision.mode.type === 'xchange')) {
|
|
582
|
+
progress.update('Checking Xchange support...', 20);
|
|
583
|
+
const canXchange = await this.canUseXchange(opts.to);
|
|
584
|
+
if (canXchange) {
|
|
585
|
+
return this.sendXchange(opts, plaintext, progress);
|
|
586
|
+
}
|
|
587
|
+
// Fall back to V3 split-channel if recipient doesn't support Xchange
|
|
588
|
+
}
|
|
589
|
+
progress.update('Establishing key agreement...', 30);
|
|
590
|
+
const ecdhResult = await this.trySenderECDH(opts.to);
|
|
591
|
+
if (ecdhResult) {
|
|
592
|
+
if (shouldUseSplitChannel) {
|
|
593
|
+
return this.sendSplitChannel(opts, plaintext, ecdhResult.sharedKey, ecdhResult.ephemeralPublicKey, ecdhResult.kemCiphertext, ecdhResult.recipientHasMlDsa, progress);
|
|
594
|
+
}
|
|
595
|
+
// V3: both sides have ML-DSA + hybrid KEM
|
|
596
|
+
if (ecdhResult.kemCiphertext && ecdhResult.recipientHasMlDsa && this.identity.mlDsaSecretKey) {
|
|
597
|
+
return this.sendWithHybridV3(opts, plaintext, ecdhResult.sharedKey, ecdhResult.ephemeralPublicKey, ecdhResult.kemCiphertext, progress);
|
|
598
|
+
}
|
|
599
|
+
if (ecdhResult.kemCiphertext) {
|
|
600
|
+
return this.sendWithHybrid(opts, plaintext, ecdhResult.sharedKey, ecdhResult.ephemeralPublicKey, ecdhResult.kemCiphertext, progress);
|
|
601
|
+
}
|
|
602
|
+
return this.sendWithECDH(opts, plaintext, ecdhResult.sharedKey, ecdhResult.ephemeralPublicKey, progress);
|
|
603
|
+
}
|
|
604
|
+
// SECURITY FIX (v1.1.6): Removed insecure fallback to deriveSharedKey()
|
|
605
|
+
// Recipient must have x25519 public key registered for secure ECDH key agreement
|
|
606
|
+
return err('KEY_AGREEMENT_FAILED:RECIPIENT_HAS_NO_X25519_KEY');
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Verify and decrypt an incoming encrypted envelope (v1 or v2).
|
|
610
|
+
*
|
|
611
|
+
* ROT-1: Supports key rotation with fallback to old keys.
|
|
612
|
+
* If decryption fails with current keys, tries old rotated keys.
|
|
613
|
+
*
|
|
614
|
+
* @param envelope - Incoming transport envelope.
|
|
615
|
+
* @param opts - Optional receive options (e.g. allowCleartext).
|
|
616
|
+
*/
|
|
617
|
+
async receive(envelope, opts) {
|
|
618
|
+
this.lastDetail = '';
|
|
619
|
+
const progress = new ProgressReporter(opts?.onProgress);
|
|
620
|
+
progress.start('Verifying envelope signature...');
|
|
621
|
+
const verified = await this.verifyEnvelope(envelope);
|
|
622
|
+
if (!verified.ok)
|
|
623
|
+
return verified;
|
|
624
|
+
const { senderRawKey, payloadBytes } = verified.value;
|
|
625
|
+
let sharedKey;
|
|
626
|
+
// V4 Xchange: use receiveXchangeShare() instead
|
|
627
|
+
if (envelope.v === 4) {
|
|
628
|
+
return err('VERIFICATION_FAILED:UNSUPPORTED_VERSION');
|
|
629
|
+
}
|
|
630
|
+
progress.update('Deriving shared key...', 30);
|
|
631
|
+
// V2/V3 hybrid path: use X25519 + ML-KEM-768
|
|
632
|
+
if ((envelope.v === 2 || envelope.v === 3) && 'kemCiphertext' in envelope) {
|
|
633
|
+
if (!this.identity.mlKemSecretKey) {
|
|
634
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
635
|
+
}
|
|
636
|
+
// Type guards to prevent crashes on malformed envelopes
|
|
637
|
+
if (typeof envelope.ephemeralPub !== 'string' || typeof envelope.kemCiphertext !== 'string') {
|
|
638
|
+
this.lastDetail = 'ephemeralPub or kemCiphertext not string';
|
|
639
|
+
return err('VERIFICATION_FAILED:INVALID_ENVELOPE');
|
|
640
|
+
}
|
|
641
|
+
const ephPubBytes = fromBase64(envelope.ephemeralPub);
|
|
642
|
+
const kemCtBytes = fromBase64(envelope.kemCiphertext);
|
|
643
|
+
// Validate identity has required public keys for IETF X-Wing combiner
|
|
644
|
+
if (!this.identity.mlKemPublicKey || !this.identity.mlKemSecretKey) {
|
|
645
|
+
this.lastDetail = 'ML-KEM keys not available in identity';
|
|
646
|
+
return err('DECRYPT_FAILED:MISSING_MLKEM_KEYS');
|
|
647
|
+
}
|
|
648
|
+
const hybridKey = await receiverHybridKeyAgreement(this.identity.x25519PrivateKey, this.identity.rawX25519PublicKey, ephPubBytes, kemCtBytes, this.identity.mlKemSecretKey, this.identity.mlKemPublicKey);
|
|
649
|
+
if (!hybridKey.ok) {
|
|
650
|
+
// ROT-1: Try old rotated keys if current key agreement fails
|
|
651
|
+
if (this.identity.rotatedKeys && this.identity.rotatedKeys.length > 0) {
|
|
652
|
+
for (const rotated of this.identity.rotatedKeys) {
|
|
653
|
+
if (!rotated.mlKemSecretKey)
|
|
654
|
+
continue; // Skip if ML-KEM key not available
|
|
655
|
+
const oldHybridKey = await receiverHybridKeyAgreement(rotated.x25519PrivateKey, this.identity.rawX25519PublicKey, // Still use current public key (sender may not know about rotation yet)
|
|
656
|
+
ephPubBytes, kemCtBytes, rotated.mlKemSecretKey, this.identity.mlKemPublicKey);
|
|
657
|
+
if (oldHybridKey.ok) {
|
|
658
|
+
sharedKey = oldHybridKey.value;
|
|
659
|
+
progress.update('Decrypting with rotated keys...', 45);
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!sharedKey)
|
|
665
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
sharedKey = hybridKey.value;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
else if (envelope.ephemeralPub) {
|
|
672
|
+
// V1 with ECDH forward secrecy
|
|
673
|
+
if (typeof envelope.ephemeralPub !== 'string') {
|
|
674
|
+
this.lastDetail = 'ephemeralPub not string';
|
|
675
|
+
return err('VERIFICATION_FAILED:INVALID_ENVELOPE');
|
|
676
|
+
}
|
|
677
|
+
const ephPubBytes = fromBase64(envelope.ephemeralPub);
|
|
678
|
+
const ecdhKey = await receiverKeyAgreement(this.identity.x25519PrivateKey, ephPubBytes);
|
|
679
|
+
if (!ecdhKey.ok) {
|
|
680
|
+
// ROT-1: Try old rotated keys if current key agreement fails
|
|
681
|
+
if (this.identity.rotatedKeys && this.identity.rotatedKeys.length > 0) {
|
|
682
|
+
for (const rotated of this.identity.rotatedKeys) {
|
|
683
|
+
const oldEcdhKey = await receiverKeyAgreement(rotated.x25519PrivateKey, ephPubBytes);
|
|
684
|
+
if (oldEcdhKey.ok) {
|
|
685
|
+
sharedKey = oldEcdhKey.value;
|
|
686
|
+
progress.update('Decrypting with rotated keys...', 45);
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (!sharedKey)
|
|
692
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
sharedKey = ecdhKey.value;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
// No ephemeralPub: check if this is a signed-only envelope (allowCleartext)
|
|
700
|
+
if (opts?.allowCleartext) {
|
|
701
|
+
// Signed-only envelope: return payload directly (already verified signature above)
|
|
702
|
+
let payload;
|
|
703
|
+
try {
|
|
704
|
+
payload = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return err('DECRYPT_FAILED:PARSE');
|
|
708
|
+
}
|
|
709
|
+
progress.complete();
|
|
710
|
+
return ok({
|
|
711
|
+
sender: envelope.sender,
|
|
712
|
+
payload,
|
|
713
|
+
scope: envelope.scope,
|
|
714
|
+
timestamp: envelope.timestamp,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
// SECURITY FIX (v1.1.6): Removed insecure V1 SHA-256 fallback
|
|
718
|
+
// Envelopes without ephemeralPub are rejected (sender must use X25519 ECDH)
|
|
719
|
+
return err('DECRYPT_FAILED:NO_EPHEMERAL_KEY');
|
|
720
|
+
}
|
|
721
|
+
progress.update('Decrypting payload...', 60);
|
|
722
|
+
// TypeScript narrowing: sharedKey is guaranteed to be defined here due to control flow
|
|
723
|
+
if (!sharedKey)
|
|
724
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
725
|
+
const decrypted = await decryptPayload(envelope, sharedKey);
|
|
726
|
+
if (!decrypted.ok) {
|
|
727
|
+
return err('DECRYPT_FAILED:DECRYPTION');
|
|
728
|
+
}
|
|
729
|
+
progress.update('Parsing message...', 90);
|
|
730
|
+
let payload;
|
|
731
|
+
try {
|
|
732
|
+
payload = JSON.parse(new TextDecoder().decode(decrypted.value));
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return err('DECRYPT_FAILED:PARSE');
|
|
736
|
+
}
|
|
737
|
+
progress.complete();
|
|
738
|
+
// Mechanism 2: Protocol information available in metadata for applications to display
|
|
739
|
+
// Applications can show this to users if needed (envelope.protocol, envelope.documentationUrl)
|
|
740
|
+
return ok({
|
|
741
|
+
sender: envelope.sender,
|
|
742
|
+
payload,
|
|
743
|
+
scope: envelope.scope,
|
|
744
|
+
timestamp: envelope.timestamp,
|
|
745
|
+
metadata: envelope.protocol && envelope.documentationUrl
|
|
746
|
+
? {
|
|
747
|
+
protocol: envelope.protocol,
|
|
748
|
+
documentationUrl: envelope.documentationUrl,
|
|
749
|
+
}
|
|
750
|
+
: undefined,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Verify only the signature on an envelope without consuming the nonce.
|
|
755
|
+
*
|
|
756
|
+
* Does NOT check timestamp, nonce, or scope. Use this for pre-screening
|
|
757
|
+
* or audit logging where you need to confirm the sender but don't want
|
|
758
|
+
* to consume replay-prevention state.
|
|
759
|
+
*
|
|
760
|
+
* @param envelope - The envelope to verify.
|
|
761
|
+
* @returns `{ sender, valid }` or error if the DID cannot be resolved.
|
|
762
|
+
*/
|
|
763
|
+
async verifySignature(envelope) {
|
|
764
|
+
const senderKey = await this.registry.resolve(envelope.sender);
|
|
765
|
+
if (!senderKey.ok)
|
|
766
|
+
return err('VERIFICATION_FAILED:DID_NOT_IN_REGISTRY');
|
|
767
|
+
const pubKey = await importPublicKey(senderKey.value);
|
|
768
|
+
if (!pubKey.ok)
|
|
769
|
+
return err('VERIFICATION_FAILED:KEY_IMPORT_FAILED');
|
|
770
|
+
const sigBytes = fromBase64(envelope.signature);
|
|
771
|
+
// SECURITY FIX (v1.1.3): Verify canonical representation (not just payload)
|
|
772
|
+
const canonicalData = JSON.stringify({
|
|
773
|
+
v: envelope.v,
|
|
774
|
+
alg: envelope.alg,
|
|
775
|
+
sender: envelope.sender,
|
|
776
|
+
recipient: envelope.recipient,
|
|
777
|
+
timestamp: envelope.timestamp,
|
|
778
|
+
nonce: envelope.nonce,
|
|
779
|
+
scope: envelope.scope,
|
|
780
|
+
payload: envelope.payload,
|
|
781
|
+
});
|
|
782
|
+
const canonicalBytes = new TextEncoder().encode(canonicalData);
|
|
783
|
+
const sigValid = await verify(pubKey.value, sigBytes, canonicalBytes);
|
|
784
|
+
if (!sigValid.ok)
|
|
785
|
+
return err('VERIFICATION_FAILED:SIGNATURE_MISMATCH');
|
|
786
|
+
return ok({ sender: envelope.sender, valid: sigValid.value });
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Export the raw 32-byte private key seeds for both Ed25519 and X25519.
|
|
790
|
+
*
|
|
791
|
+
* These are the raw private key bytes extracted from PKCS8, NOT the
|
|
792
|
+
* original HKDF seed (that derivation is one-way). Use these for
|
|
793
|
+
* persistence or cross-device transfer.
|
|
794
|
+
*
|
|
795
|
+
* @returns Two 32-byte Uint8Arrays or error.
|
|
796
|
+
*/
|
|
797
|
+
async exportSeeds() {
|
|
798
|
+
const edPkcs8 = await exportPKCS8(this.identity.privateKey);
|
|
799
|
+
if (!edPkcs8.ok)
|
|
800
|
+
return err('IDENTITY_FAILED');
|
|
801
|
+
const x25519Pkcs8 = await exportX25519PKCS8(this.identity.x25519PrivateKey);
|
|
802
|
+
if (!x25519Pkcs8.ok)
|
|
803
|
+
return err('IDENTITY_FAILED');
|
|
804
|
+
const edRaw = extractRawEd25519(edPkcs8.value);
|
|
805
|
+
if (!edRaw.ok)
|
|
806
|
+
return err('IDENTITY_FAILED');
|
|
807
|
+
const x25519Raw = extractRawX25519(x25519Pkcs8.value);
|
|
808
|
+
if (!x25519Raw.ok)
|
|
809
|
+
return err('IDENTITY_FAILED');
|
|
810
|
+
return ok({
|
|
811
|
+
ed25519: edRaw.value,
|
|
812
|
+
x25519: x25519Raw.value,
|
|
813
|
+
mlKemSecretKey: this.identity.mlKemSecretKey,
|
|
814
|
+
mlKemPublicKey: this.identity.mlKemPublicKey,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Split a cryptographic key into backup shares using XorIDA.
|
|
819
|
+
*
|
|
820
|
+
* Uses the agent's backup configuration (default: k=2, n=3).
|
|
821
|
+
* Any `threshold` shares can reconstruct the original key.
|
|
822
|
+
* Each share reveals zero information (information-theoretic security).
|
|
823
|
+
*
|
|
824
|
+
* @param key - The key to split (32 or 64 bytes typical).
|
|
825
|
+
* @returns Array of backup shares or error.
|
|
826
|
+
*
|
|
827
|
+
* @example
|
|
828
|
+
* ```typescript
|
|
829
|
+
* // Generate a key and split it
|
|
830
|
+
* const key = crypto.getRandomValues(new Uint8Array(32));
|
|
831
|
+
* const shares = await agent.splitKey(key);
|
|
832
|
+
*
|
|
833
|
+
* if (shares.ok) {
|
|
834
|
+
* // Store shares in separate locations
|
|
835
|
+
* shares.value.forEach((share, i) => {
|
|
836
|
+
* storeShare(`backup-${i}.json`, JSON.stringify(share));
|
|
837
|
+
* });
|
|
838
|
+
* }
|
|
839
|
+
* ```
|
|
840
|
+
*/
|
|
841
|
+
async splitKey(key) {
|
|
842
|
+
// Dynamic import avoids circular dependency
|
|
843
|
+
const { splitKeyWithBackup } = await import('./backup-config.js');
|
|
844
|
+
const result = await splitKeyWithBackup(key, this.backupConfig);
|
|
845
|
+
if (!result.ok) {
|
|
846
|
+
return err('ENVELOPE_FAILED:SPLIT');
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Reconstruct a cryptographic key from backup shares.
|
|
852
|
+
*
|
|
853
|
+
* Requires at least `threshold` shares. Verifies HMAC before returning
|
|
854
|
+
* the reconstructed key to prevent tampering.
|
|
855
|
+
*
|
|
856
|
+
* @param shares - Backup shares (must be >= threshold).
|
|
857
|
+
* @returns Reconstructed key or error.
|
|
858
|
+
*
|
|
859
|
+
* @example
|
|
860
|
+
* ```typescript
|
|
861
|
+
* // Load shares from storage
|
|
862
|
+
* const share0 = JSON.parse(loadShare('backup-0.json'));
|
|
863
|
+
* const share1 = JSON.parse(loadShare('backup-1.json'));
|
|
864
|
+
*
|
|
865
|
+
* // Reconstruct from any 2 shares (threshold=2)
|
|
866
|
+
* const key = await agent.reconstructKey([share0, share1]);
|
|
867
|
+
*
|
|
868
|
+
* if (key.ok) {
|
|
869
|
+
* // Use reconstructed key
|
|
870
|
+
* console.log('Key recovered:', key.value);
|
|
871
|
+
* }
|
|
872
|
+
* ```
|
|
873
|
+
*/
|
|
874
|
+
async reconstructKey(shares) {
|
|
875
|
+
// Dynamic import avoids circular dependency
|
|
876
|
+
const { reconstructKeyFromBackup } = await import('./backup-config.js');
|
|
877
|
+
const result = await reconstructKeyFromBackup(shares);
|
|
878
|
+
if (!result.ok) {
|
|
879
|
+
return err('DECRYPT_FAILED');
|
|
880
|
+
}
|
|
881
|
+
return result;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Verify a signed (unencrypted) envelope and return the message.
|
|
885
|
+
*
|
|
886
|
+
* Use for envelopes created with createSignedEnvelope().
|
|
887
|
+
* Verifies signature, timestamp, nonce, and scope — skips decryption.
|
|
888
|
+
*
|
|
889
|
+
* @param envelope - A signed (unencrypted) transport envelope.
|
|
890
|
+
* @returns Verified message or error with sub-code.
|
|
891
|
+
*/
|
|
892
|
+
async receiveSigned(envelope) {
|
|
893
|
+
this.lastDetail = '';
|
|
894
|
+
const verified = await this.verifyEnvelope(envelope);
|
|
895
|
+
if (!verified.ok)
|
|
896
|
+
return verified;
|
|
897
|
+
let payload;
|
|
898
|
+
try {
|
|
899
|
+
payload = JSON.parse(new TextDecoder().decode(verified.value.payloadBytes));
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
return err('DECRYPT_FAILED:PARSE');
|
|
903
|
+
}
|
|
904
|
+
// Mechanism 2: Protocol information available in metadata for applications to display
|
|
905
|
+
// Applications can show this to users if needed (envelope.protocol, envelope.documentationUrl)
|
|
906
|
+
return ok({
|
|
907
|
+
sender: envelope.sender,
|
|
908
|
+
payload,
|
|
909
|
+
scope: envelope.scope,
|
|
910
|
+
timestamp: envelope.timestamp,
|
|
911
|
+
metadata: envelope.protocol && envelope.documentationUrl
|
|
912
|
+
? {
|
|
913
|
+
protocol: envelope.protocol,
|
|
914
|
+
documentationUrl: envelope.documentationUrl,
|
|
915
|
+
}
|
|
916
|
+
: undefined,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Discover available tools in the registry.
|
|
921
|
+
*
|
|
922
|
+
* Query xRegistry for tools matching the given service prefix or capability.
|
|
923
|
+
* Returns tool metadata including name, description, schema, and trust level.
|
|
924
|
+
*
|
|
925
|
+
* @param service - Optional service prefix to filter by (e.g., "payments")
|
|
926
|
+
* @returns Array of tool metadata or empty array if none found
|
|
927
|
+
*
|
|
928
|
+
* @example
|
|
929
|
+
* ```typescript
|
|
930
|
+
* import { Agent } from '@private.me/xbind';
|
|
931
|
+
* import { ToolRegistry } from"./_deps/xregistry/index.js";
|
|
932
|
+
* import { setToolRegistry } from '@private.me/xbind/agent-call';
|
|
933
|
+
*
|
|
934
|
+
* const registry = new ToolRegistry();
|
|
935
|
+
* registry.register({
|
|
936
|
+
* name: 'payments:createCharge',
|
|
937
|
+
* description: 'Create a payment charge',
|
|
938
|
+
* schema: { type: 'object', properties: { amount: { type: 'number' } } },
|
|
939
|
+
* trustLevel: 'verified',
|
|
940
|
+
* endpoint: 'https://api.stripe.com/v1/charges',
|
|
941
|
+
* capabilities: ['payment', 'charge']
|
|
942
|
+
* });
|
|
943
|
+
*
|
|
944
|
+
* setToolRegistry(registry);
|
|
945
|
+
*
|
|
946
|
+
* const agent = await Agent.quickstart();
|
|
947
|
+
*
|
|
948
|
+
* // Discover all payment tools
|
|
949
|
+
* const tools = await agent.discover('payments');
|
|
950
|
+
* console.log(tools); // [{ name: 'payments:createCharge', ... }]
|
|
951
|
+
*
|
|
952
|
+
* // Discover all tools
|
|
953
|
+
* const allTools = await agent.discover();
|
|
954
|
+
* ```
|
|
955
|
+
*/
|
|
956
|
+
async discover(service) {
|
|
957
|
+
// Import dynamically to avoid circular dependency
|
|
958
|
+
// SAFETY: Dynamic import allows agent-call.ts to import agent.ts without circular reference
|
|
959
|
+
const { getToolRegistry } = await import('./agent-call.js');
|
|
960
|
+
const registry = getToolRegistry();
|
|
961
|
+
if (!registry) {
|
|
962
|
+
// No registry configured - return empty array
|
|
963
|
+
return [];
|
|
964
|
+
}
|
|
965
|
+
if (!service) {
|
|
966
|
+
// No filter - return all tools
|
|
967
|
+
return registry.listAll();
|
|
968
|
+
}
|
|
969
|
+
// Filter by service prefix using registry search
|
|
970
|
+
// Search handles name, capability, and description matching
|
|
971
|
+
const results = registry.search(service);
|
|
972
|
+
return results;
|
|
973
|
+
}
|
|
974
|
+
/** Generate an Express-compatible middleware handler. */
|
|
975
|
+
middleware() {
|
|
976
|
+
return async (req, res, next) => {
|
|
977
|
+
const validated = validateEnvelope(req.body);
|
|
978
|
+
if (!validated.ok) {
|
|
979
|
+
res.status(400).json({ error: validated.error });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
// SAFETY: validateEnvelope returns AnyTransportEnvelope
|
|
983
|
+
const result = await this.receive(validated.value);
|
|
984
|
+
if (!result.ok) {
|
|
985
|
+
const code = result.error === 'TIMESTAMP_EXPIRED'
|
|
986
|
+
|| result.error === 'REPLAY_DETECTED'
|
|
987
|
+
? 403 : 401;
|
|
988
|
+
res.status(code).json({ error: result.error });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
req['agentMessage'] = result.value;
|
|
992
|
+
next();
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Cleanup resources (timers, handlers, etc.)
|
|
997
|
+
*
|
|
998
|
+
* Call this in tests or when disposing an agent to prevent timer leaks.
|
|
999
|
+
* Clears the ephemeral identity auto-cleanup timer if present.
|
|
1000
|
+
*/
|
|
1001
|
+
cleanup() {
|
|
1002
|
+
if (this.cleanupTimer) {
|
|
1003
|
+
clearTimeout(this.cleanupTimer);
|
|
1004
|
+
this.cleanupTimer = undefined;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Dispose of agent resources (alias for cleanup)
|
|
1009
|
+
*
|
|
1010
|
+
* Provided for compatibility with test cleanup patterns.
|
|
1011
|
+
*/
|
|
1012
|
+
dispose() {
|
|
1013
|
+
this.cleanup();
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Attempt sender-side key agreement.
|
|
1017
|
+
*
|
|
1018
|
+
* If both sides support ML-KEM-768, uses hybrid (X25519 + ML-KEM-768).
|
|
1019
|
+
* Otherwise falls back to X25519-only ECDH.
|
|
1020
|
+
* Returns null if recipient has no X25519 key registered.
|
|
1021
|
+
* Also returns whether recipient supports ML-DSA-65 for v3 envelopes.
|
|
1022
|
+
*/
|
|
1023
|
+
async trySenderECDH(recipientDid) {
|
|
1024
|
+
const entry = await this.registry.getEntry(recipientDid);
|
|
1025
|
+
if (!entry.ok || !entry.value.x25519PublicKey)
|
|
1026
|
+
return null;
|
|
1027
|
+
const recipientHasMlDsa = !!entry.value.mlDsaPublicKey;
|
|
1028
|
+
const recipientX25519 = await importX25519PublicKey(entry.value.x25519PublicKey);
|
|
1029
|
+
if (!recipientX25519.ok)
|
|
1030
|
+
return null;
|
|
1031
|
+
// Try hybrid if both sides have ML-KEM keys
|
|
1032
|
+
if (entry.value.mlKemPublicKey && this.identity.mlKemSecretKey) {
|
|
1033
|
+
const hybrid = await senderHybridKeyAgreement(recipientX25519.value, entry.value.mlKemPublicKey);
|
|
1034
|
+
if (hybrid.ok) {
|
|
1035
|
+
return {
|
|
1036
|
+
sharedKey: hybrid.value.sharedKey,
|
|
1037
|
+
ephemeralPublicKey: hybrid.value.ephemeralPublicKey,
|
|
1038
|
+
kemCiphertext: hybrid.value.kemCiphertext,
|
|
1039
|
+
recipientHasMlDsa,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Fallback to X25519-only
|
|
1044
|
+
const result = await senderKeyAgreement(recipientX25519.value);
|
|
1045
|
+
if (!result.ok)
|
|
1046
|
+
return null;
|
|
1047
|
+
return { ...result.value, recipientHasMlDsa };
|
|
1048
|
+
}
|
|
1049
|
+
/** Send with ECDH forward secrecy (ephemeral key in envelope). */
|
|
1050
|
+
async sendWithECDH(opts, plaintext, sharedKey, ephemeralPublicKey, progress) {
|
|
1051
|
+
progress?.update('Encrypting message with ECDH...', 60);
|
|
1052
|
+
const envelope = await createEnvelope({
|
|
1053
|
+
senderDid: this.identity.did,
|
|
1054
|
+
recipientDid: opts.to,
|
|
1055
|
+
scope: opts.scope,
|
|
1056
|
+
plaintext,
|
|
1057
|
+
privateKey: this.identity.privateKey,
|
|
1058
|
+
sharedKey,
|
|
1059
|
+
ephemeralPublicKey,
|
|
1060
|
+
});
|
|
1061
|
+
if (!envelope.ok)
|
|
1062
|
+
return err('ENVELOPE_FAILED:ENCRYPT');
|
|
1063
|
+
progress?.update('Sending message...', 90);
|
|
1064
|
+
const result = await this.transports[0].send(envelope.value, opts.to);
|
|
1065
|
+
if (result.ok) {
|
|
1066
|
+
progress?.complete();
|
|
1067
|
+
}
|
|
1068
|
+
return result;
|
|
1069
|
+
}
|
|
1070
|
+
/** Send with hybrid KEM (X25519 + ML-KEM-768) via v2 envelope. */
|
|
1071
|
+
async sendWithHybrid(opts, plaintext, sharedKey, ephemeralPublicKey, kemCiphertext, progress) {
|
|
1072
|
+
progress?.update('Encrypting message with hybrid KEM...', 60);
|
|
1073
|
+
const envelope = await createEnvelopeV2({
|
|
1074
|
+
senderDid: this.identity.did,
|
|
1075
|
+
recipientDid: opts.to,
|
|
1076
|
+
scope: opts.scope,
|
|
1077
|
+
plaintext,
|
|
1078
|
+
privateKey: this.identity.privateKey,
|
|
1079
|
+
sharedKey,
|
|
1080
|
+
ephemeralPublicKey,
|
|
1081
|
+
kemCiphertext,
|
|
1082
|
+
});
|
|
1083
|
+
if (!envelope.ok)
|
|
1084
|
+
return err('ENVELOPE_FAILED:ENCRYPT');
|
|
1085
|
+
progress?.update('Sending message...', 90);
|
|
1086
|
+
// SAFETY: AnyTransportEnvelope is accepted by transport.send
|
|
1087
|
+
const result = await this.transports[0].send(envelope.value, opts.to);
|
|
1088
|
+
if (result.ok) {
|
|
1089
|
+
progress?.complete();
|
|
1090
|
+
}
|
|
1091
|
+
return result;
|
|
1092
|
+
}
|
|
1093
|
+
/** Send with hybrid KEM + dual signatures via v3 envelope. */
|
|
1094
|
+
async sendWithHybridV3(opts, plaintext, sharedKey, ephemeralPublicKey, kemCiphertext, progress) {
|
|
1095
|
+
if (!this.identity.mlDsaSecretKey) {
|
|
1096
|
+
return err('ENVELOPE_FAILED:PQ_KEY_MISSING');
|
|
1097
|
+
}
|
|
1098
|
+
progress?.update('Encrypting with post-quantum signatures...', 60);
|
|
1099
|
+
const envelope = await createEnvelopeV3({
|
|
1100
|
+
senderDid: this.identity.did,
|
|
1101
|
+
recipientDid: opts.to,
|
|
1102
|
+
scope: opts.scope,
|
|
1103
|
+
plaintext,
|
|
1104
|
+
privateKey: this.identity.privateKey,
|
|
1105
|
+
sharedKey,
|
|
1106
|
+
ephemeralPublicKey,
|
|
1107
|
+
kemCiphertext,
|
|
1108
|
+
mlDsaSecretKey: this.identity.mlDsaSecretKey,
|
|
1109
|
+
});
|
|
1110
|
+
if (!envelope.ok) {
|
|
1111
|
+
this.lastDetail = `v3 envelope error: ${envelope.error}`;
|
|
1112
|
+
return err('ENVELOPE_FAILED:ENCRYPT');
|
|
1113
|
+
}
|
|
1114
|
+
progress?.update('Sending message...', 90);
|
|
1115
|
+
// SAFETY: AnyTransportEnvelope is accepted by transport.send
|
|
1116
|
+
const result = await this.transports[0].send(envelope.value, opts.to);
|
|
1117
|
+
if (result.ok) {
|
|
1118
|
+
progress?.complete();
|
|
1119
|
+
}
|
|
1120
|
+
return result;
|
|
1121
|
+
}
|
|
1122
|
+
async sendDirect(opts, plaintext, sharedKey, progress) {
|
|
1123
|
+
progress?.update('Encrypting message...', 60);
|
|
1124
|
+
const envelope = await createEnvelope({
|
|
1125
|
+
senderDid: this.identity.did,
|
|
1126
|
+
recipientDid: opts.to,
|
|
1127
|
+
scope: opts.scope,
|
|
1128
|
+
plaintext,
|
|
1129
|
+
privateKey: this.identity.privateKey,
|
|
1130
|
+
sharedKey,
|
|
1131
|
+
});
|
|
1132
|
+
if (!envelope.ok)
|
|
1133
|
+
return err('ENVELOPE_FAILED:ENCRYPT');
|
|
1134
|
+
progress?.update('Sending message...', 90);
|
|
1135
|
+
const result = await this.transports[0].send(envelope.value, opts.to);
|
|
1136
|
+
if (result.ok) {
|
|
1137
|
+
progress?.complete();
|
|
1138
|
+
}
|
|
1139
|
+
return result;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Check whether the recipient supports Xchange (XorIDA key transport).
|
|
1143
|
+
*
|
|
1144
|
+
* @param recipientDid - Recipient DID to check.
|
|
1145
|
+
* @returns True if recipient has xchange: true in registry.
|
|
1146
|
+
*/
|
|
1147
|
+
async canUseXchange(recipientDid) {
|
|
1148
|
+
const entry = await this.registry.getEntry(recipientDid);
|
|
1149
|
+
if (!entry.ok)
|
|
1150
|
+
return false;
|
|
1151
|
+
return entry.value.xchange === true;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Send via Xchange mode: random key + AES-GCM encrypt, bundle, XorIDA split, v4 envelopes.
|
|
1155
|
+
*
|
|
1156
|
+
* Opt-in performance mode. No KEM, no key agreement — the random key is
|
|
1157
|
+
* embedded in the bundle and split across channels. Single security layer
|
|
1158
|
+
* (information-theoretic) + Ed25519 authentication. ~180x faster than V3.
|
|
1159
|
+
*/
|
|
1160
|
+
async sendXchange(opts, plaintext, progress) {
|
|
1161
|
+
const config = opts.splitChannelConfig ?? DEFAULT_SPLIT_CONFIG;
|
|
1162
|
+
if (this.transports.length < config.totalShares) {
|
|
1163
|
+
// eslint-disable-next-line no-console
|
|
1164
|
+
console.warn(`Split-channel: ${config.totalShares} shares but only ${this.transports.length} transport(s). ` +
|
|
1165
|
+
`For channel separation, provide at least ${config.totalShares} transports.`);
|
|
1166
|
+
}
|
|
1167
|
+
progress?.update('Generating Xchange key...', 40);
|
|
1168
|
+
const keyResult = await generateXchangeKey();
|
|
1169
|
+
if (!keyResult.ok)
|
|
1170
|
+
return err('KEY_AGREEMENT_FAILED');
|
|
1171
|
+
progress?.update('Encrypting message...', 50);
|
|
1172
|
+
const bundleResult = await xchangeEncrypt(plaintext, keyResult.value);
|
|
1173
|
+
if (!bundleResult.ok)
|
|
1174
|
+
return err('ENVELOPE_FAILED:ENCRYPT');
|
|
1175
|
+
// Load crypto package from Vault Store (payment-gated)
|
|
1176
|
+
const crypto = await this.ensureCrypto();
|
|
1177
|
+
const n = config.totalShares;
|
|
1178
|
+
const k = config.threshold;
|
|
1179
|
+
const p = crypto.nextOddPrime(n);
|
|
1180
|
+
const blockSize = p - 1;
|
|
1181
|
+
const padded = crypto.pkcs7Pad(bundleResult.value, blockSize);
|
|
1182
|
+
const { key: hmacKey, signature: hmacSig } = await crypto.generateHMAC(padded);
|
|
1183
|
+
progress?.update('Splitting message into shares...', 60);
|
|
1184
|
+
let shareArrays;
|
|
1185
|
+
try {
|
|
1186
|
+
shareArrays = crypto.splitXorIDA(padded, n, k);
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
return err('ENVELOPE_FAILED:SPLIT');
|
|
1190
|
+
}
|
|
1191
|
+
const hmacKeyB64 = toBase64(hmacKey);
|
|
1192
|
+
const hmacSigB64 = toBase64(hmacSig);
|
|
1193
|
+
const groupId = generateUUID();
|
|
1194
|
+
const results = [];
|
|
1195
|
+
progress?.update('Sending shares...', 70);
|
|
1196
|
+
for (let i = 0; i < shareArrays.length; i++) {
|
|
1197
|
+
const shareData = shareArrays[i];
|
|
1198
|
+
const shareB64 = formatShareHeader(toBase64(shareData));
|
|
1199
|
+
const shareBytes = new TextEncoder().encode(shareB64);
|
|
1200
|
+
const envResult = await createEnvelopeV4({
|
|
1201
|
+
senderDid: this.identity.did,
|
|
1202
|
+
recipientDid: opts.to,
|
|
1203
|
+
scope: opts.scope,
|
|
1204
|
+
shareData: shareBytes,
|
|
1205
|
+
privateKey: this.identity.privateKey,
|
|
1206
|
+
shareIndex: i,
|
|
1207
|
+
shareTotal: n,
|
|
1208
|
+
shareThreshold: k,
|
|
1209
|
+
shareGroupId: groupId,
|
|
1210
|
+
shareHmacKey: hmacKeyB64,
|
|
1211
|
+
shareHmacSig: hmacSigB64,
|
|
1212
|
+
});
|
|
1213
|
+
if (!envResult.ok) {
|
|
1214
|
+
results.push(err('ENVELOPE_FAILED:ENCRYPT'));
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
// Route share[i] to transports[i % transports.length]
|
|
1218
|
+
const transport = this.transports[i % this.transports.length];
|
|
1219
|
+
// SAFETY: Transport accepts v1; v4 has same required fields
|
|
1220
|
+
const sendResult = await transport.send(envResult.value, opts.to);
|
|
1221
|
+
results.push(sendResult);
|
|
1222
|
+
// Update progress for each share sent
|
|
1223
|
+
const shareProgress = 70 + Math.floor((i + 1) / shareArrays.length * 20);
|
|
1224
|
+
progress?.update(`Sent share ${i + 1}/${shareArrays.length}...`, shareProgress);
|
|
1225
|
+
}
|
|
1226
|
+
const successes = results.filter((r) => r.ok).length;
|
|
1227
|
+
if (successes < k) {
|
|
1228
|
+
return err('SEND_FAILED:BELOW_THRESHOLD');
|
|
1229
|
+
}
|
|
1230
|
+
progress?.complete();
|
|
1231
|
+
return ok(undefined);
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Split plaintext via XorIDA and send each share as a separate V3 envelope.
|
|
1235
|
+
*
|
|
1236
|
+
* Default split-channel path with three independent cryptographic layers:
|
|
1237
|
+
* XorIDA payload split, hybrid PQ KEM, and dual signatures.
|
|
1238
|
+
* Returns ok() if at least threshold sends succeed.
|
|
1239
|
+
*/
|
|
1240
|
+
async sendSplitChannel(opts, plaintext, sharedKey, ephemeralPublicKey, kemCiphertext, recipientHasMlDsa, progress) {
|
|
1241
|
+
const config = opts.splitChannelConfig ?? DEFAULT_SPLIT_CONFIG;
|
|
1242
|
+
if (this.transports.length < config.totalShares) {
|
|
1243
|
+
// eslint-disable-next-line no-console
|
|
1244
|
+
console.warn(`Split-channel: ${config.totalShares} shares but only ${this.transports.length} transport(s). ` +
|
|
1245
|
+
`For channel separation, provide at least ${config.totalShares} transports.`);
|
|
1246
|
+
}
|
|
1247
|
+
progress?.update('Splitting message into shares...', 50);
|
|
1248
|
+
const splitResult = await splitForChannel(plaintext, config);
|
|
1249
|
+
if (!splitResult.ok)
|
|
1250
|
+
return err('ENVELOPE_FAILED:SPLIT');
|
|
1251
|
+
const shares = splitResult.value;
|
|
1252
|
+
progress?.update('Encrypting and sending shares...', 70);
|
|
1253
|
+
const results = await this.sendShareEnvelopes(opts, shares, sharedKey, ephemeralPublicKey, kemCiphertext, recipientHasMlDsa, progress);
|
|
1254
|
+
const successes = results.filter((r) => r.ok).length;
|
|
1255
|
+
if (successes < config.threshold) {
|
|
1256
|
+
return err('SEND_FAILED:BELOW_THRESHOLD');
|
|
1257
|
+
}
|
|
1258
|
+
progress?.complete();
|
|
1259
|
+
return ok(undefined);
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Create and send an envelope for each share independently.
|
|
1263
|
+
*
|
|
1264
|
+
* Routes share[i] to transports[i % transports.length] for channel separation.
|
|
1265
|
+
*/
|
|
1266
|
+
async sendShareEnvelopes(opts, shares, sharedKey, ephemeralPublicKey, kemCiphertext, recipientHasMlDsa, progress) {
|
|
1267
|
+
const results = [];
|
|
1268
|
+
for (let i = 0; i < shares.length; i++) {
|
|
1269
|
+
const share = shares[i];
|
|
1270
|
+
const shareBytes = new TextEncoder().encode(share.data);
|
|
1271
|
+
let envResult;
|
|
1272
|
+
if (kemCiphertext && ephemeralPublicKey && recipientHasMlDsa && this.identity.mlDsaSecretKey) {
|
|
1273
|
+
envResult = await createEnvelopeV3({
|
|
1274
|
+
senderDid: this.identity.did,
|
|
1275
|
+
recipientDid: opts.to,
|
|
1276
|
+
scope: opts.scope,
|
|
1277
|
+
plaintext: shareBytes,
|
|
1278
|
+
privateKey: this.identity.privateKey,
|
|
1279
|
+
sharedKey,
|
|
1280
|
+
ephemeralPublicKey,
|
|
1281
|
+
kemCiphertext,
|
|
1282
|
+
mlDsaSecretKey: this.identity.mlDsaSecretKey,
|
|
1283
|
+
shareIndex: share.index,
|
|
1284
|
+
shareTotal: share.total,
|
|
1285
|
+
shareThreshold: share.threshold,
|
|
1286
|
+
shareGroupId: share.groupId,
|
|
1287
|
+
shareHmacKey: share.hmacKey,
|
|
1288
|
+
shareHmacSig: share.hmacSig,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
else if (kemCiphertext && ephemeralPublicKey) {
|
|
1292
|
+
envResult = await createEnvelopeV2({
|
|
1293
|
+
senderDid: this.identity.did,
|
|
1294
|
+
recipientDid: opts.to,
|
|
1295
|
+
scope: opts.scope,
|
|
1296
|
+
plaintext: shareBytes,
|
|
1297
|
+
privateKey: this.identity.privateKey,
|
|
1298
|
+
sharedKey,
|
|
1299
|
+
ephemeralPublicKey,
|
|
1300
|
+
kemCiphertext,
|
|
1301
|
+
shareIndex: share.index,
|
|
1302
|
+
shareTotal: share.total,
|
|
1303
|
+
shareThreshold: share.threshold,
|
|
1304
|
+
shareGroupId: share.groupId,
|
|
1305
|
+
shareHmacKey: share.hmacKey,
|
|
1306
|
+
shareHmacSig: share.hmacSig,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
envResult = await createEnvelope({
|
|
1311
|
+
senderDid: this.identity.did,
|
|
1312
|
+
recipientDid: opts.to,
|
|
1313
|
+
scope: opts.scope,
|
|
1314
|
+
plaintext: shareBytes,
|
|
1315
|
+
privateKey: this.identity.privateKey,
|
|
1316
|
+
sharedKey,
|
|
1317
|
+
ephemeralPublicKey,
|
|
1318
|
+
shareIndex: share.index,
|
|
1319
|
+
shareTotal: share.total,
|
|
1320
|
+
shareThreshold: share.threshold,
|
|
1321
|
+
shareGroupId: share.groupId,
|
|
1322
|
+
shareHmacKey: share.hmacKey,
|
|
1323
|
+
shareHmacSig: share.hmacSig,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
if (!envResult.ok) {
|
|
1327
|
+
results.push(err('ENVELOPE_FAILED:ENCRYPT'));
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
// Route share[i] to transports[i % transports.length]
|
|
1331
|
+
const transport = this.transports[i % this.transports.length];
|
|
1332
|
+
// SAFETY: Transport accepts v1; v2/v3 is a superset of v1 fields
|
|
1333
|
+
const sendResult = await transport.send(envResult.value, opts.to);
|
|
1334
|
+
results.push(sendResult);
|
|
1335
|
+
// Update progress for each share sent
|
|
1336
|
+
const shareProgress = 70 + Math.floor((i + 1) / shares.length * 20);
|
|
1337
|
+
progress?.update(`Sent share ${i + 1}/${shares.length}...`, shareProgress);
|
|
1338
|
+
}
|
|
1339
|
+
return results;
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Receive and accumulate a split-channel share envelope.
|
|
1343
|
+
*
|
|
1344
|
+
* Verifies the envelope, extracts share metadata, accumulates shares.
|
|
1345
|
+
* When threshold is reached, reconstructs the original plaintext.
|
|
1346
|
+
*
|
|
1347
|
+
* @param envelope - A share envelope with split-channel metadata
|
|
1348
|
+
* @returns AgentMessage if threshold met, null if more shares needed
|
|
1349
|
+
*/
|
|
1350
|
+
async receiveSplitShare(envelope) {
|
|
1351
|
+
if (envelope.shareGroupId === undefined) {
|
|
1352
|
+
return err('VERIFICATION_FAILED');
|
|
1353
|
+
}
|
|
1354
|
+
const receiveResult = await this.receiveRaw(envelope);
|
|
1355
|
+
if (!receiveResult.ok)
|
|
1356
|
+
return receiveResult;
|
|
1357
|
+
const { sender, decryptedText, scope, timestamp } = receiveResult.value;
|
|
1358
|
+
const share = {
|
|
1359
|
+
data: decryptedText,
|
|
1360
|
+
index: envelope.shareIndex ?? 0,
|
|
1361
|
+
total: envelope.shareTotal ?? 2,
|
|
1362
|
+
threshold: envelope.shareThreshold ?? 2,
|
|
1363
|
+
groupId: envelope.shareGroupId,
|
|
1364
|
+
hmacKey: envelope.shareHmacKey ?? '',
|
|
1365
|
+
hmacSig: envelope.shareHmacSig ?? '',
|
|
1366
|
+
};
|
|
1367
|
+
return this.accumulateShare(share, sender, scope, timestamp);
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Receive and accumulate a v4 Xchange share envelope.
|
|
1371
|
+
*
|
|
1372
|
+
* Verifies Ed25519 signature, extracts raw share data (no decryption —
|
|
1373
|
+
* the XorIDA share IS the payload). When threshold is reached,
|
|
1374
|
+
* reconstructs and decrypts via Xchange.
|
|
1375
|
+
*
|
|
1376
|
+
* @param envelope - A v4 Xchange envelope with share metadata.
|
|
1377
|
+
* @returns AgentMessage if threshold met, null if more shares needed.
|
|
1378
|
+
*/
|
|
1379
|
+
async receiveXchangeShare(envelope) {
|
|
1380
|
+
this.lastDetail = '';
|
|
1381
|
+
const verified = await this.verifyEnvelope(envelope);
|
|
1382
|
+
if (!verified.ok)
|
|
1383
|
+
return verified;
|
|
1384
|
+
// V4: payload is the raw share data (base64 of share bytes), NOT encrypted
|
|
1385
|
+
const shareText = new TextDecoder().decode(verified.value.payloadBytes);
|
|
1386
|
+
const share = {
|
|
1387
|
+
data: shareText,
|
|
1388
|
+
index: envelope.shareIndex,
|
|
1389
|
+
total: envelope.shareTotal,
|
|
1390
|
+
threshold: envelope.shareThreshold,
|
|
1391
|
+
groupId: envelope.shareGroupId,
|
|
1392
|
+
hmacKey: envelope.shareHmacKey,
|
|
1393
|
+
hmacSig: envelope.shareHmacSig,
|
|
1394
|
+
};
|
|
1395
|
+
return this.accumulateXchangeShare(share, envelope.sender, envelope.scope, envelope.timestamp);
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Accumulate a Xchange share and attempt reconstruction + decryption.
|
|
1399
|
+
*
|
|
1400
|
+
* When threshold is met:
|
|
1401
|
+
* 1. Reconstruct padded bundle from XorIDA shares
|
|
1402
|
+
* 2. Verify HMAC (BEFORE decrypt — CRITICAL)
|
|
1403
|
+
* 3. Unpad → extract bundle → xchangeDecrypt → plaintext
|
|
1404
|
+
*/
|
|
1405
|
+
async accumulateXchangeShare(share, sender, scope, timestamp) {
|
|
1406
|
+
const existing = this.shareAccumulator.get(share.groupId) ?? [];
|
|
1407
|
+
const isDuplicate = existing.some((s) => s.index === share.index);
|
|
1408
|
+
if (!isDuplicate) {
|
|
1409
|
+
existing.push(share);
|
|
1410
|
+
this.shareAccumulator.set(share.groupId, existing);
|
|
1411
|
+
}
|
|
1412
|
+
if (existing.length < share.threshold) {
|
|
1413
|
+
return ok(null);
|
|
1414
|
+
}
|
|
1415
|
+
this.shareAccumulator.delete(share.groupId);
|
|
1416
|
+
const usedShares = existing.slice(0, share.threshold);
|
|
1417
|
+
const n = share.total;
|
|
1418
|
+
const k = share.threshold;
|
|
1419
|
+
// Decode share data from base64
|
|
1420
|
+
let shareData;
|
|
1421
|
+
try {
|
|
1422
|
+
shareData = usedShares.map((s) => fromBase64(parseShareHeader(s.data)));
|
|
1423
|
+
}
|
|
1424
|
+
catch {
|
|
1425
|
+
return err('DECRYPT_FAILED');
|
|
1426
|
+
}
|
|
1427
|
+
const indices = usedShares.map((s) => s.index);
|
|
1428
|
+
// Load crypto package from Vault Store (payment-gated)
|
|
1429
|
+
const crypto = await this.ensureCrypto();
|
|
1430
|
+
// Reconstruct padded bundle
|
|
1431
|
+
let padded;
|
|
1432
|
+
try {
|
|
1433
|
+
padded = crypto.reconstructXorIDA(shareData, indices, n, k);
|
|
1434
|
+
}
|
|
1435
|
+
catch {
|
|
1436
|
+
return err('DECRYPT_FAILED');
|
|
1437
|
+
}
|
|
1438
|
+
// HMAC verification BEFORE decrypt — CRITICAL
|
|
1439
|
+
let hmacKey;
|
|
1440
|
+
let hmacSig;
|
|
1441
|
+
try {
|
|
1442
|
+
hmacKey = fromBase64(usedShares[0].hmacKey);
|
|
1443
|
+
hmacSig = fromBase64(usedShares[0].hmacSig);
|
|
1444
|
+
}
|
|
1445
|
+
catch {
|
|
1446
|
+
return err('DECRYPT_FAILED');
|
|
1447
|
+
}
|
|
1448
|
+
const hmacValid = await crypto.verifyHMAC(hmacKey, padded, hmacSig);
|
|
1449
|
+
if (!hmacValid) {
|
|
1450
|
+
this.lastDetail = 'HMAC verification failed before decrypt';
|
|
1451
|
+
return err('DECRYPT_FAILED');
|
|
1452
|
+
}
|
|
1453
|
+
// Unpad
|
|
1454
|
+
const p = crypto.nextOddPrime(n);
|
|
1455
|
+
const blockSize = p - 1;
|
|
1456
|
+
const unpadResult = crypto.pkcs7Unpad(padded, blockSize);
|
|
1457
|
+
if (!unpadResult.ok)
|
|
1458
|
+
return err('DECRYPT_FAILED');
|
|
1459
|
+
// Xchange decrypt: extract K, IV, ciphertext from bundle
|
|
1460
|
+
const decryptResult = await xchangeDecrypt(unpadResult.value);
|
|
1461
|
+
if (!decryptResult.ok)
|
|
1462
|
+
return err('DECRYPT_FAILED:DECRYPTION');
|
|
1463
|
+
let payload;
|
|
1464
|
+
try {
|
|
1465
|
+
payload = JSON.parse(new TextDecoder().decode(decryptResult.value));
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
return err('DECRYPT_FAILED:PARSE');
|
|
1469
|
+
}
|
|
1470
|
+
// Mechanism 2: Protocol information available in return value
|
|
1471
|
+
// Applications can display: "Secured with PRIVATE.ME/xBind/v3. Learn more: https://private.me/docs/xbind.html"
|
|
1472
|
+
return ok({ sender, payload, scope, timestamp });
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Accumulate a share and attempt reconstruction when threshold is met.
|
|
1476
|
+
*/
|
|
1477
|
+
async accumulateShare(share, sender, scope, timestamp) {
|
|
1478
|
+
const existing = this.shareAccumulator.get(share.groupId) ?? [];
|
|
1479
|
+
const isDuplicate = existing.some((s) => s.index === share.index);
|
|
1480
|
+
if (!isDuplicate) {
|
|
1481
|
+
existing.push(share);
|
|
1482
|
+
this.shareAccumulator.set(share.groupId, existing);
|
|
1483
|
+
}
|
|
1484
|
+
if (existing.length < share.threshold) {
|
|
1485
|
+
return ok(null);
|
|
1486
|
+
}
|
|
1487
|
+
this.shareAccumulator.delete(share.groupId);
|
|
1488
|
+
const result = await reconstructFromChannel(existing);
|
|
1489
|
+
if (!result.ok)
|
|
1490
|
+
return err('DECRYPT_FAILED');
|
|
1491
|
+
let payload;
|
|
1492
|
+
try {
|
|
1493
|
+
payload = JSON.parse(new TextDecoder().decode(result.value));
|
|
1494
|
+
}
|
|
1495
|
+
catch {
|
|
1496
|
+
return err('DECRYPT_FAILED');
|
|
1497
|
+
}
|
|
1498
|
+
// Mechanism 2: Protocol information available in return value
|
|
1499
|
+
// Applications can display: "Secured with PRIVATE.ME/xBind/v3. Learn more: https://private.me/docs/xbind.html"
|
|
1500
|
+
return ok({ sender, payload, scope, timestamp });
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Common envelope verification: version, timestamp, nonce, DID, signature, scope.
|
|
1504
|
+
*
|
|
1505
|
+
* Used by receive(), receiveSigned(), and receiveRaw() to avoid duplication.
|
|
1506
|
+
* Consumes the nonce on success (replay prevention).
|
|
1507
|
+
*/
|
|
1508
|
+
async verifyEnvelope(envelope) {
|
|
1509
|
+
// Runtime null guard: catch null/undefined despite TypeScript types
|
|
1510
|
+
if (!envelope || typeof envelope !== 'object') {
|
|
1511
|
+
this.lastDetail = 'envelope is null or not an object';
|
|
1512
|
+
return err('VERIFICATION_FAILED:INVALID_ENVELOPE');
|
|
1513
|
+
}
|
|
1514
|
+
if ((envelope.v !== 1 && envelope.v !== 2 && envelope.v !== 3 && envelope.v !== 4) || envelope.alg !== 'Ed25519') {
|
|
1515
|
+
this.lastDetail = `v=${String(envelope.v)}, alg=${String(envelope.alg)}`;
|
|
1516
|
+
return err('VERIFICATION_FAILED:UNSUPPORTED_VERSION');
|
|
1517
|
+
}
|
|
1518
|
+
// SECURITY FIX (v1.1.3): Type guard for timestamp (prevent type confusion)
|
|
1519
|
+
// Attack: Send timestamp: "soon" → Date.now() - "soon" = NaN → window check bypassed
|
|
1520
|
+
if (typeof envelope.timestamp !== 'number' || !Number.isFinite(envelope.timestamp)) {
|
|
1521
|
+
this.lastDetail = `timestamp=${String(envelope.timestamp)} (must be finite number)`;
|
|
1522
|
+
return err('VERIFICATION_FAILED:INVALID_ENVELOPE');
|
|
1523
|
+
}
|
|
1524
|
+
const age = Math.abs(Date.now() - envelope.timestamp);
|
|
1525
|
+
if (age > this.timestampWindowMs) {
|
|
1526
|
+
this.lastDetail = `age=${age}ms, max=${this.timestampWindowMs}ms`;
|
|
1527
|
+
return err('TIMESTAMP_EXPIRED');
|
|
1528
|
+
}
|
|
1529
|
+
// DEDUP-1: Pass shareContext for share-aware deduplication
|
|
1530
|
+
// Allows same nonce with different shares (idempotent retries)
|
|
1531
|
+
// while still blocking true replays (same nonce + same share)
|
|
1532
|
+
const shareContext = envelope.shareGroupId !== undefined ? {
|
|
1533
|
+
shareGroupId: envelope.shareGroupId,
|
|
1534
|
+
shareIndex: envelope.shareIndex,
|
|
1535
|
+
} : undefined;
|
|
1536
|
+
const nonceOk = await this.nonceStore.check(envelope.nonce, envelope.sender, shareContext);
|
|
1537
|
+
if (!nonceOk) {
|
|
1538
|
+
this.lastDetail = `nonce=${envelope.nonce}`;
|
|
1539
|
+
return err('REPLAY_DETECTED');
|
|
1540
|
+
}
|
|
1541
|
+
const senderKey = await this.registry.resolve(envelope.sender);
|
|
1542
|
+
if (!senderKey.ok) {
|
|
1543
|
+
this.lastDetail = `did=${envelope.sender}`;
|
|
1544
|
+
return err('VERIFICATION_FAILED:DID_NOT_IN_REGISTRY');
|
|
1545
|
+
}
|
|
1546
|
+
const pubKey = await importPublicKey(senderKey.value);
|
|
1547
|
+
if (!pubKey.ok) {
|
|
1548
|
+
this.lastDetail = `did=${envelope.sender}`;
|
|
1549
|
+
return err('VERIFICATION_FAILED:KEY_IMPORT_FAILED');
|
|
1550
|
+
}
|
|
1551
|
+
// SECURITY FIX (v1.1.3): Guard for missing signature field (prevent TypeError crash)
|
|
1552
|
+
// Attack: Send envelope without signature field → fromBase64(undefined) throws → DoS
|
|
1553
|
+
if (!envelope.signature || typeof envelope.signature !== 'string') {
|
|
1554
|
+
this.lastDetail = 'signature field missing or invalid';
|
|
1555
|
+
return err('VERIFICATION_FAILED:SIGNATURE_MISMATCH');
|
|
1556
|
+
}
|
|
1557
|
+
// SECURITY FIX (v1.1.3): Verify signature over canonical envelope representation
|
|
1558
|
+
// (not just payload) to prevent replay attacks via nonce substitution
|
|
1559
|
+
const sigBytes = fromBase64(envelope.signature);
|
|
1560
|
+
// Create canonical representation for signature verification
|
|
1561
|
+
const canonicalData = JSON.stringify({
|
|
1562
|
+
v: envelope.v,
|
|
1563
|
+
alg: envelope.alg,
|
|
1564
|
+
sender: envelope.sender,
|
|
1565
|
+
recipient: envelope.recipient,
|
|
1566
|
+
timestamp: envelope.timestamp,
|
|
1567
|
+
nonce: envelope.nonce,
|
|
1568
|
+
scope: envelope.scope,
|
|
1569
|
+
payload: envelope.payload,
|
|
1570
|
+
});
|
|
1571
|
+
const canonicalBytes = new TextEncoder().encode(canonicalData);
|
|
1572
|
+
// Verify canonical signature (v1.1.3+)
|
|
1573
|
+
// SECURITY: No legacy fallback. Pre-v1.1.3 envelopes use payload-only signatures
|
|
1574
|
+
// that don't cover nonce, allowing unlimited replay attacks. All senders must upgrade.
|
|
1575
|
+
const sigValid = await verify(pubKey.value, sigBytes, canonicalBytes);
|
|
1576
|
+
if (!sigValid.ok || !sigValid.value) {
|
|
1577
|
+
this.lastDetail = 'signature does not match canonical envelope (v1.1.3+ required)';
|
|
1578
|
+
return err('VERIFICATION_FAILED:SIGNATURE_MISMATCH');
|
|
1579
|
+
}
|
|
1580
|
+
// V3: verify ML-DSA-65 post-quantum signature (both sigs must pass)
|
|
1581
|
+
if (envelope.v === 3 && 'pqSignature' in envelope) {
|
|
1582
|
+
// SECURITY FIX (v1.1.5): Guard for non-string pqSignature field
|
|
1583
|
+
if (typeof envelope.pqSignature !== 'string') {
|
|
1584
|
+
this.lastDetail = 'pqSignature field not a string';
|
|
1585
|
+
return err('VERIFICATION_FAILED:INVALID_ENVELOPE');
|
|
1586
|
+
}
|
|
1587
|
+
const senderEntry = await this.registry.getEntry(envelope.sender);
|
|
1588
|
+
if (!senderEntry.ok || !senderEntry.value.mlDsaPublicKey) {
|
|
1589
|
+
this.lastDetail = `did=${envelope.sender} missing ML-DSA public key`;
|
|
1590
|
+
return err('VERIFICATION_FAILED:PQ_KEY_MISSING');
|
|
1591
|
+
}
|
|
1592
|
+
const pqSigBytes = fromBase64(envelope.pqSignature);
|
|
1593
|
+
// Verify ML-DSA-65 post-quantum signature (canonical only, v1.1.3+)
|
|
1594
|
+
// SECURITY: No legacy fallback for same replay protection reasons as Ed25519
|
|
1595
|
+
const pqValid = await verifyMlDsa65(senderEntry.value.mlDsaPublicKey, pqSigBytes, canonicalBytes);
|
|
1596
|
+
if (!pqValid.ok || !pqValid.value) {
|
|
1597
|
+
this.lastDetail = 'ML-DSA-65 signature does not match canonical envelope (v1.1.3+ required)';
|
|
1598
|
+
return err('VERIFICATION_FAILED:PQ_SIGNATURE_MISMATCH');
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
const hasScope = await this.registry.hasScope(envelope.sender, envelope.scope);
|
|
1602
|
+
if (!hasScope) {
|
|
1603
|
+
this.lastDetail = `scope=${envelope.scope}`;
|
|
1604
|
+
return err('SCOPE_DENIED');
|
|
1605
|
+
}
|
|
1606
|
+
// SECURITY FIX (v1.1.5): Guard for non-string payload field
|
|
1607
|
+
if (typeof envelope.payload !== 'string') {
|
|
1608
|
+
this.lastDetail = 'payload field not a string';
|
|
1609
|
+
return err('VERIFICATION_FAILED:INVALID_ENVELOPE');
|
|
1610
|
+
}
|
|
1611
|
+
// Return decoded payload bytes for decryption
|
|
1612
|
+
const payloadBytes = fromBase64(envelope.payload);
|
|
1613
|
+
return ok({ senderRawKey: senderKey.value, payloadBytes });
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Verify and decrypt an envelope, returning raw text (no JSON parse).
|
|
1617
|
+
* Used by receiveSplitShare to get the raw decrypted share data.
|
|
1618
|
+
*
|
|
1619
|
+
* ROT-1: Supports key rotation with fallback to old keys.
|
|
1620
|
+
*/
|
|
1621
|
+
async receiveRaw(envelope) {
|
|
1622
|
+
const verified = await this.verifyEnvelope(envelope);
|
|
1623
|
+
if (!verified.ok)
|
|
1624
|
+
return verified;
|
|
1625
|
+
const { senderRawKey } = verified.value;
|
|
1626
|
+
// V4 Xchange: use receiveXchangeShare() instead
|
|
1627
|
+
if (envelope.v === 4) {
|
|
1628
|
+
return err('VERIFICATION_FAILED:UNSUPPORTED_VERSION');
|
|
1629
|
+
}
|
|
1630
|
+
let sharedKey;
|
|
1631
|
+
// V2/V3 hybrid path
|
|
1632
|
+
if ((envelope.v === 2 || envelope.v === 3) && 'kemCiphertext' in envelope) {
|
|
1633
|
+
if (!this.identity.mlKemSecretKey) {
|
|
1634
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
1635
|
+
}
|
|
1636
|
+
// SECURITY FIX (v1.1.5): Guard for non-string fields
|
|
1637
|
+
if (typeof envelope.ephemeralPub !== 'string' || typeof envelope.kemCiphertext !== 'string') {
|
|
1638
|
+
return err('DECRYPT_FAILED:INVALID_ENVELOPE');
|
|
1639
|
+
}
|
|
1640
|
+
const ephPubBytes = fromBase64(envelope.ephemeralPub);
|
|
1641
|
+
const kemCtBytes = fromBase64(envelope.kemCiphertext);
|
|
1642
|
+
// Validate identity has required public keys for IETF X-Wing combiner
|
|
1643
|
+
if (!this.identity.mlKemPublicKey || !this.identity.mlKemSecretKey) {
|
|
1644
|
+
this.lastDetail = 'ML-KEM keys not available in identity';
|
|
1645
|
+
return err('DECRYPT_FAILED:MISSING_MLKEM_KEYS');
|
|
1646
|
+
}
|
|
1647
|
+
const hybridKey = await receiverHybridKeyAgreement(this.identity.x25519PrivateKey, this.identity.rawX25519PublicKey, ephPubBytes, kemCtBytes, this.identity.mlKemSecretKey, this.identity.mlKemPublicKey);
|
|
1648
|
+
if (!hybridKey.ok) {
|
|
1649
|
+
// ROT-1: Try old rotated keys if current key agreement fails
|
|
1650
|
+
if (this.identity.rotatedKeys && this.identity.rotatedKeys.length > 0) {
|
|
1651
|
+
for (const rotated of this.identity.rotatedKeys) {
|
|
1652
|
+
if (!rotated.mlKemSecretKey)
|
|
1653
|
+
continue; // Skip if ML-KEM key not available
|
|
1654
|
+
const oldHybridKey = await receiverHybridKeyAgreement(rotated.x25519PrivateKey, this.identity.rawX25519PublicKey, ephPubBytes, kemCtBytes, rotated.mlKemSecretKey, this.identity.mlKemPublicKey);
|
|
1655
|
+
if (oldHybridKey.ok) {
|
|
1656
|
+
sharedKey = oldHybridKey.value;
|
|
1657
|
+
break;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
if (!sharedKey)
|
|
1662
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
1663
|
+
}
|
|
1664
|
+
else {
|
|
1665
|
+
sharedKey = hybridKey.value;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
else if (envelope.ephemeralPub) {
|
|
1669
|
+
// SECURITY FIX (v1.1.5): Guard for non-string field
|
|
1670
|
+
if (typeof envelope.ephemeralPub !== 'string') {
|
|
1671
|
+
return err('DECRYPT_FAILED:INVALID_ENVELOPE');
|
|
1672
|
+
}
|
|
1673
|
+
const ephPubBytes = fromBase64(envelope.ephemeralPub);
|
|
1674
|
+
const ecdhKey = await receiverKeyAgreement(this.identity.x25519PrivateKey, ephPubBytes);
|
|
1675
|
+
if (!ecdhKey.ok) {
|
|
1676
|
+
// ROT-1: Try old rotated keys if current key agreement fails
|
|
1677
|
+
if (this.identity.rotatedKeys && this.identity.rotatedKeys.length > 0) {
|
|
1678
|
+
for (const rotated of this.identity.rotatedKeys) {
|
|
1679
|
+
const oldEcdhKey = await receiverKeyAgreement(rotated.x25519PrivateKey, ephPubBytes);
|
|
1680
|
+
if (oldEcdhKey.ok) {
|
|
1681
|
+
sharedKey = oldEcdhKey.value;
|
|
1682
|
+
break;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (!sharedKey)
|
|
1687
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
1688
|
+
}
|
|
1689
|
+
else {
|
|
1690
|
+
sharedKey = ecdhKey.value;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
// SECURITY FIX (v1.1.6): Removed insecure fallback
|
|
1695
|
+
// Envelopes without ephemeralPub rejected (sender must use X25519 ECDH)
|
|
1696
|
+
return err('DECRYPT_FAILED:NO_EPHEMERAL_KEY');
|
|
1697
|
+
}
|
|
1698
|
+
// TypeScript narrowing: sharedKey is guaranteed to be defined here due to control flow
|
|
1699
|
+
if (!sharedKey)
|
|
1700
|
+
return err('DECRYPT_FAILED:KEY_AGREEMENT');
|
|
1701
|
+
const decrypted = await decryptPayload(envelope, sharedKey);
|
|
1702
|
+
if (!decrypted.ok)
|
|
1703
|
+
return err('DECRYPT_FAILED:DECRYPTION');
|
|
1704
|
+
const decryptedText = new TextDecoder().decode(decrypted.value);
|
|
1705
|
+
return ok({
|
|
1706
|
+
sender: envelope.sender,
|
|
1707
|
+
decryptedText,
|
|
1708
|
+
scope: envelope.scope,
|
|
1709
|
+
timestamp: envelope.timestamp,
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Create a test envelope for testing purposes.
|
|
1714
|
+
*
|
|
1715
|
+
* This is a helper method for tests to create properly signed and encrypted
|
|
1716
|
+
* envelopes without going through the full send() flow.
|
|
1717
|
+
*
|
|
1718
|
+
* @param recipientDid - Recipient DID
|
|
1719
|
+
* @param payload - Payload object (will be JSON serialized)
|
|
1720
|
+
* @param scope - Permission scope
|
|
1721
|
+
* @returns TransportEnvelope or null if creation failed
|
|
1722
|
+
* @internal
|
|
1723
|
+
*/
|
|
1724
|
+
async createTestEnvelope(recipientDid, payload, scope) {
|
|
1725
|
+
// Resolve recipient entry
|
|
1726
|
+
const entry = await this.registry.getEntry(recipientDid);
|
|
1727
|
+
if (!entry.ok || !entry.value.x25519PublicKey)
|
|
1728
|
+
return null;
|
|
1729
|
+
// Import X25519 public key
|
|
1730
|
+
const recipientX25519 = await importX25519PublicKey(entry.value.x25519PublicKey);
|
|
1731
|
+
if (!recipientX25519.ok)
|
|
1732
|
+
return null;
|
|
1733
|
+
// Perform ECDH key agreement
|
|
1734
|
+
const kaResult = await senderKeyAgreement(recipientX25519.value);
|
|
1735
|
+
if (!kaResult.ok)
|
|
1736
|
+
return null;
|
|
1737
|
+
// Serialize payload
|
|
1738
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
1739
|
+
// Create envelope
|
|
1740
|
+
const envelopeResult = await createEnvelope({
|
|
1741
|
+
senderDid: this.identity.did,
|
|
1742
|
+
recipientDid,
|
|
1743
|
+
scope,
|
|
1744
|
+
plaintext,
|
|
1745
|
+
privateKey: this.identity.privateKey,
|
|
1746
|
+
sharedKey: kaResult.value.sharedKey,
|
|
1747
|
+
ephemeralPublicKey: kaResult.value.ephemeralPublicKey,
|
|
1748
|
+
});
|
|
1749
|
+
if (!envelopeResult.ok)
|
|
1750
|
+
return null;
|
|
1751
|
+
return envelopeResult.value;
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Send email invitation to establish connection.
|
|
1755
|
+
*
|
|
1756
|
+
* Uses email transport to send branded invitation with one-click acceptance.
|
|
1757
|
+
* Requires EmailTransport to be configured in agent options.
|
|
1758
|
+
*
|
|
1759
|
+
* @param opts - Invitation options
|
|
1760
|
+
* @returns Result with success status
|
|
1761
|
+
*
|
|
1762
|
+
* @example
|
|
1763
|
+
* ```ts
|
|
1764
|
+
* await agent.invite({
|
|
1765
|
+
* to: 'fulfillment@acme.com',
|
|
1766
|
+
* message: 'Connect our payment systems'
|
|
1767
|
+
* });
|
|
1768
|
+
* ```
|
|
1769
|
+
*/
|
|
1770
|
+
async invite(opts) {
|
|
1771
|
+
if (!this.transports || this.transports.length === 0) {
|
|
1772
|
+
return err('SEND_FAILED');
|
|
1773
|
+
}
|
|
1774
|
+
// Convert raw public key to base64 for transmission
|
|
1775
|
+
const publicKeyBase64 = Buffer.from(this.identity.rawPublicKey).toString('base64');
|
|
1776
|
+
// Create invitation envelope
|
|
1777
|
+
const envelope = {
|
|
1778
|
+
from: this.identity.did,
|
|
1779
|
+
to: opts.to,
|
|
1780
|
+
payload: {
|
|
1781
|
+
agentName: this.name,
|
|
1782
|
+
message: opts.message,
|
|
1783
|
+
publicKey: publicKeyBase64,
|
|
1784
|
+
endpoint: '', // Email-based invites don't need endpoint
|
|
1785
|
+
},
|
|
1786
|
+
};
|
|
1787
|
+
// Send via first transport (assumed to be EmailTransport for invite())
|
|
1788
|
+
const transport = this.transports[0];
|
|
1789
|
+
if (!transport) {
|
|
1790
|
+
return err('SEND_FAILED');
|
|
1791
|
+
}
|
|
1792
|
+
const result = await transport.send(envelope, opts.to);
|
|
1793
|
+
if (!result.ok) {
|
|
1794
|
+
return err('SEND_FAILED');
|
|
1795
|
+
}
|
|
1796
|
+
return ok(undefined);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
// SECURITY FIX (v1.1.6): Removed deriveSharedKey export (insecure, public-key-only derivation)
|
|
1800
|
+
export { generateSharedKey };
|