@ouro.bot/friends 0.1.0-alpha.4 → 0.1.0-alpha.5
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 +66 -6
- package/changelog.json +6 -0
- package/dist/a2a-client/a2a-message.d.ts +39 -0
- package/dist/a2a-client/a2a-message.js +54 -0
- package/dist/a2a-client/adapter.d.ts +97 -0
- package/dist/a2a-client/adapter.js +114 -0
- package/dist/a2a-client/agent-card.d.ts +50 -0
- package/dist/a2a-client/agent-card.js +32 -0
- package/dist/a2a-client/did-key.d.ts +38 -0
- package/dist/a2a-client/did-key.js +120 -0
- package/dist/a2a-client/did-verifier.d.ts +109 -0
- package/dist/a2a-client/did-verifier.js +163 -0
- package/dist/a2a-client/did-web.d.ts +26 -0
- package/dist/a2a-client/did-web.js +140 -0
- package/dist/a2a-client/index.d.ts +23 -0
- package/dist/a2a-client/index.js +72 -0
- package/dist/a2a-client/jcs.d.ts +5 -0
- package/dist/a2a-client/jcs.js +84 -0
- package/dist/a2a-client/reachability.d.ts +22 -0
- package/dist/a2a-client/reachability.js +17 -0
- package/dist/a2a-client/seal.d.ts +47 -0
- package/dist/a2a-client/seal.js +95 -0
- package/dist/a2a-client/sealed-envelope.d.ts +55 -0
- package/dist/a2a-client/sealed-envelope.js +94 -0
- package/dist/a2a-client/sign.d.ts +42 -0
- package/dist/a2a-client/sign.js +87 -0
- package/dist/a2a-client/sodium.d.ts +5 -0
- package/dist/a2a-client/sodium.js +19 -0
- package/dist/agent-peer.js +5 -1
- package/dist/{a2a → mailbox}/index.js +10 -3
- package/dist/mcp/bin.js +0 -0
- package/dist/store-file.d.ts +6 -2
- package/dist/store-file.js +28 -5
- package/dist/types.d.ts +22 -7
- package/package.json +15 -6
- /package/dist/{a2a → mailbox}/index.d.ts +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// JCS — JSON Canonicalization Scheme (RFC 8785), hand-rolled.
|
|
3
|
+
//
|
|
4
|
+
// Why hand-rolled (not a dep): the obvious dep `canonicalize` is ESM-only, and
|
|
5
|
+
// friends compiles to CommonJS with JCS called SYNCHRONOUSLY on the crypto hot
|
|
6
|
+
// path (both Ed25519 signing AND the AEAD associated-data construction). A
|
|
7
|
+
// dynamic `import()` (the only way CJS reaches ESM-only) would force seal/sign
|
|
8
|
+
// async — unacceptable. Hand-rolling keeps JCS sync and the only new runtime dep
|
|
9
|
+
// `libsodium-wrappers`. The RFC 8785 number-serialization edge cases are
|
|
10
|
+
// exhaustively unit-tested (see a2a-client-jcs.test.ts).
|
|
11
|
+
//
|
|
12
|
+
// The canonical form (RFC 8785):
|
|
13
|
+
// • object keys sorted by UTF-16 code-unit order, recursively;
|
|
14
|
+
// • no insignificant whitespace;
|
|
15
|
+
// • arrays preserve element order;
|
|
16
|
+
// • strings serialized with JSON string escaping (which IS RFC 8785's);
|
|
17
|
+
// • numbers serialized per RFC 8785 §3.2.2 — ECMAScript `Number.prototype
|
|
18
|
+
// .toString` for finite values (integers carry no fractional part), with
|
|
19
|
+
// `-0` normalized to `0`; NaN / Infinity are rejected;
|
|
20
|
+
// • `null` → "null"; booleans literal.
|
|
21
|
+
// Our envelopes carry only strings, ISO-date strings, small integers (v:1,
|
|
22
|
+
// counts) and nested objects/arrays — no floats on the wire — but the number
|
|
23
|
+
// path is tested anyway. `undefined` / functions / symbols are NOT valid JSON
|
|
24
|
+
// values and are rejected loudly rather than silently dropped.
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.jcsString = jcsString;
|
|
27
|
+
exports.jcsBytes = jcsBytes;
|
|
28
|
+
/** Serialize a number per RFC 8785 §3.2.2. Rejects non-finite values. */
|
|
29
|
+
function serializeNumber(n) {
|
|
30
|
+
if (!Number.isFinite(n)) {
|
|
31
|
+
throw new Error(`JCS: non-finite number cannot be canonicalized: ${String(n)}`);
|
|
32
|
+
}
|
|
33
|
+
// ECMAScript Number.prototype.toString is the RFC 8785 number production for
|
|
34
|
+
// finite values; it already emits integers without a fractional part and uses
|
|
35
|
+
// the shortest round-tripping form. Normalize negative zero to "0" (RFC 8785
|
|
36
|
+
// serializes -0 as 0).
|
|
37
|
+
if (Object.is(n, -0))
|
|
38
|
+
return "0";
|
|
39
|
+
return n.toString();
|
|
40
|
+
}
|
|
41
|
+
/** Canonicalize a single value into its JCS string fragment. */
|
|
42
|
+
function canonicalize(value) {
|
|
43
|
+
if (value === null)
|
|
44
|
+
return "null";
|
|
45
|
+
const t = typeof value;
|
|
46
|
+
if (t === "string")
|
|
47
|
+
return JSON.stringify(value);
|
|
48
|
+
if (t === "boolean")
|
|
49
|
+
return value ? "true" : "false";
|
|
50
|
+
if (t === "number")
|
|
51
|
+
return serializeNumber(value);
|
|
52
|
+
if (t === "bigint") {
|
|
53
|
+
throw new Error("JCS: bigint cannot be canonicalized (not a JSON number)");
|
|
54
|
+
}
|
|
55
|
+
if (t === "undefined" || t === "function" || t === "symbol") {
|
|
56
|
+
throw new Error(`JCS: ${t} is not a valid JSON value`);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return `[${value.map((el) => canonicalize(el)).join(",")}]`;
|
|
60
|
+
}
|
|
61
|
+
// Plain object: sort keys by UTF-16 code-unit order (the default `<` on JS
|
|
62
|
+
// strings), recurse. Keys whose value is `undefined` are omitted (matching
|
|
63
|
+
// JSON.stringify), but a value that is a function/symbol is omitted too — to
|
|
64
|
+
// stay strict we DROP only `undefined` (JSON-absent) and reject the rest.
|
|
65
|
+
const obj = value;
|
|
66
|
+
const keys = Object.keys(obj).sort();
|
|
67
|
+
const parts = [];
|
|
68
|
+
for (const key of keys) {
|
|
69
|
+
const v = obj[key];
|
|
70
|
+
if (v === undefined)
|
|
71
|
+
continue; // JSON omits undefined-valued keys
|
|
72
|
+
parts.push(`${JSON.stringify(key)}:${canonicalize(v)}`);
|
|
73
|
+
}
|
|
74
|
+
return `{${parts.join(",")}}`;
|
|
75
|
+
}
|
|
76
|
+
/** The JCS canonical JSON string for `value` (RFC 8785). */
|
|
77
|
+
function jcsString(value) {
|
|
78
|
+
return canonicalize(value);
|
|
79
|
+
}
|
|
80
|
+
/** The UTF-8 bytes of the JCS canonical JSON string — the message fed to
|
|
81
|
+
* Ed25519 signing and used as AEAD associated-data. */
|
|
82
|
+
function jcsBytes(value) {
|
|
83
|
+
return new TextEncoder().encode(jcsString(value));
|
|
84
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AgentMeta } from "../types";
|
|
2
|
+
export type ReachabilityPlan = {
|
|
3
|
+
rung: "direct";
|
|
4
|
+
endpointUrl: string;
|
|
5
|
+
} | {
|
|
6
|
+
rung: "relay";
|
|
7
|
+
relay: {
|
|
8
|
+
url: string;
|
|
9
|
+
handle: string;
|
|
10
|
+
};
|
|
11
|
+
} | {
|
|
12
|
+
rung: "mailbox";
|
|
13
|
+
mailbox: {
|
|
14
|
+
repo: string;
|
|
15
|
+
selfOutboxAgentId: string;
|
|
16
|
+
};
|
|
17
|
+
} | {
|
|
18
|
+
rung: "unreachable";
|
|
19
|
+
};
|
|
20
|
+
/** Resolve the reachability rung for a peer from its A2A coords + (top-level)
|
|
21
|
+
* mailbox coords. Deterministic, pure. */
|
|
22
|
+
export declare function resolveReachability(peerA2A: AgentMeta["a2a"] | undefined, peerMailbox: AgentMeta["mailbox"] | undefined): ReachabilityPlan;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveReachability = resolveReachability;
|
|
4
|
+
/** Resolve the reachability rung for a peer from its A2A coords + (top-level)
|
|
5
|
+
* mailbox coords. Deterministic, pure. */
|
|
6
|
+
function resolveReachability(peerA2A, peerMailbox) {
|
|
7
|
+
if (peerA2A?.endpointUrl) {
|
|
8
|
+
return { rung: "direct", endpointUrl: peerA2A.endpointUrl };
|
|
9
|
+
}
|
|
10
|
+
if (peerA2A?.relay) {
|
|
11
|
+
return { rung: "relay", relay: peerA2A.relay };
|
|
12
|
+
}
|
|
13
|
+
if (peerMailbox) {
|
|
14
|
+
return { rung: "mailbox", mailbox: peerMailbox };
|
|
15
|
+
}
|
|
16
|
+
return { rung: "unreachable" };
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Sodium } from "./sodium";
|
|
2
|
+
/** The opaque sealed blob the relay carries. All fields base64 (ORIGINAL
|
|
3
|
+
* variant). Nothing here reveals the sender, the payload, or the friends kind. */
|
|
4
|
+
export interface SealedBlob {
|
|
5
|
+
/** Overlay version (bound into the AAD). */
|
|
6
|
+
v: number;
|
|
7
|
+
/** The sender's ephemeral X25519 public key (base64). */
|
|
8
|
+
ePk: string;
|
|
9
|
+
/** The AEAD nonce (base64, 24 bytes). */
|
|
10
|
+
n: string;
|
|
11
|
+
/** The AEAD ciphertext+tag (base64). */
|
|
12
|
+
ct: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SealToInput {
|
|
15
|
+
sodium: Sodium;
|
|
16
|
+
/** The plaintext to seal (already the sign-then-seal plaintext bytes). */
|
|
17
|
+
plaintextBytes: Uint8Array;
|
|
18
|
+
/** The recipient's X25519 keyAgreement public key. */
|
|
19
|
+
recipientX25519Pub: Uint8Array;
|
|
20
|
+
/** The recipient's DID — bound into the AEAD AD (the re-target defense). */
|
|
21
|
+
recipientDid: string;
|
|
22
|
+
/** Overlay version (default 1). */
|
|
23
|
+
v?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Seal `plaintextBytes` to a recipient, binding the recipient DID into the AEAD
|
|
26
|
+
* associated-data. Returns the opaque blob. */
|
|
27
|
+
export declare function sealTo(input: SealToInput): SealedBlob;
|
|
28
|
+
export interface OpenSealedInput {
|
|
29
|
+
sodium: Sodium;
|
|
30
|
+
blob: SealedBlob;
|
|
31
|
+
/** The recipient's X25519 keyAgreement private key. */
|
|
32
|
+
recipientX25519Priv: Uint8Array;
|
|
33
|
+
/** The recipient's X25519 keyAgreement public key (bound into the KDF). */
|
|
34
|
+
recipientX25519Pub: Uint8Array;
|
|
35
|
+
/** The recipient's own DID — reconstructed as the AEAD AD. A wrong DID (a
|
|
36
|
+
* re-targeted blob) breaks the tag. */
|
|
37
|
+
recipientDid: string;
|
|
38
|
+
}
|
|
39
|
+
/** Thrown by `openSealed` on any failure (bad base64, wrong key, tampered or
|
|
40
|
+
* re-targeted ciphertext → AEAD tag mismatch). The caller maps this to a typed
|
|
41
|
+
* `unseal_failed` result; it never leaks plaintext. */
|
|
42
|
+
export declare class SealOpenError extends Error {
|
|
43
|
+
constructor(message: string);
|
|
44
|
+
}
|
|
45
|
+
/** Open a sealed blob. Throws `SealOpenError` on any failure (the AEAD tag
|
|
46
|
+
* enforces tamper + re-target resistance at the crypto layer). */
|
|
47
|
+
export declare function openSealed(input: OpenSealedInput): Uint8Array;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SealOpenError = void 0;
|
|
4
|
+
exports.sealTo = sealTo;
|
|
5
|
+
exports.openSealed = openSealed;
|
|
6
|
+
// seal — the E2E sealing primitive (the "sealed-box shape" with a REAL AEAD AD).
|
|
7
|
+
//
|
|
8
|
+
// The relay is UNTRUSTED: it must carry ciphertext only and never be able to
|
|
9
|
+
// read, tamper, or RE-TARGET a blob. Standard `crypto_box_seal` exposes no AAD,
|
|
10
|
+
// so to honor the spec literally ("AEAD associated-data binds the blob to its
|
|
11
|
+
// recipient") this builds the seal from `crypto_aead_xchacha20poly1305_ietf_*`
|
|
12
|
+
// over an ephemeral X25519 ECDH key, with AAD = the JCS-canonical bytes of
|
|
13
|
+
// `{ recipientDid, v }`. Consequence: a re-target (delivering B's blob to C) fails
|
|
14
|
+
// at the AEAD TAG when C reconstructs its own DID as AAD — the strong, crypto-
|
|
15
|
+
// layer defense, not a post-unseal app check.
|
|
16
|
+
//
|
|
17
|
+
// Construction (decision #1):
|
|
18
|
+
// sender: ePk,eSk = X25519 keypair; shared = scalarmult(eSk, recipientX25519Pub)
|
|
19
|
+
// K = generichash(32, shared || ePk || recipientX25519Pub) (transcript-bound)
|
|
20
|
+
// N = 24 random bytes; ct = AEAD_encrypt(plaintext, AAD={recipientDid,v}, N, K)
|
|
21
|
+
// blob = { v, ePk, n, ct } (all base64 ORIGINAL)
|
|
22
|
+
// recipient: shared' = scalarmult(recipientX25519Priv, ePk); K' = generichash(32, shared'||ePk||recipientX25519Pub)
|
|
23
|
+
// AEAD_decrypt(ct, AAD={recipientDid:self,v}, N, K') — throws on tag mismatch.
|
|
24
|
+
const jcs_1 = require("./jcs");
|
|
25
|
+
/** Derive the transcript-bound symmetric key from an ECDH shared secret. Binding
|
|
26
|
+
* `ePk` and the recipient pubkey into the KDF means the key is unique per
|
|
27
|
+
* (ephemeral, recipient) pair — a relay cannot swap ephemerals to attack it. */
|
|
28
|
+
function deriveKey(sodium, shared, ePk, recipientPub) {
|
|
29
|
+
const transcript = new Uint8Array(shared.length + ePk.length + recipientPub.length);
|
|
30
|
+
transcript.set(shared, 0);
|
|
31
|
+
transcript.set(ePk, shared.length);
|
|
32
|
+
transcript.set(recipientPub, shared.length + ePk.length);
|
|
33
|
+
return sodium.crypto_generichash(32, transcript, null);
|
|
34
|
+
}
|
|
35
|
+
/** Seal `plaintextBytes` to a recipient, binding the recipient DID into the AEAD
|
|
36
|
+
* associated-data. Returns the opaque blob. */
|
|
37
|
+
function sealTo(input) {
|
|
38
|
+
const { sodium, plaintextBytes, recipientX25519Pub, recipientDid } = input;
|
|
39
|
+
const v = input.v ?? 1;
|
|
40
|
+
// Ephemeral X25519 keypair (one-shot per message).
|
|
41
|
+
const eph = sodium.crypto_box_keypair();
|
|
42
|
+
const ePk = eph.publicKey;
|
|
43
|
+
const eSk = eph.privateKey;
|
|
44
|
+
const shared = sodium.crypto_scalarmult(eSk, recipientX25519Pub);
|
|
45
|
+
const key = deriveKey(sodium, shared, ePk, recipientX25519Pub);
|
|
46
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
47
|
+
const ad = (0, jcs_1.jcsBytes)({ recipientDid, v });
|
|
48
|
+
const ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintextBytes, ad, null, nonce, key);
|
|
49
|
+
const ORIGINAL = sodium.base64_variants.ORIGINAL;
|
|
50
|
+
return {
|
|
51
|
+
v,
|
|
52
|
+
ePk: sodium.to_base64(ePk, ORIGINAL),
|
|
53
|
+
n: sodium.to_base64(nonce, ORIGINAL),
|
|
54
|
+
ct: sodium.to_base64(ct, ORIGINAL),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Thrown by `openSealed` on any failure (bad base64, wrong key, tampered or
|
|
58
|
+
* re-targeted ciphertext → AEAD tag mismatch). The caller maps this to a typed
|
|
59
|
+
* `unseal_failed` result; it never leaks plaintext. */
|
|
60
|
+
class SealOpenError extends Error {
|
|
61
|
+
constructor(message) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "SealOpenError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.SealOpenError = SealOpenError;
|
|
67
|
+
/** Open a sealed blob. Throws `SealOpenError` on any failure (the AEAD tag
|
|
68
|
+
* enforces tamper + re-target resistance at the crypto layer). */
|
|
69
|
+
function openSealed(input) {
|
|
70
|
+
const { sodium, blob, recipientX25519Priv, recipientX25519Pub, recipientDid } = input;
|
|
71
|
+
const ORIGINAL = sodium.base64_variants.ORIGINAL;
|
|
72
|
+
let ePk;
|
|
73
|
+
let nonce;
|
|
74
|
+
let ct;
|
|
75
|
+
try {
|
|
76
|
+
ePk = sodium.from_base64(blob.ePk, ORIGINAL);
|
|
77
|
+
nonce = sodium.from_base64(blob.n, ORIGINAL);
|
|
78
|
+
ct = sodium.from_base64(blob.ct, ORIGINAL);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new SealOpenError("seal: malformed base64 in sealed blob");
|
|
82
|
+
}
|
|
83
|
+
const shared = sodium.crypto_scalarmult(recipientX25519Priv, ePk);
|
|
84
|
+
const key = deriveKey(sodium, shared, ePk, recipientX25519Pub);
|
|
85
|
+
const ad = (0, jcs_1.jcsBytes)({ recipientDid, v: blob.v });
|
|
86
|
+
try {
|
|
87
|
+
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ct, ad, nonce, key);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Tag mismatch: tampered ct/nonce, wrong ephemeral, wrong recipient key, OR a
|
|
91
|
+
// re-targeted blob (wrong recipientDid in the AAD). All collapse to one
|
|
92
|
+
// indistinguishable failure — exactly the security property we want.
|
|
93
|
+
throw new SealOpenError("seal: AEAD open failed (tampered, wrong recipient, or re-targeted)");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SealedBlob } from "./seal";
|
|
2
|
+
import type { Sodium } from "./sodium";
|
|
3
|
+
/** The friends taxonomy discriminant (the re-homed mailbox `kind`). Travels
|
|
4
|
+
* SEALED, never on the DataPart `data`. */
|
|
5
|
+
export type FriendsKind = "profile_share" | "mission_share" | "coordination";
|
|
6
|
+
/** The on-the-wire sealed envelope: NOTHING plaintext beyond the version. */
|
|
7
|
+
export interface SealedEnvelope {
|
|
8
|
+
v: number;
|
|
9
|
+
sealed: SealedBlob;
|
|
10
|
+
}
|
|
11
|
+
/** The signer's identity material (self). */
|
|
12
|
+
export interface FromIdentity {
|
|
13
|
+
did: string;
|
|
14
|
+
keyId: string;
|
|
15
|
+
ed25519Priv: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
export interface SealEnvelopeInput {
|
|
18
|
+
sodium: Sodium;
|
|
19
|
+
/** The plaintext friends envelope (ProfileShare/MissionShare/Coordination). */
|
|
20
|
+
envelope: Record<string, unknown>;
|
|
21
|
+
friendsKind: FriendsKind;
|
|
22
|
+
fromIdentity: FromIdentity;
|
|
23
|
+
recipientDid: string;
|
|
24
|
+
recipientX25519Pub: Uint8Array;
|
|
25
|
+
v?: number;
|
|
26
|
+
}
|
|
27
|
+
/** Sign-then-seal compose. Returns the opaque SealedEnvelope. */
|
|
28
|
+
export declare function sealEnvelope(input: SealEnvelopeInput): SealedEnvelope;
|
|
29
|
+
/** The recipient's identity material (self). */
|
|
30
|
+
export interface RecipientIdentity {
|
|
31
|
+
x25519Priv: Uint8Array;
|
|
32
|
+
x25519Pub: Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
export type OpenSealedEnvelopeResult = {
|
|
35
|
+
ok: true;
|
|
36
|
+
envelope: Record<string, unknown>;
|
|
37
|
+
fromAgentId: string;
|
|
38
|
+
signerDid: string;
|
|
39
|
+
signerKeyId: string;
|
|
40
|
+
friendsKind: FriendsKind;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
error: "unseal_failed" | "malformed_plaintext" | "recipient_mismatch";
|
|
44
|
+
};
|
|
45
|
+
export interface OpenSealedEnvelopeInput {
|
|
46
|
+
sodium: Sodium;
|
|
47
|
+
sealedEnvelope: SealedEnvelope;
|
|
48
|
+
recipientDid: string;
|
|
49
|
+
recipientIdentity: RecipientIdentity;
|
|
50
|
+
}
|
|
51
|
+
/** Open a SealedEnvelope. Does NOT verify the signature (U8's adapter resolves +
|
|
52
|
+
* pins the sender DID, then runs DidVerifier — the single authentication gate).
|
|
53
|
+
* The belt-and-suspenders `recipient === recipientDid` check is the redundant
|
|
54
|
+
* second line behind the AEAD AD (which already enforced it). */
|
|
55
|
+
export declare function openSealedEnvelope(input: OpenSealedEnvelopeInput): OpenSealedEnvelopeResult;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sealEnvelope = sealEnvelope;
|
|
4
|
+
exports.openSealedEnvelope = openSealedEnvelope;
|
|
5
|
+
// SealedEnvelope — the full sign-then-seal compose (ties U2 seal + U3 sign).
|
|
6
|
+
//
|
|
7
|
+
// ORDER (sign-then-seal, spec §3.1): sign the plaintext envelope FIRST, put the
|
|
8
|
+
// structured proof in the envelope's reserved slot, then SEAL the whole signed
|
|
9
|
+
// bundle to the recipient. The signature lives INSIDE the ciphertext — the relay
|
|
10
|
+
// never sees who signed. `friendsKind` also rides inside the sealed plaintext
|
|
11
|
+
// (relay-blind), so the recipient unseals first, THEN branches on the kind.
|
|
12
|
+
const seal_1 = require("./seal");
|
|
13
|
+
const sign_1 = require("./sign");
|
|
14
|
+
/** Sign-then-seal compose. Returns the opaque SealedEnvelope. */
|
|
15
|
+
function sealEnvelope(input) {
|
|
16
|
+
const { sodium, envelope, friendsKind, fromIdentity, recipientDid, recipientX25519Pub } = input;
|
|
17
|
+
const v = input.v ?? 1;
|
|
18
|
+
// 1. Sign the envelope (proof excluded from the canonical bytes — see sign.ts).
|
|
19
|
+
const proof = (0, sign_1.signEnvelope)({
|
|
20
|
+
sodium,
|
|
21
|
+
envelope,
|
|
22
|
+
signerEd25519Priv: fromIdentity.ed25519Priv,
|
|
23
|
+
signerDid: fromIdentity.did,
|
|
24
|
+
signerKeyId: fromIdentity.keyId,
|
|
25
|
+
});
|
|
26
|
+
// 2. Put the structured proof in the envelope's reserved slot, so the unsealed
|
|
27
|
+
// plaintext carries the proof the importer reads via `envelope.proof`.
|
|
28
|
+
const envelopeWithProof = { ...envelope, proof: (0, sign_1.serializeProof)(proof) };
|
|
29
|
+
// 3. Build the sealed plaintext (friendsKind + the recipient binding ride INSIDE).
|
|
30
|
+
const plaintext = {
|
|
31
|
+
envelope: envelopeWithProof,
|
|
32
|
+
signature: proof.sig,
|
|
33
|
+
signerDid: proof.signerDid,
|
|
34
|
+
signerKeyId: proof.signerKeyId,
|
|
35
|
+
recipient: recipientDid,
|
|
36
|
+
v,
|
|
37
|
+
friendsKind,
|
|
38
|
+
};
|
|
39
|
+
const plaintextBytes = new TextEncoder().encode(JSON.stringify(plaintext));
|
|
40
|
+
// 4. Seal to the recipient (the recipientDid is bound into the AEAD AD by sealTo).
|
|
41
|
+
const sealed = (0, seal_1.sealTo)({ sodium, plaintextBytes, recipientX25519Pub, recipientDid, v });
|
|
42
|
+
return { v, sealed };
|
|
43
|
+
}
|
|
44
|
+
/** Open a SealedEnvelope. Does NOT verify the signature (U8's adapter resolves +
|
|
45
|
+
* pins the sender DID, then runs DidVerifier — the single authentication gate).
|
|
46
|
+
* The belt-and-suspenders `recipient === recipientDid` check is the redundant
|
|
47
|
+
* second line behind the AEAD AD (which already enforced it). */
|
|
48
|
+
function openSealedEnvelope(input) {
|
|
49
|
+
const { sodium, sealedEnvelope, recipientDid, recipientIdentity } = input;
|
|
50
|
+
let plaintextBytes;
|
|
51
|
+
try {
|
|
52
|
+
plaintextBytes = (0, seal_1.openSealed)({
|
|
53
|
+
sodium,
|
|
54
|
+
blob: sealedEnvelope.sealed,
|
|
55
|
+
recipientX25519Priv: recipientIdentity.x25519Priv,
|
|
56
|
+
recipientX25519Pub: recipientIdentity.x25519Pub,
|
|
57
|
+
recipientDid,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { ok: false, error: "unseal_failed" };
|
|
62
|
+
}
|
|
63
|
+
let plaintext;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(new TextDecoder().decode(plaintextBytes));
|
|
66
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
67
|
+
return { ok: false, error: "malformed_plaintext" };
|
|
68
|
+
}
|
|
69
|
+
plaintext = parsed;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { ok: false, error: "malformed_plaintext" };
|
|
73
|
+
}
|
|
74
|
+
if (!plaintext.envelope || typeof plaintext.envelope !== "object") {
|
|
75
|
+
return { ok: false, error: "malformed_plaintext" };
|
|
76
|
+
}
|
|
77
|
+
if (typeof plaintext.friendsKind !== "string" || typeof plaintext.signerDid !== "string") {
|
|
78
|
+
return { ok: false, error: "malformed_plaintext" };
|
|
79
|
+
}
|
|
80
|
+
// Belt-and-suspenders: the AEAD AD already bound the recipient, but re-assert.
|
|
81
|
+
if (plaintext.recipient !== recipientDid) {
|
|
82
|
+
return { ok: false, error: "recipient_mismatch" };
|
|
83
|
+
}
|
|
84
|
+
const envelope = plaintext.envelope;
|
|
85
|
+
const fromAgentId = typeof envelope.fromAgentId === "string" ? envelope.fromAgentId : "";
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
envelope,
|
|
89
|
+
fromAgentId,
|
|
90
|
+
signerDid: plaintext.signerDid,
|
|
91
|
+
signerKeyId: plaintext.signerKeyId,
|
|
92
|
+
friendsKind: plaintext.friendsKind,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Sodium } from "./sodium";
|
|
2
|
+
/** The structured proof serialized (as JSON) into the envelope's `proof?: string`
|
|
3
|
+
* slot. `alg`/`canon` are pinned so a verifier rejects anything it can't check. */
|
|
4
|
+
export interface StructuredProof {
|
|
5
|
+
alg: "EdDSA";
|
|
6
|
+
/** The detached Ed25519 signature, base64 (ORIGINAL variant). */
|
|
7
|
+
sig: string;
|
|
8
|
+
/** The signer's DID (== its agentId; the binding is checked by DidVerifier). */
|
|
9
|
+
signerDid: string;
|
|
10
|
+
/** The signer's key id (the did:key fragment / DID-doc verification-method id). */
|
|
11
|
+
signerKeyId: string;
|
|
12
|
+
canon: "JCS";
|
|
13
|
+
}
|
|
14
|
+
export interface SignEnvelopeInput {
|
|
15
|
+
sodium: Sodium;
|
|
16
|
+
/** The plaintext envelope to sign (its `proof` field, if any, is excluded). */
|
|
17
|
+
envelope: unknown;
|
|
18
|
+
signerEd25519Priv: Uint8Array;
|
|
19
|
+
signerDid: string;
|
|
20
|
+
signerKeyId: string;
|
|
21
|
+
}
|
|
22
|
+
/** Sign an envelope: Ed25519 detached signature over `jcsBytes(envelope without
|
|
23
|
+
* proof)`. Returns the structured proof. */
|
|
24
|
+
export declare function signEnvelope(input: SignEnvelopeInput): StructuredProof;
|
|
25
|
+
export interface VerifyEnvelopeSignatureInput {
|
|
26
|
+
sodium: Sodium;
|
|
27
|
+
/** The plaintext envelope whose signature is being checked (proof excluded). */
|
|
28
|
+
envelope: unknown;
|
|
29
|
+
/** The structured proof — either the object or its JSON string form. */
|
|
30
|
+
proof: StructuredProof | string | undefined;
|
|
31
|
+
signerEd25519Pub: Uint8Array;
|
|
32
|
+
}
|
|
33
|
+
/** Verify an envelope's Ed25519 signature. Returns false (never throws) on a
|
|
34
|
+
* malformed proof, wrong `alg`/`canon`, missing fields, bad base64, or a bad
|
|
35
|
+
* signature. */
|
|
36
|
+
export declare function verifyEnvelopeSignature(input: VerifyEnvelopeSignatureInput): boolean;
|
|
37
|
+
/** Serialize a structured proof into the envelope's `proof?: string` slot. */
|
|
38
|
+
export declare function serializeProof(p: StructuredProof): string;
|
|
39
|
+
/** Parse a structured proof from the `proof?: string` slot. Returns null on
|
|
40
|
+
* invalid JSON or a non-object payload; field-level validation is the verifier's
|
|
41
|
+
* job (so a partially-shaped proof still surfaces as a verify failure). */
|
|
42
|
+
export declare function parseProof(s: string): StructuredProof | null;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.signEnvelope = signEnvelope;
|
|
4
|
+
exports.verifyEnvelopeSignature = verifyEnvelopeSignature;
|
|
5
|
+
exports.serializeProof = serializeProof;
|
|
6
|
+
exports.parseProof = parseProof;
|
|
7
|
+
// sign — Ed25519 detached signing/verification over JCS-canonical envelope bytes,
|
|
8
|
+
// and the structured proof that rides in the envelope's reserved `proof?: string`
|
|
9
|
+
// slot (decision #3; the envelope-level `proof` TYPE is unchanged so CORE needs no
|
|
10
|
+
// edit).
|
|
11
|
+
//
|
|
12
|
+
// CRITICAL: the signature is computed over the envelope with its `proof` field
|
|
13
|
+
// EXCLUDED — you cannot sign over the proof you are producing, and the verifier
|
|
14
|
+
// must recompute the identical canonical bytes. Both sides strip `proof` before
|
|
15
|
+
// `jcsBytes`.
|
|
16
|
+
const jcs_1 = require("./jcs");
|
|
17
|
+
/** Strip the `proof` field so signing/verifying both canonicalize the same bytes.
|
|
18
|
+
* Returns a shallow copy without `proof` (the rest of the envelope is unchanged). */
|
|
19
|
+
function envelopeWithoutProof(envelope) {
|
|
20
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope))
|
|
21
|
+
return envelope;
|
|
22
|
+
const { proof: _proof, ...rest } = envelope;
|
|
23
|
+
return rest;
|
|
24
|
+
}
|
|
25
|
+
/** Sign an envelope: Ed25519 detached signature over `jcsBytes(envelope without
|
|
26
|
+
* proof)`. Returns the structured proof. */
|
|
27
|
+
function signEnvelope(input) {
|
|
28
|
+
const { sodium, envelope, signerEd25519Priv, signerDid, signerKeyId } = input;
|
|
29
|
+
const msg = (0, jcs_1.jcsBytes)(envelopeWithoutProof(envelope));
|
|
30
|
+
const sig = sodium.crypto_sign_detached(msg, signerEd25519Priv);
|
|
31
|
+
return {
|
|
32
|
+
alg: "EdDSA",
|
|
33
|
+
sig: sodium.to_base64(sig, sodium.base64_variants.ORIGINAL),
|
|
34
|
+
signerDid,
|
|
35
|
+
signerKeyId,
|
|
36
|
+
canon: "JCS",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Verify an envelope's Ed25519 signature. Returns false (never throws) on a
|
|
40
|
+
* malformed proof, wrong `alg`/`canon`, missing fields, bad base64, or a bad
|
|
41
|
+
* signature. */
|
|
42
|
+
function verifyEnvelopeSignature(input) {
|
|
43
|
+
const { sodium, envelope, signerEd25519Pub } = input;
|
|
44
|
+
const proof = typeof input.proof === "string" ? parseProof(input.proof) : input.proof;
|
|
45
|
+
if (!proof)
|
|
46
|
+
return false;
|
|
47
|
+
if (proof.alg !== "EdDSA" || proof.canon !== "JCS")
|
|
48
|
+
return false;
|
|
49
|
+
if (typeof proof.sig !== "string" || typeof proof.signerDid !== "string" || typeof proof.signerKeyId !== "string") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
let sigBytes;
|
|
53
|
+
try {
|
|
54
|
+
sigBytes = sodium.from_base64(proof.sig, sodium.base64_variants.ORIGINAL);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const msg = (0, jcs_1.jcsBytes)(envelopeWithoutProof(envelope));
|
|
60
|
+
try {
|
|
61
|
+
return sodium.crypto_sign_verify_detached(sigBytes, msg, signerEd25519Pub);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// A wrong-length signature can throw inside libsodium — treat as a failed
|
|
65
|
+
// verification, never an uncaught error.
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Serialize a structured proof into the envelope's `proof?: string` slot. */
|
|
70
|
+
function serializeProof(p) {
|
|
71
|
+
return JSON.stringify(p);
|
|
72
|
+
}
|
|
73
|
+
/** Parse a structured proof from the `proof?: string` slot. Returns null on
|
|
74
|
+
* invalid JSON or a non-object payload; field-level validation is the verifier's
|
|
75
|
+
* job (so a partially-shaped proof still surfaces as a verify failure). */
|
|
76
|
+
function parseProof(s) {
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(s);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
85
|
+
return null;
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ready = ready;
|
|
7
|
+
// sodium — the single libsodium init seam for the a2a-client.
|
|
8
|
+
//
|
|
9
|
+
// libsodium-wrappers is WASM with async init; nothing crypto-bearing may run
|
|
10
|
+
// before `await _sodium.ready`. Every entry point that touches a primitive funnels
|
|
11
|
+
// through `ready()` so the WASM is initialized exactly once. Downstream code takes
|
|
12
|
+
// the resolved `Sodium` instance as a parameter (it never re-imports the module),
|
|
13
|
+
// which keeps the crypto functions pure and synchronous past this seam.
|
|
14
|
+
const libsodium_wrappers_1 = __importDefault(require("libsodium-wrappers"));
|
|
15
|
+
/** Await the WASM init (idempotent) and return the ready libsodium instance. */
|
|
16
|
+
async function ready() {
|
|
17
|
+
await libsodium_wrappers_1.default.ready;
|
|
18
|
+
return libsodium_wrappers_1.default;
|
|
19
|
+
}
|
package/dist/agent-peer.js
CHANGED
|
@@ -36,9 +36,13 @@ async function upsertAgentPeer(store, input) {
|
|
|
36
36
|
trustLevel,
|
|
37
37
|
kind: "agent",
|
|
38
38
|
agentMeta: {
|
|
39
|
+
// `...baseMeta` already carries any existing top-level `mailbox`; an explicit
|
|
40
|
+
// `input.mailbox` overrides it below. Mailbox is top-level on AgentMeta since
|
|
41
|
+
// the phase-8 demote (was nested under `a2a` in alpha.4).
|
|
39
42
|
...baseMeta,
|
|
40
43
|
bundleName: baseMeta.bundleName || bundleName || name,
|
|
41
|
-
a2a: { ...(a2a ?? {}), agentId
|
|
44
|
+
a2a: { ...(a2a ?? {}), agentId },
|
|
45
|
+
...(input.mailbox ? { mailbox: input.mailbox } : {}),
|
|
42
46
|
},
|
|
43
47
|
externalIds: [
|
|
44
48
|
...(existing?.externalIds.filter((id) => !(id.provider === "a2a-agent" && id.externalId === agentId)) ?? []),
|
|
@@ -6,7 +6,13 @@ exports.compareReady = compareReady;
|
|
|
6
6
|
exports.readIncoming = readIncoming;
|
|
7
7
|
exports.isSeen = isSeen;
|
|
8
8
|
exports.markSeen = markSeen;
|
|
9
|
-
// src/
|
|
9
|
+
// src/mailbox — the pure git-mailbox format/routing/dedup library (the demoted
|
|
10
|
+
// offline/no-endpoint FALLBACK transport, NOT the primary A2A path).
|
|
11
|
+
//
|
|
12
|
+
// Real A2A (`message/send` + the friends E2E sign-then-seal overlay — see
|
|
13
|
+
// src/a2a-client/) is the PRIMARY cross-agent transport. This git-mailbox
|
|
14
|
+
// survives only as a clearly-labelled fallback for peers with no reachable
|
|
15
|
+
// endpoint and no relay; a host opts into it explicitly.
|
|
10
16
|
//
|
|
11
17
|
// A consumer agent and a producer agent that authenticate as two DISTINCT git
|
|
12
18
|
// identities share a dedicated PRIVATE mailbox repo. This module computes the
|
|
@@ -17,8 +23,9 @@ exports.markSeen = markSeen;
|
|
|
17
23
|
// • NO fs / net / http / child_process / process.env / git anywhere — the wire
|
|
18
24
|
// (clone / pull / add / commit / push) is entirely the caller's job.
|
|
19
25
|
// Type-only imports of `ProfileShareEnvelope` (../share) + `MissionShareEnvelope`
|
|
20
|
-
// (../mission-share) carry no runtime edge. Both are CORE modules, so the
|
|
21
|
-
// import direction is eslint-legal (
|
|
26
|
+
// (../mission-share) carry no runtime edge. Both are CORE modules, so the
|
|
27
|
+
// mailbox→core import direction is eslint-legal (mailbox may import core; the
|
|
28
|
+
// reverse is forbidden).
|
|
22
29
|
//
|
|
23
30
|
// Security model (the git-native TOFU): addressing lives in the PATH, and a
|
|
24
31
|
// single-writer-per-outbox-dir layout means a forged sender can't write into
|
package/dist/mcp/bin.js
CHANGED
|
File without changes
|
package/dist/store-file.d.ts
CHANGED
|
@@ -12,9 +12,13 @@ export declare class FileFriendStore implements FriendStore {
|
|
|
12
12
|
private normalize;
|
|
13
13
|
private normalizeAgentMeta;
|
|
14
14
|
private normalizeA2AMeta;
|
|
15
|
-
/** Preserve
|
|
16
|
-
* otherwise drop it (absent ⇒ unchanged — the additive guarantee).
|
|
15
|
+
/** Preserve the top-level mailbox coord only when both fields are strings;
|
|
16
|
+
* otherwise drop it (absent ⇒ unchanged — the additive guarantee). Also used to
|
|
17
|
+
* migrate a legacy nested `a2a.mailbox`. */
|
|
17
18
|
private normalizeMailbox;
|
|
19
|
+
/** Preserve an additive a2a.relay coord only when both fields are strings;
|
|
20
|
+
* otherwise drop it (absent ⇒ unchanged — the additive guarantee). */
|
|
21
|
+
private normalizeRelay;
|
|
18
22
|
private readJson;
|
|
19
23
|
private writeJson;
|
|
20
24
|
private removeFile;
|