@a2a-exposed/encryption 0.0.0-stage → 0.8.55
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/DEPENDENCIES.md +31 -0
- package/LICENSE +21 -0
- package/LOCAL-CUSTODY-DRAFT.md +263 -0
- package/README.md +135 -2
- package/assignment.mjs +68 -0
- package/backup.mjs +63 -0
- package/carriers.mjs +45 -0
- package/content.mjs +82 -0
- package/index.mjs +230 -0
- package/keyring-worker.mjs +19 -0
- package/ledger.mjs +330 -0
- package/local-state.mjs +809 -0
- package/native-store.mjs +55 -0
- package/package.json +57 -4
- package/schemas/assigned-carrier.schema.json +271 -0
- package/schemas/assignment.schema.json +73 -0
- package/schemas/capability.schema.json +84 -0
- package/schemas/content.schema.json +546 -0
- package/schemas/envelope.schema.json +188 -0
- package/schemas/message-carrier.schema.json +195 -0
- package/schemas/receipt-carrier.schema.json +197 -0
- package/schemas/task-carrier.schema.json +247 -0
- package/tasks.mjs +55 -0
- package/transport.mjs +96 -0
package/carriers.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Structural carrier validation only. Signature, trust, replay and assignment validation are separate gates.
|
|
2
|
+
import {createHash}from'node:crypto';
|
|
3
|
+
import {validateAssignmentStructure}from'./assignment.mjs';
|
|
4
|
+
import {EXTENSION,canonicalJSON,parseJSON,EncryptionError,validateEnvelopeStructure} from './index.mjs';
|
|
5
|
+
const fail=()=>{throw new EncryptionError('carrier_invalid');};
|
|
6
|
+
const fields=(value,names)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).sort().join('\0')===[...names].sort().join('\0');
|
|
7
|
+
const id=value=>typeof value==='string'&&/^[\x21-\x7e]{1,200}$/.test(value);
|
|
8
|
+
function headerBinding(envelope){
|
|
9
|
+
if(!fields(envelope,['v','suite','header','recipient','sender','signature']))fail();const h=envelope.header;
|
|
10
|
+
if(!h||!id(h.contextId)||!id(h.messageId)||(h.taskId!==null&&!id(h.taskId))||!['user','agent'].includes(h.role)||!['message','status'].includes(h.kind))fail();
|
|
11
|
+
return h;
|
|
12
|
+
}
|
|
13
|
+
export function makeMessageCarrier(envelope,version){
|
|
14
|
+
if(!['0.3','1.0'].includes(version))fail();const copy=validateEnvelopeStructure(envelope),h=headerBinding(copy);
|
|
15
|
+
const result={...(version==='0.3'?{kind:'message'}:{}),messageId:h.messageId,contextId:h.contextId,...(h.taskId===null?{}:{taskId:h.taskId}),role:version==='0.3'?h.role:h.role==='agent'?'ROLE_AGENT':'ROLE_USER',parts:[{...(version==='0.3'?{kind:'data'}:{}),data:copy,metadata:{[EXTENSION]:{v:1}}}],extensions:[EXTENSION]};
|
|
16
|
+
if(Buffer.byteLength(canonicalJSON(result))>1024*1024)fail();return result;
|
|
17
|
+
}
|
|
18
|
+
export function readMessageCarrier(text,version){
|
|
19
|
+
if(typeof text!=='string'||!['0.3','1.0'].includes(version))fail();const carrier=parseJSON(text),names=['messageId','contextId','role','parts','extensions',...(version==='0.3'?['kind']:[]),...(carrier?.taskId===undefined?[]:['taskId'])];
|
|
20
|
+
if(!fields(carrier,names)||(version==='0.3'&&carrier.kind!=='message')||!Array.isArray(carrier.parts)||carrier.parts.length!==1||!Array.isArray(carrier.extensions)||carrier.extensions.length!==1||carrier.extensions[0]!==EXTENSION)fail();
|
|
21
|
+
const part=carrier.parts[0];if(!fields(part,['data','metadata',...(version==='0.3'?['kind']:[])])||(version==='0.3'&&part.kind!=='data')||!fields(part.metadata,[EXTENSION])||!fields(part.metadata[EXTENSION],['v'])||part.metadata[EXTENSION].v!==1)fail();
|
|
22
|
+
if(Object.hasOwn(carrier,'taskId')&&!id(carrier.taskId))fail();
|
|
23
|
+
const envelope=validateEnvelopeStructure(part.data),h=headerBinding(envelope),role=version==='0.3'?h.role:h.role==='agent'?'ROLE_AGENT':'ROLE_USER';
|
|
24
|
+
if(carrier.messageId!==h.messageId||carrier.contextId!==h.contextId||(carrier.taskId??null)!==h.taskId||carrier.role!==role)fail();return envelope;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Assignment-bearing variants never silently discard their control receipt.
|
|
28
|
+
export function makeReceiptCarrier(receipt,version){
|
|
29
|
+
if(!['0.3','1.0'].includes(version))fail();const value=validateAssignmentStructure(receipt);if(value.responseDigest!==null)fail();
|
|
30
|
+
return {...(version==='0.3'?{kind:'message'}:{}),messageId:value.ackId,contextId:value.contextId,taskId:value.taskId,role:version==='0.3'?'agent':'ROLE_AGENT',parts:[{...(version==='0.3'?{kind:'text'}:{}),text:'[encrypted request accepted]'}],extensions:[EXTENSION],metadata:{[EXTENSION]:{v:1,assignment:value}}};
|
|
31
|
+
}
|
|
32
|
+
export function readReceiptCarrier(text,version){
|
|
33
|
+
if(typeof text!=='string')fail();const value=parseJSON(text);if(!fields(value,['messageId','contextId','taskId','role','parts','extensions','metadata',...(version==='0.3'?['kind']:[])])||!fields(value.metadata,[EXTENSION])||!fields(value.metadata[EXTENSION],['v','assignment'])||value.metadata[EXTENSION].v!==1)fail();
|
|
34
|
+
const receipt=validateAssignmentStructure(value.metadata[EXTENSION].assignment),expected=makeReceiptCarrier(receipt,version);if(canonicalJSON(value)!==canonicalJSON(expected))fail();return receipt;
|
|
35
|
+
}
|
|
36
|
+
function assignedBindings(envelope,receipt){
|
|
37
|
+
const h=envelope.header;if(receipt.responseDigest===null||receipt.responseDigest!==createHash('sha256').update(canonicalJSON(envelope)).digest('base64url')||canonicalJSON(receipt.from)!==canonicalJSON(h.from)||canonicalJSON(receipt.to)!==canonicalJSON(h.to)||receipt.contextId!==h.contextId||receipt.taskId!==h.taskId||h.role!=='agent')fail();
|
|
38
|
+
}
|
|
39
|
+
export function makeAssignedCarrier(envelope,receipt,version){
|
|
40
|
+
const carrier=makeMessageCarrier(envelope,version),value=validateAssignmentStructure(receipt);assignedBindings(carrier.parts[0].data,value);carrier.metadata={[EXTENSION]:{v:1,assignment:value}};if(Buffer.byteLength(canonicalJSON(carrier))>1024*1024)fail();return carrier;
|
|
41
|
+
}
|
|
42
|
+
export function readAssignedCarrier(text,version){
|
|
43
|
+
if(typeof text!=='string')fail();const value=parseJSON(text);if(!value||typeof value!=='object'||Array.isArray(value))fail();if(!fields(value.metadata,[EXTENSION])||!fields(value.metadata[EXTENSION],['v','assignment'])||value.metadata[EXTENSION].v!==1)fail();
|
|
44
|
+
const receipt=validateAssignmentStructure(value.metadata[EXTENSION].assignment),{metadata,...rest}=value,envelope=readMessageCarrier(canonicalJSON(rest),version);assignedBindings(envelope,receipt);return {envelope,receipt};
|
|
45
|
+
}
|
package/content.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Lossless supported-part mapping and exact decrypted-content validation.
|
|
2
|
+
// Validation is not replay authorization or model delivery; trust/ledger gates remain required.
|
|
3
|
+
import {canonicalJSON,parseJSON,EncryptionError,MAX_PLAINTEXT} from './index.mjs';
|
|
4
|
+
const refuse=code=>{throw new EncryptionError(code||'content_unsupported');};
|
|
5
|
+
const object=value=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.getPrototypeOf(value)===Object.prototype;
|
|
6
|
+
const exact=(value,keys)=>object(value)&&Object.keys(value).sort().join('\0')===[...keys].sort().join('\0');
|
|
7
|
+
const optional=(value,required,allowed)=>object(value)&&required.every(key=>Object.hasOwn(value,key))&&Object.keys(value).every(key=>allowed.includes(key));
|
|
8
|
+
const string=(value,max)=>typeof value==='string'&&[...value].length<=max;
|
|
9
|
+
const nullable=(value,max)=>value===null||string(value,max);
|
|
10
|
+
const id=value=>typeof value==='string'&&/^[\x21-\x7e]{1,200}$/.test(value);
|
|
11
|
+
const digest=value=>typeof value==='string'&&/^[A-Za-z0-9_-]{43}$/.test(value)&&Buffer.from(value,'base64url').toString('base64url')===value;
|
|
12
|
+
const states=['submitted','working','input-required','completed','failed','canceled','rejected','auth-required'];
|
|
13
|
+
const terminal=new Set(['completed','failed','canceled','rejected']);
|
|
14
|
+
function base64(value){return typeof value==='string'&&Buffer.from(value,'base64').toString('base64')===value;}
|
|
15
|
+
function metadata(value){if(!object(value))refuse();}
|
|
16
|
+
function parts(values){
|
|
17
|
+
if(!Array.isArray(values)||values.length<1||values.length>100)refuse();
|
|
18
|
+
for(const part of values){
|
|
19
|
+
if(!object(part)||!nullable(part.mediaType,256))refuse();metadata(part.metadata);
|
|
20
|
+
if(part.kind==='text'){if(!exact(part,['kind','text','mediaType','metadata'])||!string(part.text,200000))refuse();}
|
|
21
|
+
else if(part.kind==='data'){if(!exact(part,['kind','data','mediaType','metadata'])||!object(part.data))refuse();}
|
|
22
|
+
else if(part.kind==='file'){if(!exact(part,['kind','file','mediaType','metadata'])||!exact(part.file,['name','bytes'])||!nullable(part.file.name,512)||!base64(part.file.bytes))refuse();}
|
|
23
|
+
else refuse();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function strings(value,max,predicate){if(!Array.isArray(value)||value.length>max||value.some(x=>!predicate(x)))refuse();}
|
|
27
|
+
function message(value){
|
|
28
|
+
if(!exact(value,['kind','authorRole','parts','metadata','extensions','referenceTaskIds',...(value?.authorRole==='agent'?['requestDigest']:[])])||value.kind!=='message'||!['user','agent'].includes(value.authorRole))refuse();
|
|
29
|
+
if(value.authorRole==='agent'&&!digest(value.requestDigest))refuse('request_binding_required');
|
|
30
|
+
parts(value.parts);metadata(value.metadata);strings(value.extensions,100,x=>string(x,4096)&&x.length>0);strings(value.referenceTaskIds,100,id);
|
|
31
|
+
}
|
|
32
|
+
export function validateContent(value,header,{expectedRequestDigest}={}){
|
|
33
|
+
try{
|
|
34
|
+
const text=canonicalJSON(value);if(Buffer.byteLength(text)>MAX_PLAINTEXT-128)refuse('content_too_large');
|
|
35
|
+
if(!object(header)||!['user','agent'].includes(header.role)||!id(header.contextId)||!id(header.messageId)||(header.taskId!==null&&!id(header.taskId)))refuse();
|
|
36
|
+
if(header.taskId===null&&(header.role!=='user'||header.kind!=='message'))refuse();
|
|
37
|
+
if(header.kind==='message'){
|
|
38
|
+
message(value);if(header.state!==null||value.authorRole!==header.role)refuse('message_role_unsupported');
|
|
39
|
+
}else if(header.kind==='status'){
|
|
40
|
+
if(!exact(value,['kind','authorRole','revision','previousDigest','state','statusMessage','artifacts','metadata','terminal',...(value?.authorRole==='agent'?['requestDigest']:[])])||value.kind!=='snapshot'||value.authorRole!==header.role||header.taskId===null||!states.includes(value.state)||value.state!==header.state||value.terminal!==terminal.has(value.state)||!Number.isSafeInteger(value.revision)||value.revision<0||(value.revision===0?value.previousDigest!==null:!digest(value.previousDigest)))refuse();
|
|
41
|
+
if(value.authorRole==='agent'&&!digest(value.requestDigest))refuse('request_binding_required');
|
|
42
|
+
if(value.statusMessage!==null){message(value.statusMessage);if(value.statusMessage.authorRole!==value.authorRole||(value.authorRole==='agent'&&value.statusMessage.requestDigest!==value.requestDigest))refuse();}
|
|
43
|
+
metadata(value.metadata);if(!Array.isArray(value.artifacts)||value.artifacts.length>100)refuse();const seen=new Set();
|
|
44
|
+
for(const artifact of value.artifacts){if(!exact(artifact,['artifactId','name','description','parts','metadata','extensions'])||!id(artifact.artifactId)||seen.has(artifact.artifactId)||!nullable(artifact.name,512)||!nullable(artifact.description,4000))refuse();seen.add(artifact.artifactId);parts(artifact.parts);metadata(artifact.metadata);strings(artifact.extensions,100,x=>string(x,4096)&&x.length>0);}
|
|
45
|
+
}else refuse('content_kind_unsupported');
|
|
46
|
+
if(expectedRequestDigest!==undefined&&value.requestDigest!==expectedRequestDigest)refuse('request_binding_mismatch');
|
|
47
|
+
return parseJSON(text);
|
|
48
|
+
}catch(error){if(error instanceof EncryptionError)throw error;refuse();}
|
|
49
|
+
}
|
|
50
|
+
export function fromProtocolParts(value,version){
|
|
51
|
+
try{
|
|
52
|
+
// Clone/validate JSON representation before touching values; never execute getters.
|
|
53
|
+
const raw=parseJSON(canonicalJSON(value));if(!['0.3','1.0'].includes(version)||!Array.isArray(raw)||raw.length<1||raw.length>100)refuse();
|
|
54
|
+
const mapped=raw.map(part=>{
|
|
55
|
+
let kind;if(version==='0.3')kind=part?.kind;else if(object(part)){const choices=['text','data','raw','url'].filter(key=>Object.hasOwn(part,key));if(choices.length!==1)refuse();kind=choices[0]==='raw'||choices[0]==='url'?'file':choices[0];}
|
|
56
|
+
if(object(part)&&Object.hasOwn(part,'mediaType')&&typeof part.mediaType!=='string')refuse();
|
|
57
|
+
const common={kind,mediaType:part?.mediaType??null,metadata:part?.metadata===undefined?{}:part.metadata};
|
|
58
|
+
if(kind==='text'){if(!optional(part,version==='0.3'?['kind','text']:['text'],version==='0.3'?['kind','text','mediaType','metadata']:['text','mediaType','metadata']))refuse();return {...common,text:part.text};}
|
|
59
|
+
if(kind==='data'){if(!optional(part,version==='0.3'?['kind','data']:['data'],version==='0.3'?['kind','data','mediaType','metadata']:['data','mediaType','metadata']))refuse();return {...common,data:part.data};}
|
|
60
|
+
if(kind==='file'){
|
|
61
|
+
if(version==='0.3'){
|
|
62
|
+
if(!optional(part,['kind','file'],['kind','file','metadata'])||!optional(part.file,['bytes'],['bytes','name','mimeType']))refuse('file_uri_unsupported');
|
|
63
|
+
for(const key of ['name','mimeType'])if(Object.hasOwn(part.file,key)&&typeof part.file[key]!=='string')refuse();
|
|
64
|
+
return {...common,mediaType:part.file.mimeType??null,file:{name:part.file.name??null,bytes:part.file.bytes}};
|
|
65
|
+
}
|
|
66
|
+
if(!optional(part,['raw'],['raw','filename','mediaType','metadata']))refuse('file_uri_unsupported');if(Object.hasOwn(part,'filename')&&typeof part.filename!=='string')refuse();return {...common,file:{name:part.filename??null,bytes:part.raw}};
|
|
67
|
+
}
|
|
68
|
+
refuse();
|
|
69
|
+
});
|
|
70
|
+
parts(mapped);return mapped;
|
|
71
|
+
}catch(error){if(error instanceof EncryptionError)throw error;refuse();}
|
|
72
|
+
}
|
|
73
|
+
export function toProtocolParts(value,version){
|
|
74
|
+
try{
|
|
75
|
+
const copy=parseJSON(canonicalJSON(value));parts(copy);if(!['0.3','1.0'].includes(version))refuse();
|
|
76
|
+
return copy.map(part=>{
|
|
77
|
+
const meta={metadata:part.metadata};
|
|
78
|
+
if(part.kind==='file')return version==='0.3'?{kind:'file',file:{bytes:part.file.bytes,...(part.file.name===null?{}:{name:part.file.name}),...(part.mediaType===null?{}:{mimeType:part.mediaType})},...meta}:{raw:part.file.bytes,...(part.file.name===null?{}:{filename:part.file.name}),...(part.mediaType===null?{}:{mediaType:part.mediaType}),...meta};
|
|
79
|
+
return {...(version==='0.3'?{kind:part.kind}:{}),[part.kind]:part[part.kind],...(part.mediaType===null?{}:{mediaType:part.mediaType}),...meta};
|
|
80
|
+
});
|
|
81
|
+
}catch(error){if(error instanceof EncryptionError)throw error;refuse();}
|
|
82
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Local-only primitives for a review-draft extension. No transport/key storage is enabled here.
|
|
2
|
+
import { CipherSuite, HkdfSha256 } from "@hpke/core";
|
|
3
|
+
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
|
|
4
|
+
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
|
|
5
|
+
|
|
6
|
+
export const EXTENSION = "https://a2a.exposed/ext/sealed/v1";
|
|
7
|
+
export const SUITE = "HPKE-X25519-SHA256-ChaCha20Poly1305";
|
|
8
|
+
export const MAX_PLAINTEXT = 300 * 1024;
|
|
9
|
+
const encoder = new TextEncoder(), decoder = new TextDecoder("utf-8", { fatal: true });
|
|
10
|
+
const suite = new CipherSuite({ kem: new DhkemX25519HkdfSha256(), kdf: new HkdfSha256(), aead: new Chacha20Poly1305() });
|
|
11
|
+
const encode = value => encoder.encode(value);
|
|
12
|
+
const b64 = bytes => Buffer.from(bytes).toString("base64url");
|
|
13
|
+
export class EncryptionError extends Error {
|
|
14
|
+
constructor(code) { super(code); this.name = "EncryptionError"; this.code = code; }
|
|
15
|
+
}
|
|
16
|
+
const refuse = (code = "verification_failed") => { throw new EncryptionError(code); };
|
|
17
|
+
function raw(value, length) {
|
|
18
|
+
if (typeof value !== "string" || value.length > Math.ceil((length ?? (MAX_PLAINTEXT + 16)) * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value)) refuse();
|
|
19
|
+
const bytes = Buffer.from(value, "base64url");
|
|
20
|
+
if (b64(bytes) !== value || (length !== undefined && bytes.length !== length)) refuse();
|
|
21
|
+
return new Uint8Array(bytes);
|
|
22
|
+
}
|
|
23
|
+
function fields(value, names) {
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) refuse();
|
|
25
|
+
const keys = Object.keys(value).sort();
|
|
26
|
+
if (keys.join("\0") !== [...names].sort().join("\0")) refuse();
|
|
27
|
+
}
|
|
28
|
+
function validString(value) {
|
|
29
|
+
if (typeof value !== "string") refuse();
|
|
30
|
+
for (const char of value) { const point = char.codePointAt(0); if (point >= 0xd800 && point <= 0xdfff) refuse(); }
|
|
31
|
+
}
|
|
32
|
+
export function canonicalJSON(value, depth = 0) {
|
|
33
|
+
if (depth > 32) refuse("content_unsupported");
|
|
34
|
+
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
35
|
+
if (typeof value === "string") { validString(value); return JSON.stringify(value); }
|
|
36
|
+
if (typeof value === "number") { if (!Number.isFinite(value)) refuse("content_unsupported"); return JSON.stringify(value); }
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || Reflect.ownKeys(value).length !== value.length + 1) refuse("content_unsupported");
|
|
39
|
+
const items = [];
|
|
40
|
+
for (let index = 0; index < value.length; index++) {
|
|
41
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
42
|
+
if (!descriptor || !("value" in descriptor)) refuse("content_unsupported");
|
|
43
|
+
items.push(canonicalJSON(descriptor.value, depth + 1));
|
|
44
|
+
}
|
|
45
|
+
return "[" + items.join(",") + "]";
|
|
46
|
+
}
|
|
47
|
+
if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype || Reflect.ownKeys(value).length !== Object.keys(value).length) refuse("content_unsupported");
|
|
48
|
+
return "{" + Object.keys(value).sort().map(key => {
|
|
49
|
+
validString(key); const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
50
|
+
if (!descriptor || !("value" in descriptor)) refuse("content_unsupported");
|
|
51
|
+
return JSON.stringify(key) + ":" + canonicalJSON(descriptor.value, depth + 1);
|
|
52
|
+
}).join(",") + "}";
|
|
53
|
+
}
|
|
54
|
+
// JSON.parse first establishes grammar. Then a bounded token walk rejects duplicate members
|
|
55
|
+
// before normalisation could erase a signed/unsigned disagreement.
|
|
56
|
+
export function parseJSON(text, maxBytes = 1024 * 1024) {
|
|
57
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 32 * 1024 * 1024) refuse("content_too_large");
|
|
58
|
+
if (typeof text !== "string" || Buffer.byteLength(text) > maxBytes) refuse("content_too_large");
|
|
59
|
+
let value; try { value = JSON.parse(text); } catch { refuse(); }
|
|
60
|
+
const tokens = text.match(/"(?:[^"\\]|\\.)*"|[{}\[\]:,]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g) || [];
|
|
61
|
+
let offset = 0;
|
|
62
|
+
function walk(depth) {
|
|
63
|
+
if (depth > 32) refuse("content_unsupported");
|
|
64
|
+
const token = tokens[offset++];
|
|
65
|
+
if (token === "{") {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
if (tokens[offset] === "}") { offset++; return; }
|
|
68
|
+
for (;;) {
|
|
69
|
+
const key = JSON.parse(tokens[offset++]); if (seen.has(key)) refuse(); seen.add(key);
|
|
70
|
+
offset++; walk(depth + 1);
|
|
71
|
+
if (tokens[offset++] === "}") return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (token === "[") {
|
|
75
|
+
if (tokens[offset] === "]") { offset++; return; }
|
|
76
|
+
for (;;) { walk(depth + 1); if (tokens[offset++] === "]") return; }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
walk(0); canonicalJSON(value); return value;
|
|
80
|
+
}
|
|
81
|
+
const digest = async bytes => b64(await crypto.subtle.digest("SHA-256", bytes));
|
|
82
|
+
const sign = async (key, bytes) => b64(await crypto.subtle.sign("Ed25519", key, bytes));
|
|
83
|
+
async function verify(key, signature, bytes) {
|
|
84
|
+
const publicKey = await crypto.subtle.importKey("raw", raw(key, 32), "Ed25519", false, ["verify"]);
|
|
85
|
+
if (!await crypto.subtle.verify("Ed25519", publicKey, raw(signature, 64), bytes)) refuse();
|
|
86
|
+
}
|
|
87
|
+
function clock(now) {
|
|
88
|
+
if (!Number.isSafeInteger(now) || now < 0 || now > 8640000000000000) refuse();
|
|
89
|
+
}
|
|
90
|
+
function timestamp(value) {
|
|
91
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) refuse();
|
|
92
|
+
}
|
|
93
|
+
function card(value) {
|
|
94
|
+
if (typeof value !== "string" || value.length > 2048) refuse();
|
|
95
|
+
let url; try { url = new URL(value); } catch { refuse(); }
|
|
96
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || url.search || url.href !== value) refuse();
|
|
97
|
+
// DNS/SSRF checks are the transport's responsibility. Key bundles never cause a fetch.
|
|
98
|
+
}
|
|
99
|
+
function peer(value) { fields(value, ["cardUrl", "identityKey", "keyId"]); card(value.cardUrl); raw(value.identityKey, 32); raw(value.keyId, 32); }
|
|
100
|
+
export function validatePeerStructure(value) { const copy = parseJSON(canonicalJSON(value)); peer(copy); return copy; }
|
|
101
|
+
const identityOf = bundle => ({ cardUrl: bundle.cardUrl, identityKey: bundle.identityKey, keyId: bundle.keyId });
|
|
102
|
+
function header(value, now, fresh = true) {
|
|
103
|
+
if (fresh) clock(now);
|
|
104
|
+
fields(value, ["from", "to", "contextId", "messageId", "taskId", "role", "state", "kind", "sentAt", "commitment"]);
|
|
105
|
+
peer(value.from); peer(value.to);
|
|
106
|
+
for (const id of [value.contextId, value.messageId]) if (typeof id !== "string" || !/^[\x21-\x7e]{1,200}$/.test(id)) refuse();
|
|
107
|
+
if (value.taskId !== null && (typeof value.taskId !== "string" || !/^[\x21-\x7e]{1,200}$/.test(value.taskId))) refuse();
|
|
108
|
+
if (!["user", "agent"].includes(value.role) || !["message", "status", "artifact"].includes(value.kind)) refuse();
|
|
109
|
+
if (value.state !== null && !["submitted", "working", "input-required", "completed", "failed", "canceled", "rejected", "auth-required"].includes(value.state)) refuse();
|
|
110
|
+
if (value.taskId === null && (value.role !== "user" || value.kind !== "message" || value.state !== null)) refuse();
|
|
111
|
+
timestamp(value.sentAt); raw(value.commitment, 32);
|
|
112
|
+
if (fresh && (Date.parse(value.sentAt) < now - 30 * 86400000 || Date.parse(value.sentAt) > now + 300000)) refuse("replay");
|
|
113
|
+
}
|
|
114
|
+
export function validateEnvelopeStructure(value) {
|
|
115
|
+
// Clone before asynchronous users can observe it. This checks structure/bytes,
|
|
116
|
+
// not signature authenticity, freshness, local trust or replay authorization.
|
|
117
|
+
const envelope = typeof value === "string" ? parseJSON(value) : parseJSON(canonicalJSON(value));
|
|
118
|
+
fields(envelope, ["v", "suite", "header", "recipient", "sender", "signature"]);
|
|
119
|
+
if (envelope.v !== 1 || envelope.suite !== SUITE) refuse("extension_unsupported");
|
|
120
|
+
header(envelope.header, undefined, false);
|
|
121
|
+
for (const copy of [envelope.recipient, envelope.sender]) {
|
|
122
|
+
fields(copy, ["enc", "ct"]); raw(copy.enc, 32); const ct = raw(copy.ct);
|
|
123
|
+
if (ct.length < 16 || ct.length > MAX_PLAINTEXT + 16) refuse("content_too_large");
|
|
124
|
+
}
|
|
125
|
+
raw(envelope.signature, 64);
|
|
126
|
+
return envelope;
|
|
127
|
+
}
|
|
128
|
+
function bundleBody(bundle) { const { signature, ...body } = bundle; return body; }
|
|
129
|
+
function envelopeBody(envelope) { const { signature, ...body } = envelope; return body; }
|
|
130
|
+
async function verifyBundleRaw(bundle, { cardUrl, identityKey, now = Date.now() }) {
|
|
131
|
+
clock(now);
|
|
132
|
+
fields(bundle, ["v", "suite", "cardUrl", "identityKey", "encryptionKey", "keyId", "notBefore", "signature"]);
|
|
133
|
+
if (bundle.v !== 1 || bundle.suite !== SUITE) refuse("extension_unsupported");
|
|
134
|
+
card(bundle.cardUrl); timestamp(bundle.notBefore);
|
|
135
|
+
if (bundle.cardUrl !== cardUrl || bundle.identityKey !== identityKey || Date.parse(bundle.notBefore) > now + 300000) refuse("key_changed");
|
|
136
|
+
if (await digest(raw(bundle.encryptionKey, 32)) !== bundle.keyId) refuse();
|
|
137
|
+
await verify(identityKey, bundle.signature, encode("a2a-exposed sealed key v1\n" + canonicalJSON(bundleBody(bundle))));
|
|
138
|
+
return bundle;
|
|
139
|
+
}
|
|
140
|
+
async function generateIdentityRaw(cardUrl, now = Date.now()) {
|
|
141
|
+
clock(now); card(cardUrl);
|
|
142
|
+
const signing = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
|
|
143
|
+
const encryption = await suite.kem.generateKeyPair();
|
|
144
|
+
const body = { v: 1, suite: SUITE, cardUrl, identityKey: b64(await crypto.subtle.exportKey("raw", signing.publicKey)), encryptionKey: b64(await suite.kem.serializePublicKey(encryption.publicKey)), notBefore: new Date(now).toISOString() };
|
|
145
|
+
body.keyId = await digest(raw(body.encryptionKey, 32));
|
|
146
|
+
const bundle = { ...body, signature: await sign(signing.privateKey, encode("a2a-exposed sealed key v1\n" + canonicalJSON(body))) };
|
|
147
|
+
return { signingKey: signing.privateKey, encryptionKey: encryption.privateKey, bundle };
|
|
148
|
+
}
|
|
149
|
+
async function rotateEncryptionKeyRaw(identity, now = Date.now()) {
|
|
150
|
+
clock(now);
|
|
151
|
+
await verifyBundle(identity.bundle, { cardUrl: identity.bundle.cardUrl, identityKey: identity.bundle.identityKey, now });
|
|
152
|
+
const encryption = await suite.kem.generateKeyPair();
|
|
153
|
+
const body = { ...bundleBody(identity.bundle), encryptionKey: b64(await suite.kem.serializePublicKey(encryption.publicKey)), notBefore: new Date(now).toISOString() };
|
|
154
|
+
body.keyId = await digest(raw(body.encryptionKey, 32));
|
|
155
|
+
const bundle = { ...body, signature: await sign(identity.signingKey, encode("a2a-exposed sealed key v1\n" + canonicalJSON(body))) };
|
|
156
|
+
await verifyBundle(bundle, { cardUrl: body.cardUrl, identityKey: body.identityKey, now });
|
|
157
|
+
return { signingKey: identity.signingKey, encryptionKey: encryption.privateKey, bundle };
|
|
158
|
+
}
|
|
159
|
+
async function commitment(nonce, content) {
|
|
160
|
+
const bytes = encode(canonicalJSON(content)), salt = raw(nonce, 32);
|
|
161
|
+
const joined = new Uint8Array(salt.length + bytes.length); joined.set(salt); joined.set(bytes, salt.length);
|
|
162
|
+
return digest(joined);
|
|
163
|
+
}
|
|
164
|
+
async function sealCopy(bundle, plaintext, aad, purpose) {
|
|
165
|
+
const publicKey = await suite.kem.deserializePublicKey(raw(bundle.encryptionKey, 32));
|
|
166
|
+
const context = await suite.createSenderContext({ recipientPublicKey: publicKey, info: encode("a2a-exposed sealed v1 " + purpose) });
|
|
167
|
+
return { enc: b64(context.enc), ct: b64(await context.seal(plaintext, aad)) };
|
|
168
|
+
}
|
|
169
|
+
// The caller must supply locally pinned bundles, and must enforce policy and replay state.
|
|
170
|
+
async function sealRaw({ identity, recipient, binding, content, now = Date.now() }) {
|
|
171
|
+
fields(binding, ["contextId", "messageId", "taskId", "role", "state", "kind"]);
|
|
172
|
+
await verifyBundle(identity.bundle, { cardUrl: identity.bundle.cardUrl, identityKey: identity.bundle.identityKey, now });
|
|
173
|
+
await verifyBundle(recipient, { cardUrl: recipient.cardUrl, identityKey: recipient.identityKey, now });
|
|
174
|
+
const nonce = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
175
|
+
const plaintext = encode(canonicalJSON({ nonce, content }));
|
|
176
|
+
if (plaintext.length > MAX_PLAINTEXT) refuse("content_too_large");
|
|
177
|
+
const h = { ...binding, from: identityOf(identity.bundle), to: identityOf(recipient), sentAt: new Date(now).toISOString(), commitment: await commitment(nonce, content) };
|
|
178
|
+
header(h, now); const aad = encode(canonicalJSON(h));
|
|
179
|
+
const body = { v: 1, suite: SUITE, header: h, recipient: await sealCopy(recipient, plaintext, aad, "recipient"), sender: await sealCopy(identity.bundle, plaintext, aad, "sender") };
|
|
180
|
+
const envelope = { ...body, signature: await sign(identity.signingKey, encode("a2a-exposed sealed envelope v1\n" + canonicalJSON(body))) };
|
|
181
|
+
if (Buffer.byteLength(canonicalJSON(envelope)) > 1024 * 1024) refuse("content_too_large");
|
|
182
|
+
await open({ envelope, identity, sender: identity.bundle, recipient, expected: binding, purpose: "sender", now });
|
|
183
|
+
return envelope;
|
|
184
|
+
}
|
|
185
|
+
async function verifyEnvelopeSignatureRaw({ envelope, sender, recipient, now = Date.now() }) {
|
|
186
|
+
envelope = validateEnvelopeStructure(envelope); sender = parseJSON(canonicalJSON(sender)); recipient = parseJSON(canonicalJSON(recipient));
|
|
187
|
+
header(envelope.header, now);
|
|
188
|
+
if (canonicalJSON(envelope.header.from) !== canonicalJSON(identityOf(sender)) || canonicalJSON(envelope.header.to) !== canonicalJSON(identityOf(recipient))) refuse("key_changed");
|
|
189
|
+
await verifyBundle(sender, { cardUrl: sender.cardUrl, identityKey: sender.identityKey, now });
|
|
190
|
+
await verifyBundle(recipient, { cardUrl: recipient.cardUrl, identityKey: recipient.identityKey, now });
|
|
191
|
+
await verify(sender.identityKey, envelope.signature, encode("a2a-exposed sealed envelope v1\n" + canonicalJSON(envelopeBody(envelope))));
|
|
192
|
+
return { envelope, sender, recipient };
|
|
193
|
+
}
|
|
194
|
+
async function openRaw({ envelope, identity, sender, recipient, expected, purpose = "recipient", now = Date.now() }) {
|
|
195
|
+
try {
|
|
196
|
+
// Capture caller-owned public objects and private key references before awaiting.
|
|
197
|
+
identity = { bundle: parseJSON(canonicalJSON(identity.bundle)), encryptionKey: identity.encryptionKey };
|
|
198
|
+
expected = parseJSON(canonicalJSON(expected));
|
|
199
|
+
if (!["recipient", "sender"].includes(purpose)) refuse();
|
|
200
|
+
fields(expected, ["contextId", "messageId", "taskId", "role", "state", "kind"]);
|
|
201
|
+
const verified = await verifyEnvelopeSignatureRaw({ envelope, sender, recipient, now });
|
|
202
|
+
({ envelope, sender, recipient } = verified);
|
|
203
|
+
for (const key of Object.keys(expected)) if (expected[key] !== envelope.header[key]) refuse();
|
|
204
|
+
const own = purpose === "sender" ? sender : recipient;
|
|
205
|
+
if (canonicalJSON(identityOf(identity.bundle)) !== canonicalJSON(identityOf(own))) refuse("key_lost");
|
|
206
|
+
const copy = envelope[purpose];
|
|
207
|
+
const context = await suite.createRecipientContext({ recipientKey: identity.encryptionKey, enc: raw(copy.enc, 32), info: encode("a2a-exposed sealed v1 " + purpose) });
|
|
208
|
+
const plaintext = await context.open(raw(copy.ct), encode(canonicalJSON(envelope.header)));
|
|
209
|
+
if (plaintext.byteLength > MAX_PLAINTEXT) refuse("content_too_large");
|
|
210
|
+
const payload = parseJSON(decoder.decode(plaintext)); fields(payload, ["nonce", "content"]);
|
|
211
|
+
if (await commitment(payload.nonce, payload.content) !== envelope.header.commitment) refuse();
|
|
212
|
+
return payload.content;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof EncryptionError) throw error;
|
|
215
|
+
refuse(); // Never expose HPKE/JSON/provider exception strings or material.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function safeCall(action) {
|
|
220
|
+
try { return await action(); } catch (error) {
|
|
221
|
+
if (error instanceof EncryptionError) throw error;
|
|
222
|
+
refuse();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export const verifyBundle = (...args) => safeCall(() => verifyBundleRaw(...args));
|
|
226
|
+
export const generateIdentity = (...args) => safeCall(() => generateIdentityRaw(...args));
|
|
227
|
+
export const rotateEncryptionKey = (...args) => safeCall(() => rotateEncryptionKeyRaw(...args));
|
|
228
|
+
export const seal = (...args) => safeCall(() => sealRaw(...args));
|
|
229
|
+
export const verifyEnvelopeSignature = (...args) => safeCall(() => verifyEnvelopeSignatureRaw(...args));
|
|
230
|
+
export const open = (...args) => safeCall(() => openRaw(...args));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Private child-process protocol. Never invoke from an agent tool or return its output directly.
|
|
2
|
+
import { AsyncEntry } from "@napi-rs/keyring";
|
|
3
|
+
let text = "";
|
|
4
|
+
try {
|
|
5
|
+
for await (const chunk of process.stdin) { text += chunk; if (Buffer.byteLength(text) > 16384) throw Error(); }
|
|
6
|
+
const input = JSON.parse(text);
|
|
7
|
+
if (!/^[a-f0-9-]{36}$/.test(input.namespace) || !/^[a-f0-9-]{36}\/[A-Za-z0-9_-]{43}$/.test(input.account)) throw Error();
|
|
8
|
+
const entry = new AsyncEntry("a2a-exposed.local-encryption.v1." + input.namespace, input.account, { linux: { store: "secret-service" } });
|
|
9
|
+
let value;
|
|
10
|
+
if (input.op === "get") value = await entry.getPassword();
|
|
11
|
+
else if (input.op === "set" && typeof input.value === "string" && input.value.length < 12000) {
|
|
12
|
+
await entry.setPassword(input.value);
|
|
13
|
+
if (await entry.getPassword() !== input.value) throw Error();
|
|
14
|
+
} else if (input.op === "delete") {
|
|
15
|
+
await entry.deleteCredential();
|
|
16
|
+
if (await entry.getPassword() !== null) throw Error();
|
|
17
|
+
} else throw Error();
|
|
18
|
+
process.stdout.write(JSON.stringify({ ok: true, ...(input.op === "get" ? { value } : {}) }));
|
|
19
|
+
} catch { process.stdout.write('{"ok":false,"code":"key_store_unavailable"}'); process.exitCode = 1; }
|