diff --git a/CLAUDE.md b/CLAUDE.md index 9772ba1..ca0e881 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,9 @@ result = await crawler.execute_tool_call("search_poi", {"query": "Beijing"}) - **`authentication/`**: DID WBA (Web-based Decentralized Identifiers) authentication — DID document creation (`create_did_wba_document`), auth header generation (`DIDWbaAuthHeader`), RS256 JWT signature verification (`DidWbaVerifier`) - **`direct_e2ee/`**: ANP-P5 private-chat E2EE SDK surface for Go/Rust parity. It owns P5 wire models, JCS AAD builders, X3DH-like initial material, HKDF `kdf_rk`/`kdf_ck`, pending-confirmation bootstrap, Double Ratchet-like session state, and top-level OPK publish/get sidecar handling for product clients/services to consume. +- **`group_e2ee/`**: ANP-P6 group E2EE SDK surface. It owns P6 wire models, recovery KeyPackage/recover-member shapes, group send AAD/JCS helpers, hidden leave-request/process control-plane structs, and shared contract fixtures; real OpenMLS one-shot operations live in `rust/src/bin/anp-mls.rs` and include create/add, recover-member prepare/finalize/abort, remove-member, commit process/finalize/abort, and local leave terminal-state handling, while non-cryptographic contract artifacts remain available only behind explicit test flags. - Shared P5 direct E2EE vector fixtures live under `testdata/direct_e2ee/`; Go (`golang/direct_e2ee/shared_vectors_test.go`) and Rust (`rust/tests/direct_e2ee_shared_vectors.rs`) must pass the same JSON vectors before product integrations claim cross-language parity. +- Shared P6 group E2EE proof fixtures live under `testdata/group_e2ee/`; Go (`golang/proof/proof_test.go`) and Rust (`rust/tests/proof_tests.rs`) verify the same `did_wba_binding` golden vector and tamper-negative before public discovery work can claim proof parity. - P5 key-service calls (`direct.e2ee.publish_prekey_bundle` / `direct.e2ee.get_prekey_bundle`) must bind `meta.target.kind="service"` and use the caller DID document's advertised `ANPMessageService.serviceDid`; do not send legacy target-less control-plane requests. - **`e2e_encryption_v2/`**: Legacy transport-agnostic E2E encryption v2 using HTTP RESTful dict-based messages, ECDHE key exchange, AES-GCM encryption. Session state machine: IDLE → HANDSHAKE_INITIATED → HANDSHAKE_COMPLETING → ACTIVE. Uses `did:wba:` format and snake_case fields (unlike the older `e2e_encryption/` which is WebSocket-coupled with camelCase). Do not use it for new ANP-P5 direct E2EE service behavior. - **`e2e_encryption/`**: Legacy WebSocket-based E2E encryption (forward compatibility, uses `did:anp:` format) @@ -141,6 +143,7 @@ Google Python Style Guide: 4-space indentation, type hints on function signature - Always use `uv run` prefix when running scripts to ensure correct environment - Go SDK work under `golang/` must remain pure Go and must not use cgo; prefer cross-platform standard-library or pure-Go dependencies only +- `rust/src/bin/anp-mls.rs` is a one-shot group E2EE binary: JSON request via stdin, JSON response via stdout, logs/errors via stderr. `system version --json-in -` is the stable no-state compatibility probe for packaging/doctor checks. Default real mode requires `--data-dir`, persists OpenMLS private state plus app metadata/idempotency/pending membership commits in local SQLite (`state.db`), validates group-state/cipher claims against local bindings before encrypt/decrypt, redacts decrypted plaintext from operation records, and guards mutations with `state.lock`; contract-test artifacts are only allowed when explicitly enabled and must remain marked `non_cryptographic=true` / `artifact_mode=contract-test`. Remove-member prepares an OpenMLS pending commit that must be finalized after service acceptance or aborted after deterministic rejection; leave is a documented OpenMLS-0.8 local terminal-state path until another active member/service flow advances the MLS epoch for remaining members. `group status` reports pending commit summaries plus `local_epoch`; inactive bindings return `left`/`removed` instead of misleading `active` so one-shot clients can distinguish accepted-but-unfinalized commits, local terminal states, and stale epochs before sending. - OpenANP `@interface` method names must be unique within a class (tracked by function reference) - OpenANP `Context` parameter (`ctx: Context`) is auto-injected and excluded from OpenRPC schemas; detected by parameter name `ctx`/`context` or type annotation - OpenANP `router()` works as both class method (tries no-arg instantiation) and instance method (recommended for constructors with arguments) diff --git a/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md new file mode 100644 index 0000000..89a7c04 --- /dev/null +++ b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md @@ -0,0 +1,36 @@ +# Group E2EE Step B P6 Conformance Notes — ANP SDK / anp-mls + +## Scope + +- Align `anp-mls` real OpenMLS path with P6 before any public discovery claim. +- Add required `ratchet_tree_b64u` export/import for add/welcome processing. +- Bind MLS encrypt/decrypt authenticated data to canonical P6 send AAD and keep a stable golden vector in the Rust SDK helper. +- Add hidden PR-B3 recover-member contract shapes and `anp-mls` contract checks for prepare/finalize/abort so downstream CLI/service integration can share stable pending-commit fields. +- Deepen local DID WBA binding checks against member DID, MLS BasicCredential identity, leaf signature key, verification method, validity window, and optional proof shape. +- Treat `group_state_ref.group_state_version` as service state for AAD only; MLS epoch validation remains bound to explicit `epoch` so P4 state versions and MLS epochs are not conflated. + +## Public discovery stance + +- This is still hidden/test-only integration work. +- Do not advertise `anp.group.e2ee.v1` / `group-e2ee` from ANP SDK release notes until message-service discovery gate and security review pass in a separate enablement PR. + +## Config / migration impact + +- No ANP database migration. +- No Go/CLI CGO dependency is introduced; product clients still invoke `anp-mls` via JSON stdin/stdout. +- Contract-test artifacts remain available only when explicitly enabled and remain marked non-cryptographic. + +## Fresh validation evidence + +- `cargo fmt --manifest-path rust/Cargo.toml --check` +- `cargo test --manifest-path rust/Cargo.toml group_e2ee --all-targets` → passed: Rust P6 helper tests 3 passed; real `anp-mls` group E2EE tests 12 passed. +- PR-B3 recovery sync adds focused evidence for `recover-member-prepare/finalize/abort` contract paths plus Rust/Go recover-member wire models; keep this evidence fresh in the implementing PR. + +## Rollback + +- Revert the Step B `anp-mls` changes if the service/CLI side must return to the previous hidden minimal loop. This would remove ratchet-tree-required welcome processing and MLS AAD tamper rejection, so public discovery must remain disabled. + +## Caveats + +- Still no External Commit, multi-device membership sync, group attachment E2EE, cloud snapshot, public recover-member discovery, or HTTP `anp-mls serve` requirement. +- No k1 DID compatibility is included. diff --git a/docs/pr-notes/group-e2ee-v1-pr-closeout.md b/docs/pr-notes/group-e2ee-v1-pr-closeout.md new file mode 100644 index 0000000..915e262 --- /dev/null +++ b/docs/pr-notes/group-e2ee-v1-pr-closeout.md @@ -0,0 +1,34 @@ +# Group E2EE v1 PR Closeout Notes — ANP SDK / anp-mls + +## Scope + +- Close stale wording in the Rust P6 group E2EE helper so it reflects the current split: P6 wire/canonicalization helpers in `rust/src/group_e2ee/`, real OpenMLS operations in the `anp-mls` binary. +- Keep PR-B3 recover-member contract surfaces synchronized for `recover-member-prepare`, `recover-member-finalize`, and `recover-member-abort`; this remains hidden/test-only and is not public discovery. +- Keep contract-test artifacts explicitly non-cryptographic; do not claim public discovery readiness. + +## Commits / branch context + +- Current branch is ahead of origin with recent Group E2EE work through `25cdb83 Harden anp-mls release probes and local bindings`. +- This closeout only adjusts wording/docs for the existing hidden/test-only v1 minimal loop. + +## Config / migration impact + +- No ANP SDK database migration or config default changes. +- No k1 DID compatibility work. + +## Validation + +- Fresh evidence collected in this Ralph pass: + - `cargo test --manifest-path Cargo.toml group_e2ee -- --nocapture` from `anp/anp/rust` → passed: P6 helper tests 2 passed and real `anp-mls` Group E2EE tests 9 passed. + - Stale wording grep no longer finds the old “no MLS implementation here yet” disclaimer in current Rust P6 helper comments. + +## Rollback + +- Revert the wording-only commit if needed. No schema or wire behavior rollback is required. + +## Caveats + +- Group E2EE v1 remains a minimal same-domain/single-device loop. +- Recover-member prepare/finalize/abort is available only as the hidden PR-B3 recovery lifecycle surface; do not route public add-member or discovery behavior through it. +- Step B P6 conformance is tracked in `docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md`; public discovery still requires a separate security-reviewed enablement PR. +- Public discovery remains hidden; do not advertise `anp.group.e2ee.v1` / `group-e2ee` from this PR. diff --git a/golang/docs/api.md b/golang/docs/api.md index d831120..62dba9b 100644 --- a/golang/docs/api.md +++ b/golang/docs/api.md @@ -112,6 +112,20 @@ Common production entry points: - `MessageServiceDirectE2eeClient.ProcessIncoming` - `MessageServiceDirectE2eeClient.DecryptHistoryPage` +### `group_e2ee` + +Wire models and an `ExecProvider` for `anp.group.e2ee.v1`. Real group E2EE flows are owned by the Rust `anp-mls` one-shot binary, which keeps OpenMLS private state in its local SQLite state directory and receives plaintext through stdin, not argv. PR-B1 safe leave uses hidden/test-only `group.e2ee.leave_request` control-plane objects plus owner/admin processing through an epoch-advancing remove commit; it does not make same-member local-terminal leave a service success. Contract-test artifacts are still available only when explicitly enabled for compatibility tests, and those deterministic artifacts must be marked `non_cryptographic=true` and `artifact_mode=contract-test`. + +Key APIs: + +- `ExecProvider.Call` +- `GroupKeyPackage` +- `GroupCipherObject` +- `GroupLeaveRequestObject` +- `GroupLeaveRequestProcessObject` +- `GroupStateRef` +- `ApplicationPlaintext` + ## Compatibility Notes - Pure Go implementation only diff --git a/golang/group_e2ee/exec_provider.go b/golang/group_e2ee/exec_provider.go new file mode 100644 index 0000000..8aeacf0 --- /dev/null +++ b/golang/group_e2ee/exec_provider.go @@ -0,0 +1,72 @@ +package groupe2ee + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "time" +) + +type CommandRunner interface { + Run(ctx context.Context, binary string, args []string, stdin []byte) (stdout []byte, stderr []byte, err error) +} + +type OSCommandRunner struct{} + +func (OSCommandRunner) Run(ctx context.Context, binary string, args []string, stdin []byte) ([]byte, []byte, error) { + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Stdin = bytes.NewReader(stdin) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.Bytes(), stderr.Bytes(), err +} + +type ExecProvider struct { + BinaryPath string + DataDir string + Timeout time.Duration + Runner CommandRunner +} + +func (p ExecProvider) Call(ctx context.Context, domain string, action string, req Request) (*Response, error) { + if p.BinaryPath == "" { + p.BinaryPath = "anp-mls" + } + if p.Timeout <= 0 { + p.Timeout = 15 * time.Second + } + runner := p.Runner + if runner == nil { + runner = OSCommandRunner{} + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, p.Timeout) + defer cancel() + args := []string{domain, action, "--json-in", "-"} + if p.DataDir != "" { + args = append(args, "--data-dir", p.DataDir) + } + stdout, stderr, err := runner.Run(ctx, p.BinaryPath, args, body) + if err != nil && len(stdout) == 0 { + return nil, fmt.Errorf("anp-mls exec failed: %w: %s", err, string(stderr)) + } + var resp Response + if decodeErr := json.Unmarshal(stdout, &resp); decodeErr != nil { + return nil, fmt.Errorf("decode anp-mls response: %w: stderr=%s", decodeErr, string(stderr)) + } + if !resp.OK { + if resp.Error != nil { + return &resp, fmt.Errorf("anp-mls error %s: %s", resp.Error.Code, resp.Error.Message) + } + return &resp, fmt.Errorf("anp-mls returned ok=false") + } + return &resp, nil +} diff --git a/golang/group_e2ee/exec_provider_test.go b/golang/group_e2ee/exec_provider_test.go new file mode 100644 index 0000000..5858530 --- /dev/null +++ b/golang/group_e2ee/exec_provider_test.go @@ -0,0 +1,48 @@ +package groupe2ee + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +type recordingRunner struct { + args []string + stdin []byte +} + +func (r *recordingRunner) Run(_ context.Context, _ string, args []string, stdin []byte) ([]byte, []byte, error) { + r.args = append([]string(nil), args...) + r.stdin = append([]byte(nil), stdin...) + return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-1","result":{"non_cryptographic":true}}`), nil, nil +} + +func TestExecProviderPassesPlaintextOnStdinNotArgv(t *testing.T) { + runner := &recordingRunner{} + provider := ExecProvider{BinaryPath: "anp-mls", Runner: runner} + _, err := provider.Call(context.Background(), "message", "encrypt", Request{ + APIVersion: "anp-mls/v1", + RequestID: "req-1", + ContractTestEnabled: true, + Params: map[string]any{ + "application_plaintext": map[string]any{"text": "super secret"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(runner.args, " "), "super secret") { + t.Fatalf("plaintext leaked into argv: %#v", runner.args) + } + if !strings.Contains(string(runner.stdin), "super secret") { + t.Fatalf("plaintext request was not sent via stdin: %s", string(runner.stdin)) + } + var req Request + if err := json.Unmarshal(runner.stdin, &req); err != nil { + t.Fatal(err) + } + if !req.ContractTestEnabled { + t.Fatal("contract test flag not preserved") + } +} diff --git a/golang/group_e2ee/models.go b/golang/group_e2ee/models.go new file mode 100644 index 0000000..b8c3c5e --- /dev/null +++ b/golang/group_e2ee/models.go @@ -0,0 +1,149 @@ +package groupe2ee + +const ( + Profile = "anp.group.e2ee.v1" + SecurityProfile = "group-e2ee" + TransportSecurityProfile = "transport-protected" + ContractArtifactMode = "contract-test" + MTISuite = "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519" + MethodLeaveRequest = "group.e2ee.leave_request" + MethodLeaveRequestProcess = "group.e2ee.process_leave_request" + MethodRecoverMember = "group.e2ee.recover_member" +) + +type TargetRef struct { + Kind string `json:"kind"` + DID string `json:"did"` +} + +type GroupStateRef struct { + GroupDID string `json:"group_did"` + GroupStateVersion string `json:"group_state_version"` + PolicyHash string `json:"policy_hash,omitempty"` +} + +type EnvelopeMeta struct { + ANPVersion string `json:"anp_version"` + Profile string `json:"profile"` + SecurityProfile string `json:"security_profile"` + SenderDID string `json:"sender_did,omitempty"` + Target *TargetRef `json:"target,omitempty"` + OperationID string `json:"operation_id,omitempty"` + MessageID string `json:"message_id,omitempty"` + ContentType string `json:"content_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +type GroupKeyPackage struct { + KeyPackageID string `json:"key_package_id"` + OwnerDID string `json:"owner_did"` + DeviceID string `json:"device_id,omitempty"` + Purpose string `json:"purpose,omitempty"` + GroupDID string `json:"group_did,omitempty"` + Suite string `json:"suite"` + MLSKeyPackageB64U string `json:"mls_key_package_b64u"` + DIDWBABinding map[string]any `json:"did_wba_binding"` + ExpiresAt string `json:"expires_at,omitempty"` + NonCryptographic bool `json:"non_cryptographic,omitempty"` + ArtifactMode string `json:"artifact_mode,omitempty"` +} + +type RecoverMemberTarget struct { + AgentDID string `json:"agent_did"` + DeviceID string `json:"device_id"` +} + +type RecoverMemberRequestObject struct { + OperationID string `json:"operation_id"` + GroupDID string `json:"group_did"` + ActorDID string `json:"actor_did"` + Target RecoverMemberTarget `json:"target"` + GroupStateRef GroupStateRef `json:"group_state_ref"` + RecoveryKeyPackageID string `json:"recovery_key_package_id,omitempty"` + GroupKeyPackage *GroupKeyPackage `json:"group_key_package,omitempty"` + CommitB64U string `json:"commit_b64u"` + WelcomeB64U string `json:"welcome_b64u"` + RatchetTreeB64U string `json:"ratchet_tree_b64u,omitempty"` + Epoch string `json:"epoch"` + EpochAuthenticator string `json:"epoch_authenticator,omitempty"` + NonCryptographic bool `json:"non_cryptographic,omitempty"` + ArtifactMode string `json:"artifact_mode,omitempty"` +} + +type RecoverMemberFinalizeRequestObject struct { + OperationID string `json:"operation_id,omitempty"` + PendingCommitID string `json:"pending_commit_id"` +} + +type RecoverMemberAbortRequestObject struct { + OperationID string `json:"operation_id,omitempty"` + PendingCommitID string `json:"pending_commit_id"` +} + +type GroupCipherObject struct { + CryptoGroupIDB64U string `json:"crypto_group_id_b64u"` + Epoch string `json:"epoch"` + PrivateMessageB64U string `json:"private_message_b64u"` + GroupStateRef GroupStateRef `json:"group_state_ref"` + EpochAuthenticator string `json:"epoch_authenticator,omitempty"` + NonCryptographic bool `json:"non_cryptographic,omitempty"` + ArtifactMode string `json:"artifact_mode,omitempty"` +} + +type GroupLeaveRequestObject struct { + LeaveRequestID string `json:"leave_request_id"` + GroupDID string `json:"group_did"` + RequesterDID string `json:"requester_did"` + GroupStateRef GroupStateRef `json:"group_state_ref"` + ReasonText string `json:"reason_text,omitempty"` + RequestedAt string `json:"requested_at,omitempty"` + NonCryptographic bool `json:"non_cryptographic,omitempty"` + ArtifactMode string `json:"artifact_mode,omitempty"` +} + +type GroupLeaveRequestProcessObject struct { + LeaveRequestID string `json:"leave_request_id"` + GroupDID string `json:"group_did"` + RequesterDID string `json:"requester_did"` + ProcessorDID string `json:"processor_did"` + GroupStateRef GroupStateRef `json:"group_state_ref"` + CryptoGroupIDB64U string `json:"crypto_group_id_b64u"` + Epoch string `json:"epoch"` + CommitB64U string `json:"commit_b64u"` + EpochAuthenticator string `json:"epoch_authenticator,omitempty"` + ReasonText string `json:"reason_text,omitempty"` + NonCryptographic bool `json:"non_cryptographic,omitempty"` + ArtifactMode string `json:"artifact_mode,omitempty"` +} + +type ApplicationPlaintext struct { + ApplicationContentType string `json:"application_content_type"` + ThreadID string `json:"thread_id,omitempty"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + Annotations map[string]any `json:"annotations,omitempty"` + Text string `json:"text,omitempty"` + Payload map[string]any `json:"payload,omitempty"` + PayloadB64U string `json:"payload_b64u,omitempty"` +} + +type Request struct { + APIVersion string `json:"api_version"` + RequestID string `json:"request_id"` + AgentDID string `json:"agent_did,omitempty"` + DeviceID string `json:"device_id,omitempty"` + ContractTestEnabled bool `json:"contract_test_enabled,omitempty"` + Params map[string]any `json:"params"` +} + +type Response struct { + OK bool `json:"ok"` + APIVersion string `json:"api_version"` + RequestID string `json:"request_id"` + Result map[string]any `json:"result,omitempty"` + Error *ErrorObject `json:"error,omitempty"` +} + +type ErrorObject struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/golang/group_e2ee/models_test.go b/golang/group_e2ee/models_test.go new file mode 100644 index 0000000..cb9cc41 --- /dev/null +++ b/golang/group_e2ee/models_test.go @@ -0,0 +1,144 @@ +package groupe2ee + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestLeaveRequestWireModelsKeepControlPlaneOpaque(t *testing.T) { + request := GroupLeaveRequestObject{ + LeaveRequestID: "leave-req-1", + GroupDID: "did:wba:example.com:groups:demo:e1", + RequesterDID: "did:wba:example.com:users:bob:e1", + GroupStateRef: GroupStateRef{ + GroupDID: "did:wba:example.com:groups:demo:e1", + GroupStateVersion: "7", + }, + ReasonText: "leaving this workspace", + } + encoded, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + text := string(encoded) + for _, token := range []string{"leave_request_id", "requester_did", "group_state_ref"} { + if !strings.Contains(text, token) { + t.Fatalf("leave request JSON missing %s: %s", token, text) + } + } + for _, forbidden := range []string{"commit_b64u", "private", "plaintext"} { + if strings.Contains(text, forbidden) { + t.Fatalf("leave request JSON leaked lifecycle/private field %s: %s", forbidden, text) + } + } +} + +func TestLeaveRequestProcessWireModelCarriesEpochAdvancingRemoveCommit(t *testing.T) { + process := GroupLeaveRequestProcessObject{ + LeaveRequestID: "leave-req-1", + GroupDID: "did:wba:example.com:groups:demo:e1", + RequesterDID: "did:wba:example.com:users:bob:e1", + ProcessorDID: "did:wba:example.com:users:alice:e1", + CryptoGroupIDB64U: "Y3J5cHRv", + Epoch: "8", + CommitB64U: "Y29tbWl0", + GroupStateRef: GroupStateRef{ + GroupDID: "did:wba:example.com:groups:demo:e1", + GroupStateVersion: "7", + }, + } + encoded, err := json.Marshal(process) + if err != nil { + t.Fatal(err) + } + text := string(encoded) + for _, token := range []string{"leave_request_id", "processor_did", "crypto_group_id_b64u", "epoch", "commit_b64u"} { + if !strings.Contains(text, token) { + t.Fatalf("leave request process JSON missing %s: %s", token, text) + } + } + if MethodLeaveRequest != "group.e2ee.leave_request" { + t.Fatalf("unexpected leave request method: %s", MethodLeaveRequest) + } + if MethodLeaveRequestProcess != "group.e2ee.process_leave_request" { + t.Fatalf("unexpected leave request process method: %s", MethodLeaveRequestProcess) + } + if TransportSecurityProfile != "transport-protected" { + t.Fatalf("unexpected leave request security profile: %s", TransportSecurityProfile) + } +} + +func TestRecoverMemberWireModelRequiresRecoveryBoundKeyPackage(t *testing.T) { + recovery := RecoverMemberRequestObject{ + OperationID: "op-recover-bob", + GroupDID: "did:wba:example.com:groups:demo:e1", + ActorDID: "did:wba:example.com:users:alice:e1", + Target: RecoverMemberTarget{ + AgentDID: "did:wba:example.com:users:bob:e1", + DeviceID: "phone", + }, + GroupStateRef: GroupStateRef{ + GroupDID: "did:wba:example.com:groups:demo:e1", + GroupStateVersion: "7", + }, + GroupKeyPackage: &GroupKeyPackage{ + KeyPackageID: "kp-recovery-bob", + OwnerDID: "did:wba:example.com:users:bob:e1", + DeviceID: "phone", + Purpose: "recovery", + GroupDID: "did:wba:example.com:groups:demo:e1", + Suite: MTISuite, + MLSKeyPackageB64U: "bWxzLWtleS1wYWNrYWdl", + DIDWBABinding: map[string]any{ + "agent_did": "did:wba:example.com:users:bob:e1", + "device_id": "phone", + }, + }, + CommitB64U: "Y29tbWl0", + WelcomeB64U: "d2VsY29tZQ", + RatchetTreeB64U: "cmF0Y2hldA", + Epoch: "8", + } + encoded, err := json.Marshal(recovery) + if err != nil { + t.Fatal(err) + } + text := string(encoded) + for _, token := range []string{"operation_id", "target", "purpose", "recovery", "group_key_package", "welcome_b64u"} { + if !strings.Contains(text, token) { + t.Fatalf("recover member JSON missing %s: %s", token, text) + } + } + if MethodRecoverMember != "group.e2ee.recover_member" { + t.Fatalf("unexpected recover member method: %s", MethodRecoverMember) + } + for _, forbidden := range []string{"plaintext", "private"} { + if strings.Contains(text, forbidden) { + t.Fatalf("recover member JSON leaked private field %s: %s", forbidden, text) + } + } +} + +func TestRecoverMemberFinalizeAbortWireModelsCarryPendingCommitID(t *testing.T) { + finalize := RecoverMemberFinalizeRequestObject{ + OperationID: "op-finalize", + PendingCommitID: "pc-recover", + } + abort := RecoverMemberAbortRequestObject{ + OperationID: "op-abort", + PendingCommitID: "pc-recover", + } + for name, value := range map[string]any{"finalize": finalize, "abort": abort} { + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("%s marshal: %v", name, err) + } + text := string(encoded) + for _, token := range []string{"operation_id", "pending_commit_id", "pc-recover"} { + if !strings.Contains(text, token) { + t.Fatalf("%s JSON missing %s: %s", name, token, text) + } + } + } +} diff --git a/golang/proof/proof_test.go b/golang/proof/proof_test.go index 8123526..a6472bb 100644 --- a/golang/proof/proof_test.go +++ b/golang/proof/proof_test.go @@ -1,6 +1,9 @@ package proof_test import ( + "encoding/json" + "os" + "path/filepath" "testing" anp "github.com/agent-network-protocol/anp/golang" @@ -107,6 +110,49 @@ func TestDidWbaBindingProof(t *testing.T) { } } +func TestDidWbaBindingGoldenVector(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "group_e2ee", "did_wba_binding_golden.json")) + if err != nil { + t.Fatalf("ReadFile golden vector failed: %v", err) + } + var vector map[string]any + if err := json.Unmarshal(raw, &vector); err != nil { + t.Fatalf("Unmarshal golden vector failed: %v", err) + } + binding, ok := vector["did_wba_binding"].(map[string]any) + if !ok { + t.Fatalf("golden vector did_wba_binding has unexpected shape: %#v", vector["did_wba_binding"]) + } + didDocument, ok := vector["did_document"].(map[string]any) + if !ok { + t.Fatalf("golden vector did_document has unexpected shape: %#v", vector["did_document"]) + } + if err := proof.VerifyDidWbaBinding(binding, didDocument, proof.DidWbaBindingVerificationOptions{ + Now: testStringValue(vector["now"]), + ExpectedLeafSignatureKey: testStringValue(vector["leaf_signature_key_b64u"]), + ExpectedCredentialIdentity: testStringValue(vector["issuer_did"]), + }); err != nil { + t.Fatalf("VerifyDidWbaBinding golden vector failed: %v", err) + } + + tampered := cloneMap(binding) + tampered["leaf_signature_key_b64u"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + if err := proof.VerifyDidWbaBinding(tampered, didDocument, proof.DidWbaBindingVerificationOptions{ + Now: testStringValue(vector["now"]), + ExpectedCredentialIdentity: testStringValue(vector["issuer_did"]), + }); err == nil { + t.Fatalf("tampered golden vector should fail verification") + } +} + +func cloneMap(input map[string]any) map[string]any { + output := make(map[string]any, len(input)) + for key, value := range input { + output[key] = value + } + return output +} + func testStringValue(value any) string { result, _ := value.(string) return result diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 07a0324..938a439 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -32,6 +32,13 @@ rand = "0.8" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } ring = "0.17" +fs2 = "0.4" +openmls = "0.8.1" +openmls_basic_credential = "0.5.0" +openmls_rust_crypto = "0.5.1" +openmls_sqlite_storage = "0.2.0" +openmls_traits = "0.5.0" +rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" @@ -46,3 +53,7 @@ serde_json_canonicalizer = "0.3.2" [dev-dependencies] tempfile = "3" wiremock = "0.6" + +[[bin]] +name = "anp-mls" +path = "src/bin/anp-mls.rs" diff --git a/rust/README.md b/rust/README.md index bfe0be2..bf17cf9 100644 --- a/rust/README.md +++ b/rust/README.md @@ -20,10 +20,35 @@ cargo add anp - Appendix-B object proof helpers for `group_receipt`, `prekey_bundle`, and `did_wba_binding` - RFC 9421 origin proof helpers for ANP request objects - WNS models, validation, and resolver helpers +- `anp-mls` one-shot group E2EE helper for ANP-P6 real OpenMLS key-package, group create/add, + MLS-backed member removal, local leave terminal-state handling, commit notice processing, + pending commit finalize/abort, welcome processing, message encrypt/decrypt, and local status + operations backed by a `state.db` SQLite store plus `state.lock` mutation lock. Contract-test artifacts remain + available only when explicitly enabled by request or `ANP_MLS_CONTRACT_TEST=1`. Packaging + and doctor integrations can probe compatibility with `anp-mls system version --json-in -`. ## Compatibility Notes - `create_did_wba_document_with_key_binding` is deprecated. Use `create_did_wba_document` with `DidDocumentOptions::with_profile(DidProfile::K1)` when you need a `k1_` DID. +- Real `anp-mls` mode requires `--data-dir DIR`; MLS private material, KeyPackage state, + group bindings, operation idempotency records, and OpenMLS persistence are local to + `DIR/state.db` and are not emitted to service-facing P6 payloads. +- `anp-mls system version --json-in -` does not require `--data-dir`; it returns + `api_version`, `binary_name`, `binary_version`/`build_version`, and `supported_commands`. +- Message encrypt/decrypt rejects incoming group-state or cipher claims whose + `crypto_group_id_b64u`/`openmls_group_id_b64u` or epoch does not match the local + SQLite group binding before invoking OpenMLS. +- Decrypted plaintext is returned to stdout for the active request only and is redacted from + the local operation idempotency table. +- `group remove-member` creates a durable OpenMLS pending commit and returns opaque commit, + ratchet-tree/group-info-compatible public artifacts, from/to epochs, and `pending_commit_id`; + local epoch remains unchanged until `group commit-finalize`, while `group commit-abort` + clears the pending commit after deterministic service rejection. +- `group leave` records a local terminal pending artifact because OpenMLS 0.8 rejects + same-member self-remove commits; finalizing it marks the local binding `left` without + advancing the local epoch. PR-B1 service/CLI integrations should use the hidden + `group.e2ee.leave_request` control plane and process it through an authorized + epoch-advancing remove commit for remaining members. ## Repository diff --git a/rust/src/bin/anp-mls.rs b/rust/src/bin/anp-mls.rs new file mode 100644 index 0000000..8d1798c --- /dev/null +++ b/rust/src/bin/anp-mls.rs @@ -0,0 +1,2954 @@ +//! [INPUT] One-shot `anp-mls` JSON requests on stdin plus CLI domain/action and optional `--data-dir`; `system version` is the no-state compatibility probe. +//! [OUTPUT] A single JSON response on stdout; real mode persists OpenMLS state, pending membership/recovery commits, local active/inactive bindings, and contract-test mode emits explicit non-cryptographic fixtures. +//! [POS] Boundary binary between Go/product clients and Rust MLS state; keep private MLS material local to `--data-dir` while exposing opaque P6 remove/leave/recovery/commit artifacts. + +use anp::group_e2ee::{ + build_send_aad, deterministic_contract_artifact, CONTRACT_ARTIFACT_MODE, MTI_SUITE, + SECURITY_PROFILE, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use fs2::FileExt; +use openmls::prelude::{ + tls_codec::{Deserialize as TlsDeserialize, Serialize as TlsSerialize}, + *, +}; +use openmls_basic_credential::SignatureKeyPair; +use openmls_rust_crypto::RustCrypto; +use openmls_sqlite_storage::{Connection as MlsConnection, SqliteStorageProvider}; +use openmls_traits::OpenMlsProvider; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const API_VERSION: &str = "anp-mls/v1"; +const DEVICE_ID_DEFAULT: &str = "default"; +const BINARY_NAME: &str = "anp-mls"; +const GROUP_CIPHER_CONTENT_TYPE: &str = "application/anp-group-cipher+json"; +const SUPPORTED_COMMANDS: &[&str] = &[ + "system version", + "key-package generate", + "group create", + "group add-member", + "group recover-member-prepare", + "group recover-member-finalize", + "group recover-member-abort", + "group remove-member", + "group leave", + "group commit-finalize", + "group commit-abort", + "welcome process", + "commit process", + "notice process", + "message encrypt", + "message decrypt", + "group restore", + "group status", +]; + +fn main() { + let code = match run() { + Ok(value) => { + println!("{}", serde_json::to_string(&value).expect("json response")); + 0 + } + Err(value) => { + println!("{}", serde_json::to_string(&value).expect("json error")); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let args: Vec = std::env::args().skip(1).collect(); + let invocation = parse_invocation(&args)?; + if !invocation.json_in { + return Err(error( + "invalid_args", + "usage: anp-mls --json-in - [--data-dir DIR]", + None, + )); + } + let command = format!("{} {}", invocation.domain, invocation.action); + let mut stdin = String::new(); + io::stdin() + .read_to_string(&mut stdin) + .map_err(|e| error("stdin_read_failed", &e.to_string(), None))?; + let req: Value = + serde_json::from_str(&stdin).map_err(|e| error("invalid_json", &e.to_string(), None))?; + let request_id = req + .get("request_id") + .and_then(Value::as_str) + .unwrap_or("req-unknown") + .to_owned(); + let contract_enabled = req + .get("contract_test_enabled") + .and_then(Value::as_bool) + .unwrap_or(false) + || std::env::var("ANP_MLS_CONTRACT_TEST").ok().as_deref() == Some("1"); + let params = request_params(&req); + if command == "system version" { + return Ok(json!({ + "ok": true, + "api_version": API_VERSION, + "request_id": request_id, + "result": system_version(), + })); + } + if contract_enabled { + return run_contract_mode(&command, &request_id, ¶ms, invocation.data_dir.as_ref()); + } + run_real_mode( + &command, + &request_id, + &req, + ¶ms, + invocation.data_dir.as_ref(), + ) +} + +struct Invocation { + domain: String, + action: String, + json_in: bool, + data_dir: Option, +} + +fn parse_invocation(args: &[String]) -> Result { + let mut positionals = Vec::new(); + let mut json_in = false; + let mut data_dir = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--json-in" => { + json_in = true; + if args.get(index + 1).map(String::as_str) == Some("-") { + index += 2; + } else { + index += 1; + } + } + "--data-dir" => { + let Some(value) = args.get(index + 1) else { + return Err(error("invalid_args", "--data-dir requires a value", None)); + }; + data_dir = Some(PathBuf::from(value)); + index += 2; + } + other if other.starts_with("--") => { + return Err(error( + "invalid_args", + &format!("unsupported option: {other}"), + None, + )); + } + other => { + positionals.push(other.to_owned()); + index += 1; + } + } + } + if positionals.len() < 2 { + return Err(error( + "invalid_args", + "usage: anp-mls --json-in - [--data-dir DIR]", + None, + )); + } + Ok(Invocation { + domain: positionals[0].clone(), + action: positionals[1].clone(), + json_in, + data_dir, + }) +} + +fn request_params(req: &Value) -> Value { + let mut params = req.get("params").cloned().unwrap_or_else(|| json!({})); + let Some(object) = params.as_object_mut() else { + return json!({}); + }; + for (top_level, param_key) in [ + ("agent_did", "agent_did"), + ("device_id", "device_id"), + ("operation_id", "operation_id"), + ] { + if !object.contains_key(param_key) { + if let Some(value) = req.get(top_level).cloned() { + object.insert(param_key.to_owned(), value); + } + } + } + if !object.contains_key("owner_did") { + if let Some(value) = object.get("agent_did").cloned() { + object.insert("owner_did".to_owned(), value); + } + } + params +} + +fn run_contract_mode( + command: &str, + request_id: &str, + params: &Value, + data_dir: Option<&PathBuf>, +) -> Result { + let result = match command { + "key-package generate" => contract_key_package(params)?, + "group create" => contract_group_create(params)?, + "group add-member" => contract_group_add_member(params)?, + "group recover-member-prepare" => contract_group_recover_member_prepare(params)?, + "group recover-member-finalize" => contract_group_commit_finalize(params)?, + "group recover-member-abort" => contract_group_commit_abort(params)?, + "group remove-member" => contract_group_remove_member(params)?, + "group leave" => contract_group_leave(params)?, + "group commit-finalize" => contract_group_commit_finalize(params)?, + "group commit-abort" => contract_group_commit_abort(params)?, + "welcome process" => contract_welcome_process(params)?, + "commit process" | "notice process" => contract_commit_process(params)?, + "message encrypt" => contract_message_encrypt(params)?, + "message decrypt" => contract_message_decrypt(params)?, + "group restore" | "group status" => contract_group_status(params, data_dir)?, + _ => { + return Err(error( + "unsupported_command", + &format!("unsupported command: {command}"), + Some(request_id.to_owned()), + )) + } + }; + if let Some(data_dir) = data_dir { + append_contract_operation_log(data_dir, request_id, command)?; + } + Ok(json!({ + "ok": true, + "api_version": API_VERSION, + "request_id": request_id, + "result": result, + })) +} + +fn run_real_mode( + command: &str, + request_id: &str, + req: &Value, + params: &Value, + data_dir: Option<&PathBuf>, +) -> Result { + let data_dir = data_dir.ok_or_else(|| { + error( + "missing_data_dir", + "real anp-mls mode requires --data-dir for SQLite state", + Some(request_id.to_owned()), + ) + })?; + fs::create_dir_all(data_dir).map_err(|e| { + error( + "state_write_failed", + &format!("create data dir: {e}"), + Some(request_id.to_owned()), + ) + })?; + let _lock = StateLock::try_acquire(data_dir, request_id)?; + let db_path = data_dir.join("state.db"); + let app_conn = + Connection::open(&db_path).map_err(|e| sqlite_error("state_open_failed", e, request_id))?; + init_app_schema(&app_conn) + .map_err(|e| sqlite_error("state_migration_failed", e, request_id))?; + let operation_id = req + .get("operation_id") + .or_else(|| params.get("operation_id")) + .and_then(Value::as_str) + .unwrap_or(request_id); + let input_digest = digest_json(&json!({"command": command, "params": params})); + if let Some((saved_digest, saved_response)) = lookup_operation(&app_conn, operation_id) + .map_err(|e| sqlite_error("state_read_failed", e, request_id))? + { + if saved_digest == input_digest { + let mut response: Value = serde_json::from_str(&saved_response).map_err(|e| { + error( + "state_read_failed", + &format!("decode saved operation response: {e}"), + Some(request_id.to_owned()), + ) + })?; + response["request_id"] = json!(request_id); + return Ok(response); + } + return Err(error( + "operation_conflict", + "operation_id was already used with different input", + Some(request_id.to_owned()), + )); + } + + let result = { + let mut provider = sqlite_mls_provider(&db_path, request_id)?; + match command { + "key-package generate" => { + real_key_package(&mut provider, &app_conn, params, request_id)? + } + "group create" => real_group_create(&mut provider, &app_conn, params, request_id)?, + "group add-member" => { + real_group_add_member(&mut provider, &app_conn, params, request_id)? + } + "group recover-member-prepare" => real_group_recover_member_prepare( + &mut provider, + &app_conn, + params, + operation_id, + request_id, + )?, + "group recover-member-finalize" => { + real_group_commit_finalize(&mut provider, &app_conn, params, request_id)? + } + "group recover-member-abort" => { + real_group_commit_abort(&mut provider, &app_conn, params, request_id)? + } + "group remove-member" => real_group_remove_member( + &mut provider, + &app_conn, + params, + operation_id, + request_id, + )?, + "group leave" => { + real_group_leave(&mut provider, &app_conn, params, operation_id, request_id)? + } + "group commit-finalize" => { + real_group_commit_finalize(&mut provider, &app_conn, params, request_id)? + } + "group commit-abort" => { + real_group_commit_abort(&mut provider, &app_conn, params, request_id)? + } + "welcome process" => { + real_welcome_process(&mut provider, &app_conn, params, request_id)? + } + "commit process" | "notice process" => { + real_commit_process(&mut provider, &app_conn, params, request_id)? + } + "message encrypt" => { + real_message_encrypt(&mut provider, &app_conn, params, request_id)? + } + "message decrypt" => { + real_message_decrypt(&mut provider, &app_conn, params, request_id)? + } + "group restore" | "group status" => { + real_group_status(&mut provider, &app_conn, params, data_dir, request_id)? + } + _ => { + return Err(error( + "unsupported_command", + &format!("unsupported command: {command}"), + Some(request_id.to_owned()), + )) + } + } + }; + let response = json!({ + "ok": true, + "api_version": API_VERSION, + "request_id": request_id, + "result": result, + }); + let recorded_response = response_for_operation_log(command, &response); + record_operation( + &app_conn, + operation_id, + command, + &input_digest, + &recorded_response, + ) + .map_err(|e| sqlite_error("state_write_failed", e, request_id))?; + Ok(response) +} + +fn system_version() -> Value { + json!({ + "api_version": API_VERSION, + "binary_name": BINARY_NAME, + "binary_version": env!("CARGO_PKG_VERSION"), + "build_version": env!("CARGO_PKG_VERSION"), + "supported_commands": SUPPORTED_COMMANDS, + }) +} + +fn response_for_operation_log(command: &str, response: &Value) -> Value { + let mut stored = response.clone(); + if command == "message decrypt" { + if let Some(result) = stored.get_mut("result").and_then(Value::as_object_mut) { + result.remove("application_plaintext"); + result.insert( + "plaintext_redacted".to_owned(), + json!({"redacted": true, "reason": "plaintext is never persisted in operations"}), + ); + } + } + stored +} + +struct StateLock { + file: File, +} + +impl StateLock { + fn try_acquire(data_dir: &Path, request_id: &str) -> Result { + let lock_path = data_dir.join("state.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|e| { + error( + "state_lock_failed", + &format!("open state lock: {e}"), + Some(request_id.to_owned()), + ) + })?; + file.try_lock_exclusive().map_err(|e| { + error( + "state_locked", + &format!("state is locked by another anp-mls operation: {e}"), + Some(request_id.to_owned()), + ) + })?; + Ok(Self { file }) + } +} + +impl Drop for StateLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} + +#[derive(Default)] +struct JsonCodec; + +impl openmls_sqlite_storage::Codec for JsonCodec { + type Error = serde_json::Error; + + fn to_vec(value: &T) -> Result, Self::Error> { + serde_json::to_vec(value) + } + + fn from_slice(slice: &[u8]) -> Result { + serde_json::from_slice(slice) + } +} + +struct SqliteMlsProvider { + crypto: RustCrypto, + storage: SqliteStorageProvider, +} + +impl OpenMlsProvider for SqliteMlsProvider { + type CryptoProvider = RustCrypto; + type RandProvider = RustCrypto; + type StorageProvider = SqliteStorageProvider; + + fn storage(&self) -> &Self::StorageProvider { + &self.storage + } + + fn crypto(&self) -> &Self::CryptoProvider { + &self.crypto + } + + fn rand(&self) -> &Self::RandProvider { + &self.crypto + } +} + +fn sqlite_mls_provider(db_path: &Path, request_id: &str) -> Result { + let connection = MlsConnection::open(db_path) + .map_err(|e| sqlite_error("state_open_failed", e, request_id))?; + let mut storage = SqliteStorageProvider::::new(connection); + storage.run_migrations().map_err(|e| { + error( + "state_migration_failed", + &format!("OpenMLS SQLite migrations failed: {e}"), + Some(request_id.to_owned()), + ) + })?; + Ok(SqliteMlsProvider { + crypto: RustCrypto::default(), + storage, + }) +} + +fn init_app_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT OR IGNORE INTO schema_migrations(version) VALUES (1); + CREATE TABLE IF NOT EXISTS operations ( + operation_id TEXT PRIMARY KEY, + command TEXT NOT NULL, + input_digest TEXT NOT NULL, + response_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS agents ( + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + signature_public_key BLOB NOT NULL, + signature_scheme TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(agent_did, device_id) + ); + CREATE TABLE IF NOT EXISTS key_packages ( + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + key_package_id TEXT PRIMARY KEY, + public_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + consumed_at TEXT + ); + CREATE TABLE IF NOT EXISTS group_bindings ( + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + group_did TEXT NOT NULL, + crypto_group_id_b64u TEXT NOT NULL, + openmls_group_id_b64u TEXT NOT NULL, + epoch INTEGER NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(agent_did, device_id, group_did) + ); + CREATE TABLE IF NOT EXISTS pending_commits ( + pending_commit_id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + command TEXT NOT NULL, + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + group_did TEXT NOT NULL, + crypto_group_id_b64u TEXT NOT NULL, + subject_did TEXT NOT NULL, + subject_status TEXT NOT NULL, + from_epoch INTEGER NOT NULL, + to_epoch INTEGER NOT NULL, + commit_b64u TEXT NOT NULL, + ratchet_tree_b64u TEXT, + group_info_b64u TEXT, + epoch_authenticator_b64u TEXT, + status TEXT NOT NULL, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_commits_operation_id + ON pending_commits(operation_id);", + ) +} + +fn lookup_operation( + conn: &Connection, + operation_id: &str, +) -> rusqlite::Result> { + conn.query_row( + "SELECT input_digest, response_json FROM operations WHERE operation_id = ?1 AND status = 'completed'", + params![operation_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() +} + +fn record_operation( + conn: &Connection, + operation_id: &str, + command: &str, + input_digest: &str, + response: &Value, +) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO operations(operation_id, command, input_digest, response_json, status, updated_at) + VALUES (?1, ?2, ?3, ?4, 'completed', CURRENT_TIMESTAMP)", + params![operation_id, command, input_digest, response.to_string()], + )?; + Ok(()) +} + +fn real_key_package( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let owner = agent_did(params)?; + let device_id = device_id(params); + let key_package_id = params + .get("key_package_id") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "kp-{}", + short_digest( + &json!({"owner": owner, "device_id": device_id, "request_id": request_id}) + ) + ) + }); + let (credential, signer) = ensure_agent(provider, conn, owner, device_id, request_id)?; + let key_package_bundle = KeyPackage::builder() + .key_package_extensions(Extensions::default()) + .build(ciphersuite(), provider, &signer, credential) + .map_err(|e| mls_error("key_package_failed", e, request_id))?; + let public_bytes = key_package_bundle + .key_package() + .tls_serialize_detached() + .map_err(|e| mls_error("key_package_encode_failed", e, request_id))?; + let public_b64u = encode_b64u(&public_bytes); + let purpose = params + .get("purpose") + .and_then(Value::as_str) + .or_else(|| { + params + .get("recovery") + .and_then(Value::as_bool) + .filter(|enabled| *enabled) + .map(|_| "recovery") + }) + .unwrap_or("normal"); + let public_json = json!({ + "key_package_id": key_package_id, + "owner_did": owner, + "device_id": device_id, + "purpose": purpose, + "group_did": params.get("group_did").cloned(), + "suite": MTI_SUITE, + "mls_key_package_b64u": public_b64u, + "did_wba_binding": did_wba_binding(owner, device_id, &signer), + }); + conn.execute( + "INSERT OR REPLACE INTO key_packages(agent_did, device_id, key_package_id, public_json, status) + VALUES (?1, ?2, ?3, ?4, 'published')", + params![owner, device_id, key_package_id, public_json.to_string()], + ) + .map_err(|e| sqlite_error("state_write_failed", e, request_id))?; + Ok(json!({ + "group_key_package": public_json, + "private_ref": format!("sqlite://openmls/key_packages/{key_package_id}"), + })) +} + +fn real_group_create( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let group_did = required(params, "group_did")?; + let creator = agent_did(params)?; + let device_id = device_id(params); + let (credential, signer) = ensure_agent(provider, conn, creator, device_id, request_id)?; + let openmls_group_id = GroupId::from_slice(group_did.as_bytes()); + let config = group_create_config(); + let group = MlsGroup::new_with_group_id( + provider, + &signer, + &config, + openmls_group_id.clone(), + credential, + ) + .map_err(|e| mls_error("group_create_failed", e, request_id))?; + upsert_binding( + conn, + creator, + device_id, + group_did, + &openmls_group_id, + group.epoch().as_u64(), + "creator", + request_id, + )?; + Ok(json!({ + "group_did": group_did, + "crypto_group_id_b64u": encode_b64u(openmls_group_id.as_slice()), + "openmls_group_id_b64u": encode_b64u(openmls_group_id.as_slice()), + "epoch": group.epoch().as_u64().to_string(), + "epoch_authenticator": encode_b64u(group.epoch_authenticator().as_slice()), + "suite": MTI_SUITE, + "group_state_ref": {"group_did": group_did, "group_state_version": group.epoch().as_u64().to_string()}, + })) +} + +fn real_group_add_member( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let group_did = required(params, "group_did")?; + let member_did = required(params, "member_did")?; + let actor = params + .get("actor_did") + .or_else(|| params.get("owner_did")) + .or_else(|| params.get("agent_did")) + .and_then(Value::as_str) + .ok_or_else(|| error("missing_field", "actor_did or owner_did is required", None))?; + let device_id = device_id(params); + let binding = binding(conn, actor, device_id, group_did, request_id)?; + let mut group = load_group(provider, &binding.openmls_group_id, request_id)?; + validate_loaded_group_matches_binding(&binding, &group, request_id)?; + let signer = load_signer(provider, conn, actor, device_id, request_id)?; + let kp_b64u = params + .pointer("/group_key_package/mls_key_package_b64u") + .or_else(|| params.get("mls_key_package_b64u")) + .and_then(Value::as_str) + .ok_or_else(|| { + error( + "missing_field", + "group_key_package.mls_key_package_b64u is required", + None, + ) + })?; + let key_package_bytes = decode_b64u(kp_b64u, request_id)?; + let mut key_package_reader = key_package_bytes.as_slice(); + let key_package_in = KeyPackageIn::tls_deserialize(&mut key_package_reader) + .map_err(|e| mls_error("key_package_decode_failed", e, request_id))?; + if !key_package_reader.is_empty() { + return Err(error( + "key_package_decode_failed", + "trailing bytes after KeyPackage", + Some(request_id.to_owned()), + )); + } + let key_package = key_package_in + .validate(provider.crypto(), ProtocolVersion::Mls10) + .map_err(|e| mls_error("key_package_validate_failed", e, request_id))?; + validate_key_package_did_wba_binding(params, member_did, &key_package, request_id)?; + let (commit, welcome, _group_info) = group + .add_members(provider, &signer, core::slice::from_ref(&key_package)) + .map_err(|e| mls_error("group_add_member_failed", e, request_id))?; + group + .merge_pending_commit(provider) + .map_err(|e| mls_error("group_add_merge_failed", e, request_id))?; + upsert_binding( + conn, + actor, + device_id, + group_did, + &binding.openmls_group_id, + group.epoch().as_u64(), + &binding.role, + request_id, + )?; + let commit_b64u = encode_b64u( + &commit + .tls_serialize_detached() + .map_err(|e| mls_error("commit_encode_failed", e, request_id))?, + ); + let welcome_body = match welcome.body() { + MlsMessageBodyOut::Welcome(welcome) => welcome.clone(), + _ => { + return Err(error( + "welcome_encode_failed", + "OpenMLS add-member did not return a Welcome message", + Some(request_id.to_owned()), + )) + } + }; + let welcome_b64u = encode_b64u( + &welcome_body + .tls_serialize_detached() + .map_err(|e| mls_error("welcome_encode_failed", e, request_id))?, + ); + let ratchet_tree: RatchetTreeIn = group.export_ratchet_tree().into(); + let ratchet_tree_b64u = encode_b64u( + &ratchet_tree + .tls_serialize_detached() + .map_err(|e| mls_error("ratchet_tree_encode_failed", e, request_id))?, + ); + Ok(json!({ + "crypto_group_id_b64u": encode_b64u(binding.openmls_group_id.as_slice()), + "openmls_group_id_b64u": encode_b64u(binding.openmls_group_id.as_slice()), + "epoch": group.epoch().as_u64().to_string(), + "commit_b64u": commit_b64u, + "welcome_b64u": welcome_b64u, + "ratchet_tree_b64u": ratchet_tree_b64u, + "member_did": member_did, + "epoch_authenticator": encode_b64u(group.epoch_authenticator().as_slice()), + })) +} + +fn real_group_recover_member_prepare( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + operation_id: &str, + request_id: &str, +) -> Result { + let group_did = required(params, "group_did")?; + let member_did = params + .get("member_did") + .or_else(|| params.get("target_did")) + .or_else(|| params.pointer("/target/agent_did")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "missing_field", + "member_did/target.agent_did is required", + None, + ) + })?; + let actor = params + .get("actor_did") + .or_else(|| params.get("owner_did")) + .or_else(|| params.get("agent_did")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| error("missing_field", "actor_did or owner_did is required", None))?; + let device_id = device_id(params); + let target_device_id = params + .get("target_device_id") + .or_else(|| params.get("member_device_id")) + .or_else(|| params.pointer("/target/device_id")) + .or_else(|| params.pointer("/group_key_package/device_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(DEVICE_ID_DEFAULT); + validate_recovery_key_package_context(params, group_did, member_did, target_device_id)?; + let binding = binding(conn, actor, device_id, group_did, request_id)?; + let mut group = load_group(provider, &binding.openmls_group_id, request_id)?; + if let Some(group_state_ref) = params.get("group_state_ref") { + validate_group_binding_claims(&binding, group_state_ref, request_id)?; + } + validate_loaded_group_matches_binding(&binding, &group, request_id)?; + let signer = load_signer(provider, conn, actor, device_id, request_id)?; + let kp_b64u = params + .pointer("/group_key_package/mls_key_package_b64u") + .or_else(|| params.get("mls_key_package_b64u")) + .and_then(Value::as_str) + .ok_or_else(|| { + error( + "missing_field", + "group_key_package.mls_key_package_b64u is required", + None, + ) + })?; + let key_package_bytes = decode_b64u(kp_b64u, request_id)?; + let mut key_package_reader = key_package_bytes.as_slice(); + let key_package_in = KeyPackageIn::tls_deserialize(&mut key_package_reader) + .map_err(|e| mls_error("key_package_decode_failed", e, request_id))?; + if !key_package_reader.is_empty() { + return Err(error( + "key_package_decode_failed", + "trailing bytes after KeyPackage", + Some(request_id.to_owned()), + )); + } + let key_package = key_package_in + .validate(provider.crypto(), ProtocolVersion::Mls10) + .map_err(|e| mls_error("key_package_validate_failed", e, request_id))?; + validate_key_package_did_wba_binding(params, member_did, &key_package, request_id)?; + let original_tree = group.export_ratchet_tree(); + let (commit, welcome, _group_info) = group + .add_members(provider, &signer, core::slice::from_ref(&key_package)) + .map_err(|e| mls_error("group_recover_member_prepare_failed", e, request_id))?; + let pending = group.pending_commit().ok_or_else(|| { + error( + "pending_commit_missing", + "OpenMLS did not persist a pending recovery commit", + Some(request_id.to_owned()), + ) + })?; + let to_epoch = pending.epoch().as_u64(); + let epoch_authenticator_b64u = pending + .epoch_authenticator() + .map(|value| encode_b64u(value.as_slice())); + let ratchet_tree_b64u = pending + .export_ratchet_tree(provider.crypto(), original_tree) + .map_err(|e| mls_error("ratchet_tree_encode_failed", e, request_id))? + .map(|tree| { + let tree_in: RatchetTreeIn = tree.into(); + tree_in + .tls_serialize_detached() + .map(|bytes| encode_b64u(&bytes)) + .map_err(|e| mls_error("ratchet_tree_encode_failed", e, request_id)) + }) + .transpose()?; + let commit_b64u = encode_b64u( + &commit + .tls_serialize_detached() + .map_err(|e| mls_error("commit_encode_failed", e, request_id))?, + ); + let welcome_body = match welcome.body() { + MlsMessageBodyOut::Welcome(welcome) => welcome.clone(), + _ => { + return Err(error( + "welcome_encode_failed", + "OpenMLS recover-member prepare did not return a Welcome message", + Some(request_id.to_owned()), + )) + } + }; + let welcome_b64u = encode_b64u( + &welcome_body + .tls_serialize_detached() + .map_err(|e| mls_error("welcome_encode_failed", e, request_id))?, + ); + let pending_commit_id = params + .get("pending_commit_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "pc-{}", + short_digest(&json!({"operation_id": operation_id})) + ) + }); + let result = membership_prepare_response(MembershipPrepare { + pending_commit_id: &pending_commit_id, + operation_id, + command: "group recover-member-prepare", + actor_did: actor, + subject_did: member_did, + subject_status: "recovered", + group_did, + crypto_group_id_b64u: &encode_b64u(binding.openmls_group_id.as_slice()), + from_epoch: binding.epoch, + to_epoch, + commit_b64u: &commit_b64u, + welcome_b64u: Some(&welcome_b64u), + ratchet_tree_b64u: ratchet_tree_b64u.as_deref(), + group_info_b64u: None, + epoch_authenticator_b64u: epoch_authenticator_b64u.as_deref(), + }); + insert_pending_commit( + conn, + &pending_commit_id, + operation_id, + "group recover-member-prepare", + actor, + device_id, + group_did, + member_did, + "recovered", + binding.epoch, + to_epoch, + &commit_b64u, + ratchet_tree_b64u.as_deref(), + None, + epoch_authenticator_b64u.as_deref(), + &result, + request_id, + )?; + Ok(result) +} + +fn real_group_remove_member( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + operation_id: &str, + request_id: &str, +) -> Result { + let group_did = required(params, "group_did")?; + let subject = params + .get("subject_did") + .or_else(|| params.get("member_did")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| error("missing_field", "subject_did/member_did is required", None))?; + let actor = params + .get("actor_did") + .or_else(|| params.get("owner_did")) + .or_else(|| params.get("agent_did")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| error("missing_field", "actor_did/agent_did is required", None))?; + prepare_membership_remove( + provider, + conn, + params, + operation_id, + request_id, + actor, + subject, + group_did, + "group remove-member", + "removed", + ) +} + +fn real_group_leave( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + operation_id: &str, + request_id: &str, +) -> Result { + let group_did = required(params, "group_did")?; + let actor = params + .get("actor_did") + .or_else(|| params.get("agent_did")) + .or_else(|| params.get("owner_did")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| error("missing_field", "actor_did/agent_did is required", None))?; + let device_id = device_id(params); + let binding = binding(conn, actor, device_id, group_did, request_id)?; + let group = load_group(provider, &binding.openmls_group_id, request_id)?; + if let Some(group_state_ref) = params.get("group_state_ref") { + validate_group_binding_claims(&binding, group_state_ref, request_id)?; + } + validate_loaded_group_matches_binding(&binding, &group, request_id)?; + let commit_b64u = encode_b64u( + serde_json::to_vec(&json!({ + "artifact_type": "local-terminal-leave", + "group_did": group_did, + "actor_did": actor, + "epoch": binding.epoch.to_string(), + "protocol_limitation": "OpenMLS 0.8 rejects same-member self-remove commits; service must record leave status and remaining members advance MLS with a separate remove commit/notice." + })) + .map_err(|e| error("artifact_failed", &e.to_string(), Some(request_id.to_owned())))? + .as_slice(), + ); + let pending_commit_id = params + .get("pending_commit_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "pc-{}", + short_digest(&json!({"operation_id": operation_id})) + ) + }); + let epoch_authenticator_b64u = encode_b64u(group.epoch_authenticator().as_slice()); + let result = membership_prepare_response(MembershipPrepare { + pending_commit_id: &pending_commit_id, + operation_id, + command: "group leave", + actor_did: actor, + subject_did: actor, + subject_status: "left", + group_did, + crypto_group_id_b64u: &encode_b64u(binding.openmls_group_id.as_slice()), + from_epoch: binding.epoch, + to_epoch: binding.epoch, + commit_b64u: &commit_b64u, + welcome_b64u: None, + ratchet_tree_b64u: None, + group_info_b64u: None, + epoch_authenticator_b64u: Some(&epoch_authenticator_b64u), + }); + let mut result = result; + result["artifact_type"] = json!("local-terminal-leave"); + result["protocol_limitation"] = json!("OpenMLS 0.8 rejects same-member self-remove commits; local finalize marks the leaver inactive without advancing local epoch."); + insert_pending_commit( + conn, + &pending_commit_id, + operation_id, + "group leave", + actor, + device_id, + group_did, + actor, + "left", + binding.epoch, + binding.epoch, + &commit_b64u, + None, + None, + Some(&epoch_authenticator_b64u), + &result, + request_id, + )?; + Ok(result) +} + +#[allow(clippy::too_many_arguments)] +fn prepare_membership_remove( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + operation_id: &str, + request_id: &str, + actor: &str, + subject: &str, + group_did: &str, + command: &str, + subject_status: &str, +) -> Result { + let device_id = device_id(params); + let binding = binding(conn, actor, device_id, group_did, request_id)?; + let mut group = load_group(provider, &binding.openmls_group_id, request_id)?; + if let Some(group_state_ref) = params.get("group_state_ref") { + validate_group_binding_claims(&binding, group_state_ref, request_id)?; + } + validate_loaded_group_matches_binding(&binding, &group, request_id)?; + let target_leaf = member_leaf_index_by_did(&group, subject).ok_or_else(|| { + error( + "member_not_found", + "subject_did/member_did is not an active MLS leaf in the local group", + Some(request_id.to_owned()), + ) + })?; + let signer = load_signer(provider, conn, actor, device_id, request_id)?; + let original_tree = group.export_ratchet_tree(); + let (commit, _welcome, group_info) = group + .remove_members(provider, &signer, &[target_leaf]) + .map_err(|e| mls_error("group_remove_member_failed", e, request_id))?; + let pending = group.pending_commit().ok_or_else(|| { + error( + "pending_commit_missing", + "OpenMLS did not persist a pending membership commit", + Some(request_id.to_owned()), + ) + })?; + let to_epoch = pending.epoch().as_u64(); + let epoch_authenticator_b64u = pending + .epoch_authenticator() + .map(|value| encode_b64u(value.as_slice())); + let ratchet_tree_b64u = pending + .export_ratchet_tree(provider.crypto(), original_tree) + .map_err(|e| mls_error("ratchet_tree_encode_failed", e, request_id))? + .map(|tree| { + let tree_in: RatchetTreeIn = tree.into(); + tree_in + .tls_serialize_detached() + .map(|bytes| encode_b64u(&bytes)) + .map_err(|e| mls_error("ratchet_tree_encode_failed", e, request_id)) + }) + .transpose()?; + let group_info_b64u = group_info + .map(|value| { + value + .tls_serialize_detached() + .map(|bytes| encode_b64u(&bytes)) + .map_err(|e| mls_error("group_info_encode_failed", e, request_id)) + }) + .transpose()?; + let commit_b64u = encode_b64u( + &commit + .tls_serialize_detached() + .map_err(|e| mls_error("commit_encode_failed", e, request_id))?, + ); + let pending_commit_id = params + .get("pending_commit_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "pc-{}", + short_digest(&json!({"operation_id": operation_id})) + ) + }); + let result = membership_prepare_response(MembershipPrepare { + pending_commit_id: &pending_commit_id, + operation_id, + command, + actor_did: actor, + subject_did: subject, + subject_status, + group_did, + crypto_group_id_b64u: &encode_b64u(binding.openmls_group_id.as_slice()), + from_epoch: binding.epoch, + to_epoch, + commit_b64u: &commit_b64u, + welcome_b64u: None, + ratchet_tree_b64u: ratchet_tree_b64u.as_deref(), + group_info_b64u: group_info_b64u.as_deref(), + epoch_authenticator_b64u: epoch_authenticator_b64u.as_deref(), + }); + insert_pending_commit( + conn, + &pending_commit_id, + operation_id, + command, + actor, + device_id, + group_did, + subject, + subject_status, + binding.epoch, + to_epoch, + &commit_b64u, + ratchet_tree_b64u.as_deref(), + group_info_b64u.as_deref(), + epoch_authenticator_b64u.as_deref(), + &result, + request_id, + )?; + Ok(result) +} + +fn real_welcome_process( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let agent = agent_did(params)?; + let device_id = device_id(params); + ensure_agent(provider, conn, agent, device_id, request_id)?; + let group_did = required(params, "group_did")?; + let welcome_b64u = required(params, "welcome_b64u")?; + let ratchet_tree_b64u = required(params, "ratchet_tree_b64u")?; + let welcome = Welcome::tls_deserialize_exact(decode_b64u(welcome_b64u, request_id)?) + .map_err(|e| mls_error("welcome_decode_failed", e, request_id))?; + let ratchet_tree = + RatchetTreeIn::tls_deserialize_exact(decode_b64u(ratchet_tree_b64u, request_id)?) + .map_err(|e| mls_error("ratchet_tree_decode_failed", e, request_id))?; + let join_config = group_join_config(); + let staged = + StagedWelcome::new_from_welcome(provider, &join_config, welcome, Some(ratchet_tree)) + .map_err(|e| mls_error("welcome_stage_failed", e, request_id))?; + let group = staged + .into_group(provider) + .map_err(|e| mls_error("welcome_process_failed", e, request_id))?; + let group_id = group.group_id().clone(); + upsert_binding( + conn, + agent, + device_id, + group_did, + &group_id, + group.epoch().as_u64(), + "member", + request_id, + )?; + Ok(json!({ + "crypto_group_id_b64u": encode_b64u(group_id.as_slice()), + "openmls_group_id_b64u": encode_b64u(group_id.as_slice()), + "epoch": group.epoch().as_u64().to_string(), + "status": "active", + "epoch_authenticator": encode_b64u(group.epoch_authenticator().as_slice()), + })) +} + +fn real_message_encrypt( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let group_state_ref = params + .get("group_state_ref") + .cloned() + .ok_or_else(|| error("missing_field", "group_state_ref is required", None))?; + let group_did = group_state_ref + .get("group_did") + .and_then(Value::as_str) + .or_else(|| params.get("group_did").and_then(Value::as_str)) + .ok_or_else(|| error("missing_field", "group_did is required", None))?; + let sender = agent_did(params)?; + let device_id = device_id(params); + let binding = binding(conn, sender, device_id, group_did, request_id)?; + let mut group = load_group(provider, &binding.openmls_group_id, request_id)?; + validate_group_binding_claims(&binding, &group_state_ref, request_id)?; + validate_loaded_group_matches_binding(&binding, &group, request_id)?; + let signer = load_signer(provider, conn, sender, device_id, request_id)?; + let plaintext = application_plaintext_bytes(params, request_id)?; + let aad = build_message_aad(params, &binding, &group_state_ref, request_id)?; + group.set_aad(aad.clone()); + let message = group + .create_message(provider, &signer, &plaintext) + .map_err(|e| mls_error("message_encrypt_failed", e, request_id))?; + let private_message_b64u = encode_b64u( + &message + .tls_serialize_detached() + .map_err(|e| mls_error("message_encode_failed", e, request_id))?, + ); + upsert_binding( + conn, + sender, + device_id, + group_did, + &binding.openmls_group_id, + group.epoch().as_u64(), + &binding.role, + request_id, + )?; + Ok(json!({ + "group_cipher_object": { + "crypto_group_id_b64u": encode_b64u(binding.openmls_group_id.as_slice()), + "openmls_group_id_b64u": encode_b64u(binding.openmls_group_id.as_slice()), + "epoch": group.epoch().as_u64().to_string(), + "private_message_b64u": private_message_b64u, + "group_state_ref": group_state_ref, + "epoch_authenticator": encode_b64u(group.epoch_authenticator().as_slice()) + }, + "authenticated_data_sha256_b64u": encode_b64u(&Sha256::digest(&aad)), + })) +} + +fn real_message_decrypt( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let recipient = params + .get("recipient_did") + .or_else(|| params.get("agent_did")) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + error( + "missing_field", + "recipient_did or agent_did is required", + None, + ) + })?; + let device_id = device_id(params); + let group_state_ref = params + .get("group_state_ref") + .or_else(|| params.pointer("/group_cipher_object/group_state_ref")) + .cloned() + .ok_or_else(|| error("missing_field", "group_state_ref is required", None))?; + let group_did = params + .pointer("/group_state_ref/group_did") + .or_else(|| params.pointer("/group_cipher_object/group_state_ref/group_did")) + .or_else(|| params.get("group_did")) + .and_then(Value::as_str) + .ok_or_else(|| error("missing_field", "group_did is required", None))?; + let private_message_b64u = params + .pointer("/group_cipher_object/private_message_b64u") + .or_else(|| params.get("private_message_b64u")) + .and_then(Value::as_str) + .ok_or_else(|| error("missing_field", "private_message_b64u is required", None))?; + let binding = binding(conn, recipient, device_id, group_did, request_id)?; + let mut group = load_group(provider, &binding.openmls_group_id, request_id)?; + if let Some(group_cipher_object) = params.get("group_cipher_object") { + validate_group_binding_claims(&binding, group_cipher_object, request_id)?; + if let Some(group_state_ref) = group_cipher_object.get("group_state_ref") { + validate_group_binding_claims(&binding, group_state_ref, request_id)?; + } + } + if let Some(group_state_ref) = params.get("group_state_ref") { + validate_group_binding_claims(&binding, group_state_ref, request_id)?; + } + validate_loaded_group_matches_binding(&binding, &group, request_id)?; + let expected_aad = build_message_aad(params, &binding, &group_state_ref, request_id)?; + let message_in = + MlsMessageIn::tls_deserialize_exact(decode_b64u(private_message_b64u, request_id)?) + .map_err(|e| mls_error("message_decode_failed", e, request_id))?; + let protocol = message_in.try_into_protocol_message().map_err(|_| { + error( + "message_decode_failed", + "MLS message is not a protocol message", + Some(request_id.to_owned()), + ) + })?; + let processed = group + .process_message(provider, protocol) + .map_err(|e| mls_error("message_decrypt_failed", e, request_id))?; + if processed.aad() != expected_aad.as_slice() { + return Err(error( + "aad_mismatch", + "MLS authenticated_data does not match P6 outer message binding", + Some(request_id.to_owned()), + )); + } + upsert_binding( + conn, + recipient, + device_id, + group_did, + &binding.openmls_group_id, + group.epoch().as_u64(), + &binding.role, + request_id, + )?; + let plaintext = match processed.into_content() { + ProcessedMessageContent::ApplicationMessage(application) => application.into_bytes(), + other => { + return Err(error( + "message_decrypt_failed", + &format!("expected application message, got {other:?}"), + Some(request_id.to_owned()), + )) + } + }; + Ok(json!({ + "application_plaintext": application_plaintext_value(&plaintext), + "epoch": group.epoch().as_u64().to_string(), + })) +} + +fn real_group_commit_finalize( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let pending_commit_id = pending_commit_id(params)?; + let pending = pending_commit(conn, pending_commit_id, request_id)?; + if pending.status == "finalized" { + return Ok(json!({ + "pending_commit_id": pending.pending_commit_id, + "operation_id": pending.operation_id, + "group_did": pending.group_did, + "status": "finalized", + "epoch": pending.to_epoch.to_string(), + "local_epoch": pending.to_epoch.to_string(), + "subject_did": pending.subject_did, + "subject_status": pending.subject_status, + })); + } + if pending.status == "aborted" { + return Err(error( + "pending_commit_aborted", + "pending commit was already aborted", + Some(request_id.to_owned()), + )); + } + let mut epoch_authenticator = None; + if pending.command != "group leave" { + let mut group = load_group( + provider, + &GroupId::from_slice(&decode_b64u(&pending.crypto_group_id_b64u, request_id)?), + request_id, + )?; + if group.pending_commit().is_none() { + return Err(error( + "pending_commit_missing", + "OpenMLS pending commit is missing; abort and retry prepare", + Some(request_id.to_owned()), + )); + } + group + .merge_pending_commit(provider) + .map_err(|e| mls_error("pending_commit_finalize_failed", e, request_id))?; + epoch_authenticator = Some(encode_b64u(group.epoch_authenticator().as_slice())); + } + if pending.subject_did == pending.agent_did { + mark_binding_inactive( + conn, + &pending.agent_did, + &pending.device_id, + &pending.group_did, + pending.to_epoch, + &pending.subject_status, + request_id, + )?; + } else { + set_binding_epoch_status( + conn, + &pending.agent_did, + &pending.device_id, + &pending.group_did, + pending.to_epoch, + "active", + request_id, + )?; + } + update_pending_commit_status(conn, &pending.pending_commit_id, "finalized", request_id)?; + Ok(json!({ + "pending_commit_id": pending.pending_commit_id, + "operation_id": pending.operation_id, + "group_did": pending.group_did, + "crypto_group_id_b64u": pending.crypto_group_id_b64u, + "status": "finalized", + "from_epoch": pending.from_epoch.to_string(), + "epoch": pending.to_epoch.to_string(), + "local_epoch": pending.to_epoch.to_string(), + "subject_did": pending.subject_did, + "subject_status": pending.subject_status, + "epoch_authenticator": epoch_authenticator, + })) +} + +fn real_group_commit_abort( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let pending_commit_id = pending_commit_id(params)?; + let pending = pending_commit(conn, pending_commit_id, request_id)?; + if pending.status == "finalized" { + return Err(error( + "pending_commit_finalized", + "finalized pending commits cannot be aborted", + Some(request_id.to_owned()), + )); + } + if pending.status != "aborted" { + if pending.command != "group leave" { + let mut group = load_group( + provider, + &GroupId::from_slice(&decode_b64u(&pending.crypto_group_id_b64u, request_id)?), + request_id, + )?; + group + .clear_pending_commit(provider.storage()) + .map_err(|e| mls_error("pending_commit_abort_failed", e, request_id))?; + } + update_pending_commit_status(conn, &pending.pending_commit_id, "aborted", request_id)?; + } + Ok(json!({ + "pending_commit_id": pending.pending_commit_id, + "operation_id": pending.operation_id, + "group_did": pending.group_did, + "crypto_group_id_b64u": pending.crypto_group_id_b64u, + "status": "aborted", + "local_epoch": pending.from_epoch.to_string(), + "subject_did": pending.subject_did, + "subject_status": pending.subject_status, + })) +} + +fn real_commit_process( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + request_id: &str, +) -> Result { + let agent = params + .get("recipient_did") + .or_else(|| params.get("agent_did")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| error("missing_field", "recipient_did/agent_did is required", None))?; + let device_id = device_id(params); + let group_did = required(params, "group_did")?; + let commit_b64u = params + .get("commit_b64u") + .or_else(|| params.pointer("/notice/commit_b64u")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| error("missing_field", "commit_b64u is required", None))?; + let binding = binding(conn, agent, device_id, group_did, request_id)?; + if let Some(from_epoch) = params.get("from_epoch").and_then(epoch_claim_as_u64) { + if from_epoch != binding.epoch { + return Err(error( + "group_epoch_mismatch", + "commit from_epoch does not match the local MLS group epoch", + Some(request_id.to_owned()), + )); + } + } + let mut group = load_group(provider, &binding.openmls_group_id, request_id)?; + validate_loaded_group_matches_binding(&binding, &group, request_id)?; + let original_tree = group.export_ratchet_tree(); + let message_in = MlsMessageIn::tls_deserialize_exact(decode_b64u(commit_b64u, request_id)?) + .map_err(|e| mls_error("commit_decode_failed", e, request_id))?; + let protocol = message_in.try_into_protocol_message().map_err(|_| { + error( + "commit_decode_failed", + "commit_b64u is not an MLS protocol message", + Some(request_id.to_owned()), + ) + })?; + let processed = group + .process_message(provider, protocol) + .map_err(|e| mls_error("commit_process_failed", e, request_id))?; + let staged_commit = match processed.into_content() { + ProcessedMessageContent::StagedCommitMessage(staged_commit) => *staged_commit, + other => { + return Err(error( + "commit_process_failed", + &format!("expected MLS staged commit, got {other:?}"), + Some(request_id.to_owned()), + )) + } + }; + let self_removed = staged_commit.self_removed(); + let to_epoch = staged_commit.epoch().as_u64(); + let epoch_authenticator_b64u = staged_commit + .epoch_authenticator() + .map(|value| encode_b64u(value.as_slice())); + let ratchet_tree_b64u = staged_commit + .export_ratchet_tree(provider.crypto(), original_tree) + .map_err(|e| mls_error("ratchet_tree_encode_failed", e, request_id))? + .map(|tree| { + let tree_in: RatchetTreeIn = tree.into(); + tree_in + .tls_serialize_detached() + .map(|bytes| encode_b64u(&bytes)) + .map_err(|e| mls_error("ratchet_tree_encode_failed", e, request_id)) + }) + .transpose()?; + group + .merge_staged_commit(provider, staged_commit) + .map_err(|e| mls_error("commit_merge_failed", e, request_id))?; + let subject_status = params + .get("subject_status") + .and_then(Value::as_str) + .unwrap_or(if self_removed { "removed" } else { "active" }); + if self_removed { + mark_binding_inactive( + conn, + agent, + device_id, + group_did, + to_epoch, + subject_status, + request_id, + )?; + } else { + set_binding_epoch_status( + conn, agent, device_id, group_did, to_epoch, "active", request_id, + )?; + } + Ok(json!({ + "group_did": group_did, + "crypto_group_id_b64u": encode_b64u(binding.openmls_group_id.as_slice()), + "status": if self_removed { "inactive" } else { "active" }, + "self_removed": self_removed, + "from_epoch": binding.epoch.to_string(), + "epoch": to_epoch.to_string(), + "epoch_authenticator": epoch_authenticator_b64u, + "ratchet_tree_b64u": ratchet_tree_b64u, + "subject_did": params.get("subject_did").cloned().unwrap_or_else(|| json!(agent)), + "subject_status": subject_status, + })) +} + +fn real_group_status( + provider: &mut SqliteMlsProvider, + conn: &Connection, + params: &Value, + data_dir: &Path, + request_id: &str, +) -> Result { + let agent = params + .get("agent_did") + .or_else(|| params.get("owner_did")) + .and_then(Value::as_str); + let device_id = params + .get("device_id") + .and_then(Value::as_str) + .unwrap_or(DEVICE_ID_DEFAULT); + let group_did = params.get("group_did").and_then(Value::as_str); + let mut stmt = conn + .prepare( + "SELECT agent_did, device_id, group_did, crypto_group_id_b64u, openmls_group_id_b64u, epoch, role, status + FROM group_bindings + WHERE (?1 IS NULL OR agent_did = ?1) AND (?2 IS NULL OR group_did = ?2) AND device_id = ?3 + ORDER BY updated_at DESC", + ) + .map_err(|e| sqlite_error("state_read_failed", e, request_id))?; + let rows = stmt + .query_map(params![agent, group_did, device_id], |row| { + Ok(json!({ + "agent_did": row.get::<_, String>(0)?, + "device_id": row.get::<_, String>(1)?, + "group_did": row.get::<_, String>(2)?, + "crypto_group_id_b64u": row.get::<_, String>(3)?, + "openmls_group_id_b64u": row.get::<_, String>(4)?, + "epoch": row.get::<_, i64>(5)?.to_string(), + "role": row.get::<_, String>(6)?, + "status": row.get::<_, String>(7)?, + })) + }) + .map_err(|e| sqlite_error("state_read_failed", e, request_id))?; + let mut bindings = Vec::new(); + for row in rows { + bindings.push(row.map_err(|e| sqlite_error("state_read_failed", e, request_id))?); + } + let pending_commits = + pending_commits_for_status(conn, agent, device_id, group_did, request_id)?; + if let (Some(agent), Some(group_did)) = (agent, group_did) { + if let Ok(binding) = binding(conn, agent, device_id, group_did, request_id) { + if let Some(group) = MlsGroup::load(provider.storage(), &binding.openmls_group_id) + .map_err(|e| mls_error("group_load_failed", e, request_id))? + { + return Ok(json!({ + "data_dir": data_dir.to_string_lossy(), + "state_db": data_dir.join("state.db").to_string_lossy(), + "bindings": bindings, + "pending_commits": pending_commits, + "status": "active", + "epoch": group.epoch().as_u64().to_string(), + "local_epoch": group.epoch().as_u64().to_string(), + "epoch_authenticator": encode_b64u(group.epoch_authenticator().as_slice()), + })); + } + } + } + let derived_status = derive_group_status_from_bindings(&bindings); + let derived_epoch = bindings + .first() + .and_then(|binding| binding.get("epoch")) + .and_then(Value::as_str) + .map(str::to_owned); + Ok(json!({ + "data_dir": data_dir.to_string_lossy(), + "state_db": data_dir.join("state.db").to_string_lossy(), + "bindings": bindings, + "pending_commits": pending_commits, + "status": derived_status, + "epoch": derived_epoch.clone(), + "local_epoch": derived_epoch, + })) +} + +fn derive_group_status_from_bindings(bindings: &[Value]) -> String { + if bindings.is_empty() { + return "empty".to_owned(); + } + if bindings + .iter() + .any(|binding| binding.get("status").and_then(Value::as_str) == Some("active")) + { + return "active".to_owned(); + } + bindings + .first() + .and_then(|binding| binding.get("status")) + .and_then(Value::as_str) + .unwrap_or("inactive") + .to_owned() +} + +fn pending_commits_for_status( + conn: &Connection, + agent: Option<&str>, + device_id: &str, + group_did: Option<&str>, + request_id: &str, +) -> Result, Value> { + let mut stmt = conn + .prepare( + "SELECT pending_commit_id, operation_id, command, agent_did, device_id, group_did, subject_did, subject_status, crypto_group_id_b64u, from_epoch, to_epoch, status + FROM pending_commits + WHERE (?1 IS NULL OR agent_did = ?1) AND (?2 IS NULL OR group_did = ?2) AND device_id = ?3 AND status = 'pending' + ORDER BY created_at DESC", + ) + .map_err(|e| sqlite_error("state_read_failed", e, request_id))?; + let rows = stmt + .query_map(params![agent, group_did, device_id], |row| { + Ok(json!({ + "pending_commit_id": row.get::<_, String>(0)?, + "operation_id": row.get::<_, String>(1)?, + "command": row.get::<_, String>(2)?, + "agent_did": row.get::<_, String>(3)?, + "device_id": row.get::<_, String>(4)?, + "group_did": row.get::<_, String>(5)?, + "subject_did": row.get::<_, String>(6)?, + "subject_status": row.get::<_, String>(7)?, + "crypto_group_id_b64u": row.get::<_, String>(8)?, + "from_epoch": row.get::<_, i64>(9)?.to_string(), + "to_epoch": row.get::<_, i64>(10)?.to_string(), + "status": row.get::<_, String>(11)?, + })) + }) + .map_err(|e| sqlite_error("state_read_failed", e, request_id))?; + let mut pending = Vec::new(); + for row in rows { + pending.push(row.map_err(|e| sqlite_error("state_read_failed", e, request_id))?); + } + Ok(pending) +} + +struct Binding { + openmls_group_id: GroupId, + epoch: u64, + role: String, +} + +struct MembershipPrepare<'a> { + pending_commit_id: &'a str, + operation_id: &'a str, + command: &'a str, + actor_did: &'a str, + subject_did: &'a str, + subject_status: &'a str, + group_did: &'a str, + crypto_group_id_b64u: &'a str, + from_epoch: u64, + to_epoch: u64, + commit_b64u: &'a str, + welcome_b64u: Option<&'a str>, + ratchet_tree_b64u: Option<&'a str>, + group_info_b64u: Option<&'a str>, + epoch_authenticator_b64u: Option<&'a str>, +} + +struct PendingCommitRecord { + pending_commit_id: String, + operation_id: String, + command: String, + agent_did: String, + device_id: String, + group_did: String, + subject_did: String, + subject_status: String, + crypto_group_id_b64u: String, + from_epoch: u64, + to_epoch: u64, + status: String, +} + +fn membership_prepare_response(input: MembershipPrepare<'_>) -> Value { + json!({ + "pending_commit_id": input.pending_commit_id, + "operation_id": input.operation_id, + "command": input.command, + "status": "pending", + "actor_did": input.actor_did, + "subject_did": input.subject_did, + "subject_status": input.subject_status, + "group_did": input.group_did, + "crypto_group_id_b64u": input.crypto_group_id_b64u, + "openmls_group_id_b64u": input.crypto_group_id_b64u, + "from_epoch": input.from_epoch.to_string(), + "epoch": input.to_epoch.to_string(), + "to_epoch": input.to_epoch.to_string(), + "local_epoch": input.from_epoch.to_string(), + "commit_b64u": input.commit_b64u, + "welcome_b64u": input.welcome_b64u, + "ratchet_tree_b64u": input.ratchet_tree_b64u, + "group_info_b64u": input.group_info_b64u, + "epoch_authenticator": input.epoch_authenticator_b64u, + "epoch_authenticator_b64u": input.epoch_authenticator_b64u, + "suite": MTI_SUITE, + }) +} + +fn upsert_binding( + conn: &Connection, + agent_did: &str, + device_id: &str, + group_did: &str, + openmls_group_id: &GroupId, + epoch: u64, + role: &str, + request_id: &str, +) -> Result<(), Value> { + let group_id_b64u = encode_b64u(openmls_group_id.as_slice()); + conn.execute( + "INSERT INTO group_bindings(agent_did, device_id, group_did, crypto_group_id_b64u, openmls_group_id_b64u, epoch, role, status, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4, ?5, ?6, 'active', CURRENT_TIMESTAMP) + ON CONFLICT(agent_did, device_id, group_did) DO UPDATE SET + crypto_group_id_b64u = excluded.crypto_group_id_b64u, + openmls_group_id_b64u = excluded.openmls_group_id_b64u, + epoch = excluded.epoch, + role = excluded.role, + status = 'active', + updated_at = CURRENT_TIMESTAMP", + params![agent_did, device_id, group_did, group_id_b64u, epoch as i64, role], + ) + .map_err(|e| sqlite_error("state_write_failed", e, request_id))?; + Ok(()) +} + +fn set_binding_epoch_status( + conn: &Connection, + agent_did: &str, + device_id: &str, + group_did: &str, + epoch: u64, + status: &str, + request_id: &str, +) -> Result<(), Value> { + conn.execute( + "UPDATE group_bindings + SET epoch = ?4, status = ?5, updated_at = CURRENT_TIMESTAMP + WHERE agent_did = ?1 AND device_id = ?2 AND group_did = ?3", + params![agent_did, device_id, group_did, epoch as i64, status], + ) + .map_err(|e| sqlite_error("state_write_failed", e, request_id))?; + Ok(()) +} + +fn mark_binding_inactive( + conn: &Connection, + agent_did: &str, + device_id: &str, + group_did: &str, + epoch: u64, + status: &str, + request_id: &str, +) -> Result<(), Value> { + let inactive_status = match status { + "left" => "left", + "removed" => "removed", + other => other, + }; + set_binding_epoch_status( + conn, + agent_did, + device_id, + group_did, + epoch, + inactive_status, + request_id, + ) +} + +#[allow(clippy::too_many_arguments)] +fn insert_pending_commit( + conn: &Connection, + pending_commit_id: &str, + operation_id: &str, + command: &str, + agent_did: &str, + device_id: &str, + group_did: &str, + subject_did: &str, + subject_status: &str, + from_epoch: u64, + to_epoch: u64, + commit_b64u: &str, + ratchet_tree_b64u: Option<&str>, + group_info_b64u: Option<&str>, + epoch_authenticator_b64u: Option<&str>, + response: &Value, + request_id: &str, +) -> Result<(), Value> { + conn.execute( + "INSERT INTO pending_commits( + pending_commit_id, operation_id, command, agent_did, device_id, group_did, crypto_group_id_b64u, + subject_did, subject_status, from_epoch, to_epoch, commit_b64u, + ratchet_tree_b64u, group_info_b64u, epoch_authenticator_b64u, + status, response_json, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 'pending', ?16, CURRENT_TIMESTAMP)", + params![ + pending_commit_id, + operation_id, + command, + agent_did, + device_id, + group_did, + response["crypto_group_id_b64u"].as_str().unwrap_or_default(), + subject_did, + subject_status, + from_epoch as i64, + to_epoch as i64, + commit_b64u, + ratchet_tree_b64u, + group_info_b64u, + epoch_authenticator_b64u, + response.to_string(), + ], + ) + .map_err(|e| sqlite_error("state_write_failed", e, request_id))?; + Ok(()) +} + +fn pending_commit_id(params: &Value) -> Result<&str, Value> { + params + .get("pending_commit_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| error("missing_field", "pending_commit_id is required", None)) +} + +fn pending_commit( + conn: &Connection, + pending_commit_id: &str, + request_id: &str, +) -> Result { + conn.query_row( + "SELECT pending_commit_id, operation_id, command, agent_did, device_id, group_did, + subject_did, subject_status, from_epoch, to_epoch, status, + crypto_group_id_b64u + FROM pending_commits + WHERE pending_commit_id = ?1", + params![pending_commit_id], + |row| { + Ok(PendingCommitRecord { + pending_commit_id: row.get(0)?, + operation_id: row.get(1)?, + command: row.get(2)?, + agent_did: row.get(3)?, + device_id: row.get(4)?, + group_did: row.get(5)?, + subject_did: row.get(6)?, + subject_status: row.get(7)?, + from_epoch: row.get::<_, i64>(8)? as u64, + to_epoch: row.get::<_, i64>(9)? as u64, + status: row.get(10)?, + crypto_group_id_b64u: row.get(11)?, + }) + }, + ) + .optional() + .map_err(|e| sqlite_error("state_read_failed", e, request_id))? + .ok_or_else(|| { + error( + "pending_commit_not_found", + "pending_commit_id was not found", + Some(request_id.to_owned()), + ) + }) +} + +fn update_pending_commit_status( + conn: &Connection, + pending_commit_id: &str, + status: &str, + request_id: &str, +) -> Result<(), Value> { + conn.execute( + "UPDATE pending_commits SET status = ?2, updated_at = CURRENT_TIMESTAMP WHERE pending_commit_id = ?1", + params![pending_commit_id, status], + ) + .map_err(|e| sqlite_error("state_write_failed", e, request_id))?; + Ok(()) +} + +fn binding( + conn: &Connection, + agent_did: &str, + device_id: &str, + group_did: &str, + request_id: &str, +) -> Result { + let row: Option<(String, String, i64)> = conn + .query_row( + "SELECT openmls_group_id_b64u, role, epoch FROM group_bindings WHERE agent_did = ?1 AND device_id = ?2 AND group_did = ?3 AND status = 'active'", + params![agent_did, device_id, group_did], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(|e| sqlite_error("state_read_failed", e, request_id))?; + let Some((group_id_b64u, role, epoch)) = row else { + return Err(error( + "group_not_found", + "no local MLS group binding found for agent/device/group", + Some(request_id.to_owned()), + )); + }; + Ok(Binding { + openmls_group_id: GroupId::from_slice(&decode_b64u(&group_id_b64u, request_id)?), + epoch: epoch as u64, + role, + }) +} + +fn member_leaf_index_by_did(group: &MlsGroup, member_did: &str) -> Option { + let credential: Credential = BasicCredential::new(member_did.as_bytes().to_vec()).into(); + group.member_leaf_index(&credential) +} + +fn validate_group_binding_claims( + binding: &Binding, + claims: &Value, + request_id: &str, +) -> Result<(), Value> { + let expected_group_id = encode_b64u(binding.openmls_group_id.as_slice()); + for key in ["crypto_group_id_b64u", "openmls_group_id_b64u"] { + if let Some(actual) = claims.get(key).and_then(Value::as_str) { + if actual != expected_group_id { + return Err(error( + "group_binding_mismatch", + &format!("{key} does not match the local MLS group binding"), + Some(request_id.to_owned()), + )); + } + } + } + for key in ["epoch"] { + if let Some(actual) = claims.get(key).and_then(epoch_claim_as_u64) { + if actual != binding.epoch { + return Err(error( + "group_epoch_mismatch", + &format!("{key} does not match the local MLS group epoch"), + Some(request_id.to_owned()), + )); + } + } + } + Ok(()) +} + +fn validate_loaded_group_matches_binding( + binding: &Binding, + group: &MlsGroup, + request_id: &str, +) -> Result<(), Value> { + let actual_epoch = group.epoch().as_u64(); + if actual_epoch != binding.epoch { + return Err(error( + "group_epoch_mismatch", + "local binding epoch does not match the persisted OpenMLS group epoch", + Some(request_id.to_owned()), + )); + } + Ok(()) +} + +fn validate_key_package_did_wba_binding( + params: &Value, + member_did: &str, + key_package: &KeyPackage, + request_id: &str, +) -> Result<(), Value> { + if let Some(owner_did) = params + .pointer("/group_key_package/owner_did") + .and_then(Value::as_str) + { + if owner_did != member_did { + return Err(error( + "did_wba_binding_mismatch", + "group_key_package.owner_did does not match member_did", + Some(request_id.to_owned()), + )); + } + } + let binding = params + .pointer("/group_key_package/did_wba_binding") + .or_else(|| params.get("did_wba_binding")) + .ok_or_else(|| { + error( + "missing_field", + "group_key_package.did_wba_binding is required", + Some(request_id.to_owned()), + ) + })?; + let agent_did = binding + .get("agent_did") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "invalid_did_wba_binding", + "did_wba_binding.agent_did is required", + Some(request_id.to_owned()), + ) + })?; + if agent_did != member_did { + return Err(error( + "did_wba_binding_mismatch", + "did_wba_binding.agent_did does not match member_did", + Some(request_id.to_owned()), + )); + } + let verification_method = binding + .get("verification_method") + .and_then(Value::as_str) + .filter(|value| { + value.starts_with(&format!("{member_did}#")) && value.len() > member_did.len() + 1 + }) + .ok_or_else(|| { + error( + "invalid_did_wba_binding", + "did_wba_binding.verification_method must be a fragment under member_did", + Some(request_id.to_owned()), + ) + })?; + let leaf_signature_key = binding + .get("leaf_signature_key_b64u") + .and_then(Value::as_str) + .ok_or_else(|| { + error( + "invalid_did_wba_binding", + "did_wba_binding.leaf_signature_key_b64u is required", + Some(request_id.to_owned()), + ) + })?; + let leaf_signature_key = decode_b64u(leaf_signature_key, request_id)?; + if leaf_signature_key.as_slice() != key_package.leaf_node().signature_key().as_slice() { + return Err(error( + "did_wba_binding_mismatch", + "did_wba_binding.leaf_signature_key_b64u does not match the MLS KeyPackage leaf signature key", + Some(request_id.to_owned()), + )); + } + let credential: BasicCredential = key_package + .leaf_node() + .credential() + .clone() + .try_into() + .map_err(|e| mls_error("key_package_credential_decode_failed", e, request_id))?; + if credential.identity() != member_did.as_bytes() { + return Err(error( + "did_wba_binding_mismatch", + "MLS KeyPackage credential identity does not match member_did", + Some(request_id.to_owned()), + )); + } + validate_binding_time_window(binding, request_id)?; + if let Some(proof) = binding.get("proof") { + validate_binding_proof_shape(proof, verification_method, request_id)?; + } + Ok(()) +} + +fn validate_recovery_key_package_context( + params: &Value, + group_did: &str, + member_did: &str, + target_device_id: &str, +) -> Result<(), Value> { + let package = params.get("group_key_package").ok_or_else(|| { + error( + "missing_field", + "group_key_package is required for recover-member prepare", + None, + ) + })?; + if package.get("purpose").and_then(Value::as_str) != Some("recovery") { + return Err(error( + "invalid_recovery_key_package", + "recover-member prepare requires group_key_package.purpose=recovery", + None, + )); + } + if package.get("group_did").and_then(Value::as_str) != Some(group_did) { + return Err(error( + "recovery_key_package_group_mismatch", + "group_key_package.group_did does not match group_did", + None, + )); + } + if package.get("owner_did").and_then(Value::as_str) != Some(member_did) { + return Err(error( + "recovery_key_package_did_mismatch", + "group_key_package.owner_did does not match member_did", + None, + )); + } + if package.get("device_id").and_then(Value::as_str) != Some(target_device_id) { + return Err(error( + "recovery_key_package_device_mismatch", + "group_key_package.device_id does not match target device", + None, + )); + } + Ok(()) +} + +fn validate_binding_time_window(binding: &Value, request_id: &str) -> Result<(), Value> { + let issued_at = parse_binding_time(binding, "issued_at", request_id)?; + let expires_at = parse_binding_time(binding, "expires_at", request_id)?; + if expires_at <= issued_at { + return Err(error( + "invalid_did_wba_binding", + "did_wba_binding.expires_at must be after issued_at", + Some(request_id.to_owned()), + )); + } + if expires_at <= Utc::now() { + return Err(error( + "did_wba_binding_expired", + "did_wba_binding.expires_at is in the past", + Some(request_id.to_owned()), + )); + } + Ok(()) +} + +fn parse_binding_time( + binding: &Value, + field: &'static str, + request_id: &str, +) -> Result, Value> { + let raw = binding + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "invalid_did_wba_binding", + &format!("did_wba_binding.{field} is required"), + Some(request_id.to_owned()), + ) + })?; + DateTime::parse_from_rfc3339(raw) + .map(|value| value.with_timezone(&Utc)) + .map_err(|e| { + error( + "invalid_did_wba_binding", + &format!("did_wba_binding.{field} must be RFC3339: {e}"), + Some(request_id.to_owned()), + ) + }) +} + +fn validate_binding_proof_shape( + proof: &Value, + verification_method: &str, + request_id: &str, +) -> Result<(), Value> { + let Some(proof_object) = proof.as_object() else { + return Err(error( + "invalid_did_wba_binding", + "did_wba_binding.proof must be an object when present", + Some(request_id.to_owned()), + )); + }; + let proof_verification_method = proof_object + .get("verificationMethod") + .or_else(|| proof_object.get("verification_method")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "invalid_did_wba_binding", + "did_wba_binding.proof.verificationMethod is required when proof is present", + Some(request_id.to_owned()), + ) + })?; + if proof_verification_method != verification_method { + return Err(error( + "did_wba_binding_mismatch", + "did_wba_binding.proof.verificationMethod does not match verification_method", + Some(request_id.to_owned()), + )); + } + Ok(()) +} + +fn build_message_aad( + params: &Value, + binding: &Binding, + group_state_ref: &Value, + request_id: &str, +) -> Result, Value> { + let group_did = group_state_ref + .get("group_did") + .and_then(Value::as_str) + .or_else(|| params.get("group_did").and_then(Value::as_str)) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "missing_field", + "group_state_ref.group_did is required", + Some(request_id.to_owned()), + ) + })?; + let sender_did = params + .get("sender_did") + .or_else(|| params.get("agent_did")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "missing_field", + "sender_did is required for P6 MLS AAD", + Some(request_id.to_owned()), + ) + })?; + let message_id = params + .get("message_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "missing_field", + "message_id is required for P6 MLS AAD", + Some(request_id.to_owned()), + ) + })?; + let operation_id = params + .get("operation_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + error( + "missing_field", + "operation_id is required for P6 MLS AAD", + Some(request_id.to_owned()), + ) + })?; + let content_type = params + .get("content_type") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(GROUP_CIPHER_CONTENT_TYPE); + if content_type != GROUP_CIPHER_CONTENT_TYPE { + return Err(error( + "invalid_aad_binding", + "group.e2ee.send content_type must be application/anp-group-cipher+json", + Some(request_id.to_owned()), + )); + } + let security_profile = params + .get("security_profile") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(SECURITY_PROFILE); + if security_profile != SECURITY_PROFILE { + return Err(error( + "invalid_aad_binding", + "group.e2ee.send security_profile must be group-e2ee", + Some(request_id.to_owned()), + )); + } + let value = json!({ + "content_type": content_type, + "group_did": group_did, + "crypto_group_id_b64u": encode_b64u(binding.openmls_group_id.as_slice()), + "group_state_ref": group_state_ref, + "security_profile": security_profile, + "sender_did": sender_did, + "message_id": message_id, + "operation_id": operation_id, + }); + build_send_aad(&value).map_err(|e| { + error( + "invalid_aad_binding", + &format!("build P6 send AAD: {e}"), + Some(request_id.to_owned()), + ) + }) +} + +fn epoch_claim_as_u64(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_str().and_then(|text| text.parse::().ok())) +} + +fn load_group( + provider: &SqliteMlsProvider, + group_id: &GroupId, + request_id: &str, +) -> Result { + MlsGroup::load(provider.storage(), group_id) + .map_err(|e| mls_error("group_load_failed", e, request_id))? + .ok_or_else(|| { + error( + "group_not_found", + "OpenMLS group state was not found in SQLite", + Some(request_id.to_owned()), + ) + }) +} + +fn ensure_agent( + provider: &SqliteMlsProvider, + conn: &Connection, + agent_did: &str, + device_id: &str, + request_id: &str, +) -> Result<(CredentialWithKey, SignatureKeyPair), Value> { + if let Some((public_key, scheme)) = conn + .query_row( + "SELECT signature_public_key, signature_scheme FROM agents WHERE agent_did = ?1 AND device_id = ?2", + params![agent_did, device_id], + |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|e| sqlite_error("state_read_failed", e, request_id))? + { + let signature_scheme = signature_scheme_from_name(&scheme)?; + let signer = SignatureKeyPair::read(provider.storage(), &public_key, signature_scheme).ok_or_else(|| { + error( + "agent_key_missing", + "agent signature key metadata exists but private key is missing from OpenMLS storage", + Some(request_id.to_owned()), + ) + })?; + let credential = BasicCredential::new(agent_did.as_bytes().to_vec()); + return Ok(( + CredentialWithKey { + credential: credential.into(), + signature_key: public_key.into(), + }, + signer, + )); + } + let signature_scheme = ciphersuite().signature_algorithm(); + let signer = SignatureKeyPair::new(signature_scheme) + .map_err(|e| mls_error("agent_key_generate_failed", e, request_id))?; + signer + .store(provider.storage()) + .map_err(|e| mls_error("agent_key_store_failed", e, request_id))?; + let public_key = signer.to_public_vec(); + conn.execute( + "INSERT INTO agents(agent_did, device_id, signature_public_key, signature_scheme, updated_at) + VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)", + params![agent_did, device_id, public_key, signature_scheme_name(signature_scheme)], + ) + .map_err(|e| sqlite_error("state_write_failed", e, request_id))?; + let credential = BasicCredential::new(agent_did.as_bytes().to_vec()); + Ok(( + CredentialWithKey { + credential: credential.into(), + signature_key: signer.to_public_vec().into(), + }, + signer, + )) +} + +fn load_signer( + provider: &SqliteMlsProvider, + conn: &Connection, + agent_did: &str, + device_id: &str, + request_id: &str, +) -> Result { + let (_, signer) = ensure_agent(provider, conn, agent_did, device_id, request_id)?; + Ok(signer) +} + +fn group_create_config() -> MlsGroupCreateConfig { + MlsGroupCreateConfig::builder() + .padding_size(100) + .sender_ratchet_configuration(SenderRatchetConfiguration::new(10, 2000)) + .use_ratchet_tree_extension(true) + .build() +} + +fn group_join_config() -> MlsGroupJoinConfig { + MlsGroupJoinConfig::builder() + .padding_size(100) + .sender_ratchet_configuration(SenderRatchetConfiguration::new(10, 2000)) + .use_ratchet_tree_extension(true) + .build() +} + +fn ciphersuite() -> Ciphersuite { + Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 +} + +fn signature_scheme_name(scheme: SignatureScheme) -> &'static str { + match scheme { + SignatureScheme::ED25519 => "ED25519", + SignatureScheme::ECDSA_SECP256R1_SHA256 => "ECDSA_SECP256R1_SHA256", + _ => "UNKNOWN", + } +} + +fn signature_scheme_from_name(name: &str) -> Result { + match name { + "ED25519" => Ok(SignatureScheme::ED25519), + "ECDSA_SECP256R1_SHA256" => Ok(SignatureScheme::ECDSA_SECP256R1_SHA256), + _ => Err(error("unsupported_signature_scheme", name, None)), + } +} + +fn agent_did(params: &Value) -> Result<&str, Value> { + params + .get("agent_did") + .or_else(|| params.get("owner_did")) + .or_else(|| params.get("sender_did")) + .or_else(|| params.get("recipient_did")) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + error( + "missing_field", + "agent_did/owner_did/sender_did is required", + None, + ) + }) +} + +fn device_id(params: &Value) -> &str { + params + .get("device_id") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .unwrap_or(DEVICE_ID_DEFAULT) +} + +fn did_wba_binding(owner: &str, device_id: &str, signer: &SignatureKeyPair) -> Value { + let issued_at = Utc::now(); + let expires_at = issued_at + ChronoDuration::days(365); + json!({ + "agent_did": owner, + "device_id": device_id, + "verification_method": format!("{}#{}", owner, device_id), + "leaf_signature_key_b64u": encode_b64u(&signer.to_public_vec()), + "issued_at": issued_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "expires_at": expires_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + }) +} + +fn application_plaintext_bytes(params: &Value, request_id: &str) -> Result, Value> { + let plaintext = params + .get("application_plaintext") + .or_else(|| params.get("plaintext")) + .ok_or_else(|| { + error( + "missing_field", + "application_plaintext is required", + Some(request_id.to_owned()), + ) + })?; + if let Some(text) = plaintext.get("text").and_then(Value::as_str) { + return Ok(text.as_bytes().to_vec()); + } + if let Some(payload_b64u) = plaintext.get("payload_b64u").and_then(Value::as_str) { + return decode_b64u(payload_b64u, request_id); + } + serde_json::to_vec(plaintext).map_err(|e| { + error( + "invalid_plaintext", + &e.to_string(), + Some(request_id.to_owned()), + ) + }) +} + +fn application_plaintext_value(bytes: &[u8]) -> Value { + match std::str::from_utf8(bytes) { + Ok(text) => json!({"application_content_type": "text/plain", "text": text}), + Err(_) => { + json!({"application_content_type": "application/octet-stream", "payload_b64u": encode_b64u(bytes)}) + } + } +} + +fn encode_b64u(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) +} + +fn decode_b64u(value: &str, request_id: &str) -> Result, Value> { + URL_SAFE_NO_PAD.decode(value).map_err(|e| { + error( + "invalid_base64url", + &format!("base64url decode failed: {e}"), + Some(request_id.to_owned()), + ) + }) +} + +fn digest_json(value: &Value) -> String { + let bytes = serde_json::to_vec(value).unwrap_or_default(); + encode_b64u(&Sha256::digest(bytes)) +} + +fn short_digest(value: &Value) -> String { + digest_json(value).chars().take(16).collect() +} + +fn contract_key_package(params: &Value) -> Result { + let owner = required(params, "owner_did")?; + let key_package_id = params + .get("key_package_id") + .and_then(Value::as_str) + .unwrap_or("kp-contract-1"); + let artifact = artifact("key-package", params)?; + Ok(json!({ + "group_key_package": { + "key_package_id": key_package_id, + "owner_did": owner, + "device_id": params.get("device_id").and_then(Value::as_str).unwrap_or(DEVICE_ID_DEFAULT), + "purpose": params.get("purpose").and_then(Value::as_str).unwrap_or("normal"), + "group_did": params.get("group_did").cloned(), + "suite": params.get("suite").and_then(Value::as_str).unwrap_or(MTI_SUITE), + "mls_key_package_b64u": artifact["value_b64u"], + "did_wba_binding": params.get("did_wba_binding").cloned().unwrap_or_else(|| json!({ + "agent_did": owner, + "verification_method": format!("{}#key-1", owner), + "leaf_signature_key_b64u": artifact["digest_b64u"], + "issued_at": "2026-01-01T00:00:00Z", + "expires_at": "2099-01-01T00:00:00Z", + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })), + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + }, + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_create(params: &Value) -> Result { + let group_did = required(params, "group_did")?; + let artifact = artifact("group-create", params)?; + Ok(json!({ + "group_did": group_did, + "crypto_group_id_b64u": artifact["digest_b64u"], + "epoch": "0", + "epoch_authenticator": artifact["value_b64u"], + "suite": params.get("suite").and_then(Value::as_str).unwrap_or(MTI_SUITE), + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_add_member(params: &Value) -> Result { + required(params, "group_did")?; + required(params, "member_did")?; + let artifact = artifact("group-add-member", params)?; + Ok(json!({ + "crypto_group_id_b64u": params.get("crypto_group_id_b64u").cloned().unwrap_or_else(|| artifact["digest_b64u"].clone()), + "epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("1"), + "commit_b64u": artifact["value_b64u"], + "welcome_b64u": artifact["digest_b64u"], + "ratchet_tree_b64u": artifact["value_b64u"], + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_recover_member_prepare(params: &Value) -> Result { + required(params, "group_did")?; + let subject = params + .get("member_did") + .or_else(|| params.get("target_did")) + .or_else(|| params.pointer("/target/agent_did")) + .and_then(Value::as_str) + .ok_or_else(|| { + error( + "missing_field", + "member_did/target.agent_did is required", + None, + ) + })?; + let artifact = artifact("group-recover-member", params)?; + Ok(json!({ + "pending_commit_id": params.get("pending_commit_id").and_then(Value::as_str).unwrap_or("pc-contract-recover"), + "status": "pending", + "subject_did": subject, + "subject_status": "recovered", + "crypto_group_id_b64u": params.get("crypto_group_id_b64u").cloned().unwrap_or_else(|| artifact["digest_b64u"].clone()), + "from_epoch": params.get("from_epoch").and_then(Value::as_str).unwrap_or("1"), + "epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("2"), + "to_epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("2"), + "local_epoch": params.get("from_epoch").and_then(Value::as_str).unwrap_or("1"), + "commit_b64u": artifact["value_b64u"], + "welcome_b64u": artifact["digest_b64u"], + "ratchet_tree_b64u": artifact["value_b64u"], + "epoch_authenticator": artifact["digest_b64u"], + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_remove_member(params: &Value) -> Result { + required(params, "group_did")?; + let subject = params + .get("subject_did") + .or_else(|| params.get("member_did")) + .and_then(Value::as_str) + .ok_or_else(|| error("missing_field", "subject_did/member_did is required", None))?; + let artifact = artifact("group-remove-member", params)?; + Ok(json!({ + "pending_commit_id": params.get("pending_commit_id").and_then(Value::as_str).unwrap_or("pc-contract-remove"), + "status": "pending", + "subject_did": subject, + "subject_status": "removed", + "crypto_group_id_b64u": params.get("crypto_group_id_b64u").cloned().unwrap_or_else(|| artifact["digest_b64u"].clone()), + "from_epoch": params.get("from_epoch").and_then(Value::as_str).unwrap_or("1"), + "epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("2"), + "to_epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("2"), + "local_epoch": params.get("from_epoch").and_then(Value::as_str).unwrap_or("1"), + "commit_b64u": artifact["value_b64u"], + "ratchet_tree_b64u": artifact["digest_b64u"], + "epoch_authenticator": artifact["digest_b64u"], + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_leave(params: &Value) -> Result { + required(params, "group_did")?; + let subject = params + .get("actor_did") + .or_else(|| params.get("agent_did")) + .and_then(Value::as_str) + .ok_or_else(|| error("missing_field", "actor_did/agent_did is required", None))?; + let artifact = artifact("group-leave", params)?; + Ok(json!({ + "pending_commit_id": params.get("pending_commit_id").and_then(Value::as_str).unwrap_or("pc-contract-leave"), + "status": "pending", + "subject_did": subject, + "subject_status": "left", + "crypto_group_id_b64u": params.get("crypto_group_id_b64u").cloned().unwrap_or_else(|| artifact["digest_b64u"].clone()), + "from_epoch": params.get("from_epoch").and_then(Value::as_str).unwrap_or("1"), + "epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("2"), + "to_epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("2"), + "local_epoch": params.get("from_epoch").and_then(Value::as_str).unwrap_or("1"), + "commit_b64u": artifact["value_b64u"], + "ratchet_tree_b64u": Value::Null, + "epoch_authenticator": Value::Null, + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_commit_finalize(params: &Value) -> Result { + let pending_commit_id = required(params, "pending_commit_id")?; + Ok(json!({ + "pending_commit_id": pending_commit_id, + "status": "finalized", + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_commit_abort(params: &Value) -> Result { + let pending_commit_id = required(params, "pending_commit_id")?; + Ok(json!({ + "pending_commit_id": pending_commit_id, + "status": "aborted", + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_commit_process(params: &Value) -> Result { + required(params, "commit_b64u")?; + let artifact = artifact("commit-process", params)?; + Ok(json!({ + "status": params.get("status").and_then(Value::as_str).unwrap_or("active"), + "self_removed": params.get("self_removed").and_then(Value::as_bool).unwrap_or(false), + "epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("2"), + "epoch_authenticator": artifact["digest_b64u"], + "ratchet_tree_b64u": artifact["value_b64u"], + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_welcome_process(params: &Value) -> Result { + required(params, "welcome_b64u")?; + required(params, "ratchet_tree_b64u")?; + let artifact = artifact("welcome-process", params)?; + Ok(json!({ + "crypto_group_id_b64u": params.get("crypto_group_id_b64u").cloned().unwrap_or_else(|| artifact["digest_b64u"].clone()), + "epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("1"), + "status": "active", + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_message_encrypt(params: &Value) -> Result { + let group_state_ref = params + .get("group_state_ref") + .cloned() + .ok_or_else(|| error("missing_field", "group_state_ref is required", None))?; + let artifact = artifact("message-encrypt", params)?; + Ok(json!({ + "group_cipher_object": { + "crypto_group_id_b64u": params.get("crypto_group_id_b64u").cloned().unwrap_or_else(|| artifact["digest_b64u"].clone()), + "epoch": params.get("epoch").and_then(Value::as_str).unwrap_or("0"), + "private_message_b64u": artifact["value_b64u"], + "group_state_ref": group_state_ref, + "epoch_authenticator": artifact["digest_b64u"], + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + }, + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_message_decrypt(params: &Value) -> Result { + required(params, "private_message_b64u")?; + Ok(json!({ + "application_plaintext": params.get("application_plaintext").cloned().unwrap_or_else(|| json!({ + "application_content_type": "text/plain", + "text": "contract-test plaintext" + })), + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn contract_group_status(params: &Value, data_dir: Option<&PathBuf>) -> Result { + let operations_logged = data_dir + .and_then(|dir| contract_operation_log_len(dir).ok()) + .unwrap_or_default(); + Ok(json!({ + "group_did": params.get("group_did").cloned().unwrap_or(Value::Null), + "data_dir": data_dir.map(|path| path.to_string_lossy().to_string()), + "operations_logged": operations_logged, + "status": "contract-test-ready", + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE + })) +} + +fn append_contract_operation_log( + data_dir: &PathBuf, + request_id: &str, + command: &str, +) -> Result<(), Value> { + fs::create_dir_all(data_dir) + .map_err(|e| error("state_write_failed", &format!("create data dir: {e}"), None))?; + let state_path = data_dir.join("contract-operations.jsonl"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .unwrap_or_default(); + let record = json!({ + "request_id": request_id, + "command": command, + "created_at_unix": now, + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE, + }); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&state_path) + .map_err(|e| { + error( + "state_write_failed", + &format!("open operation log: {e}"), + None, + ) + })?; + writeln!( + file, + "{}", + serde_json::to_string(&record).map_err(|e| error( + "state_write_failed", + &format!("encode operation log: {e}"), + None + ))? + ) + .map_err(|e| { + error( + "state_write_failed", + &format!("write operation log: {e}"), + None, + ) + })?; + Ok(()) +} + +fn contract_operation_log_len(data_dir: &PathBuf) -> io::Result { + let state_path = data_dir.join("contract-operations.jsonl"); + let contents = fs::read_to_string(state_path)?; + Ok(contents.lines().count()) +} + +fn artifact(purpose: &str, params: &Value) -> Result { + let artifact = deterministic_contract_artifact(purpose, params, true) + .map_err(|e| error("artifact_failed", &e.to_string(), None))?; + serde_json::to_value(artifact).map_err(|e| error("artifact_failed", &e.to_string(), None)) +} + +fn required<'a>(value: &'a Value, field: &'static str) -> Result<&'a str, Value> { + value + .get(field) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .ok_or_else(|| error("missing_field", &format!("{field} is required"), None)) +} + +fn sqlite_error(code: &str, err: rusqlite::Error, request_id: &str) -> Value { + error(code, &err.to_string(), Some(request_id.to_owned())) +} + +fn mls_error(code: &str, err: impl std::fmt::Display, request_id: &str) -> Value { + error(code, &err.to_string(), Some(request_id.to_owned())) +} + +fn error(code: &str, message: &str, request_id: Option) -> Value { + json!({ + "ok": false, + "api_version": API_VERSION, + "request_id": request_id, + "error": {"code": code, "message": message} + }) +} diff --git a/rust/src/group_e2ee/mod.rs b/rust/src/group_e2ee/mod.rs new file mode 100644 index 0000000..bff86b4 --- /dev/null +++ b/rust/src/group_e2ee/mod.rs @@ -0,0 +1,476 @@ +//! P6 wire helpers for ANP group E2EE. +//! +//! This module owns P6 data models, canonical AAD helpers, and the explicit +//! non-cryptographic contract-test artifact generator. Real OpenMLS group +//! operations live in the `anp-mls` binary so SDK/product integrations can share +//! wire semantics without embedding local MLS private state in this helper module. + +use crate::canonical_json::{canonicalize_json, CanonicalJsonError}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const PROFILE: &str = "anp.group.e2ee.v1"; +pub const SECURITY_PROFILE: &str = "group-e2ee"; +pub const TRANSPORT_SECURITY_PROFILE: &str = "transport-protected"; +pub const CONTRACT_ARTIFACT_MODE: &str = "contract-test"; +pub const MTI_SUITE: &str = "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519"; +pub const METHOD_LEAVE_REQUEST: &str = "group.e2ee.leave_request"; +pub const METHOD_LEAVE_REQUEST_PROCESS: &str = "group.e2ee.process_leave_request"; +pub const METHOD_RECOVER_MEMBER: &str = "group.e2ee.recover_member"; + +#[derive(Debug, Error)] +pub enum GroupE2eeError { + #[error("P6 contract-test mode is disabled")] + ContractModeDisabled, + #[error("missing required field: {0}")] + MissingField(&'static str), + #[error("invalid field: {0}")] + InvalidField(&'static str), + #[error(transparent)] + CanonicalJson(#[from] CanonicalJsonError), + #[error(transparent)] + Serde(#[from] serde_json::Error), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupStateRef { + pub group_did: String, + pub group_state_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupKeyPackage { + pub key_package_id: String, + pub owner_did: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_did: Option, + pub suite: String, + pub mls_key_package_b64u: String, + pub did_wba_binding: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub non_cryptographic: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RecoverMemberTarget { + pub agent_did: String, + pub device_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RecoverMemberRequestObject { + pub operation_id: String, + pub group_did: String, + pub actor_did: String, + pub target: RecoverMemberTarget, + pub group_state_ref: GroupStateRef, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery_key_package_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_key_package: Option, + pub commit_b64u: String, + pub welcome_b64u: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ratchet_tree_b64u: Option, + pub epoch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch_authenticator: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub non_cryptographic: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RecoverMemberFinalizeRequestObject { + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_id: Option, + pub pending_commit_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RecoverMemberAbortRequestObject { + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_id: Option, + pub pending_commit_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupCipherObject { + pub crypto_group_id_b64u: String, + pub epoch: String, + pub private_message_b64u: String, + pub group_state_ref: GroupStateRef, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch_authenticator: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub non_cryptographic: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupLeaveRequestObject { + pub leave_request_id: String, + pub group_did: String, + pub requester_did: String, + pub group_state_ref: GroupStateRef, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_at: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub non_cryptographic: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupLeaveRequestProcessObject { + pub leave_request_id: String, + pub group_did: String, + pub requester_did: String, + pub processor_did: String, + pub group_state_ref: GroupStateRef, + pub crypto_group_id_b64u: String, + pub epoch: String, + pub commit_b64u: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch_authenticator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason_text: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub non_cryptographic: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct E2eeNoticeObject { + pub notice_id: String, + pub notice_type: String, + pub group_did: String, + pub group_state_ref: GroupStateRef, + pub crypto_group_id_b64u: String, + pub epoch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject_did: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_b64u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub welcome_b64u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ratchet_tree_b64u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch_authenticator: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub non_cryptographic: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GroupApplicationPlaintext { + pub application_content_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to_message_id: Option, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub annotations: serde_json::Map, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload_b64u: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContractArtifact { + pub value_b64u: String, + pub digest_b64u: String, + pub non_cryptographic: bool, + pub artifact_mode: String, +} + +pub fn build_leave_request_control_aad(value: &Value) -> Result, GroupE2eeError> { + let method = value + .get("method") + .and_then(Value::as_str) + .ok_or(GroupE2eeError::MissingField("method"))?; + match method { + METHOD_LEAVE_REQUEST => { + for field in [ + "method", + "security_profile", + "group_did", + "group_state_ref", + "requester_did", + "operation_id", + ] { + if value.get(field).is_none() { + return Err(GroupE2eeError::MissingField(field)); + } + } + if value.get("security_profile").and_then(Value::as_str) + != Some(TRANSPORT_SECURITY_PROFILE) + { + return Err(GroupE2eeError::InvalidField("security_profile")); + } + } + METHOD_LEAVE_REQUEST_PROCESS => { + for field in [ + "method", + "security_profile", + "group_did", + "group_state_ref", + "leave_request_id", + "requester_did", + "processor_did", + "crypto_group_id_b64u", + "epoch", + "commit_b64u", + "operation_id", + ] { + if value.get(field).is_none() { + return Err(GroupE2eeError::MissingField(field)); + } + } + if value.get("security_profile").and_then(Value::as_str) != Some(SECURITY_PROFILE) { + return Err(GroupE2eeError::InvalidField("security_profile")); + } + } + _ => return Err(GroupE2eeError::InvalidField("method")), + } + Ok(canonicalize_json(value)?) +} + +pub fn build_send_aad(value: &Value) -> Result, GroupE2eeError> { + for field in [ + "content_type", + "group_did", + "crypto_group_id_b64u", + "group_state_ref", + "security_profile", + "sender_did", + "message_id", + "operation_id", + ] { + if value.get(field).is_none() { + return Err(GroupE2eeError::MissingField(field)); + } + } + Ok(canonicalize_json(value)?) +} + +pub fn deterministic_contract_artifact( + purpose: &str, + input: &Value, + enabled: bool, +) -> Result { + if !enabled { + return Err(GroupE2eeError::ContractModeDisabled); + } + let canonical = canonicalize_json(input)?; + let mut hasher = Sha256::new(); + hasher.update(b"ANP-P6-CONTRACT-TEST\0"); + hasher.update(purpose.as_bytes()); + hasher.update(b"\0"); + hasher.update(&canonical); + let digest = hasher.finalize(); + let digest_b64u = URL_SAFE_NO_PAD.encode(digest); + let value = json!({ + "purpose": purpose, + "digest_b64u": digest_b64u, + "non_cryptographic": true, + "artifact_mode": CONTRACT_ARTIFACT_MODE, + }); + Ok(ContractArtifact { + value_b64u: URL_SAFE_NO_PAD.encode(canonicalize_json(&value)?), + digest_b64u, + non_cryptographic: true, + artifact_mode: CONTRACT_ARTIFACT_MODE.to_owned(), + }) +} + +fn is_false(value: &bool) -> bool { + !*value +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn leave_request_control_aad_has_stable_golden_vector() { + let aad = build_leave_request_control_aad(&json!({ + "method": METHOD_LEAVE_REQUEST, + "operation_id": "op-leave-request", + "security_profile": TRANSPORT_SECURITY_PROFILE, + "group_did": "did:wba:example.com:groups:golden:e1", + "requester_did": "did:wba:example.com:users:bob:e1", + "reason_text": "leaving this workspace", + "group_state_ref": { + "group_state_version": "7", + "group_did": "did:wba:example.com:groups:golden:e1" + } + })) + .expect("leave request aad"); + assert_eq!( + String::from_utf8(aad).expect("utf8 aad"), + r#"{"group_did":"did:wba:example.com:groups:golden:e1","group_state_ref":{"group_did":"did:wba:example.com:groups:golden:e1","group_state_version":"7"},"method":"group.e2ee.leave_request","operation_id":"op-leave-request","reason_text":"leaving this workspace","requester_did":"did:wba:example.com:users:bob:e1","security_profile":"transport-protected"}"# + ); + } + + #[test] + fn leave_request_process_requires_epoch_advancing_commit_fields() { + let err = build_leave_request_control_aad(&json!({ + "method": METHOD_LEAVE_REQUEST_PROCESS, + "operation_id": "op-process", + "security_profile": SECURITY_PROFILE, + "group_did": "did:wba:example.com:groups:golden:e1", + "group_state_ref": { + "group_state_version": "7", + "group_did": "did:wba:example.com:groups:golden:e1" + }, + "leave_request_id": "leave-req-1", + "requester_did": "did:wba:example.com:users:bob:e1", + "processor_did": "did:wba:example.com:users:alice:e1", + "crypto_group_id_b64u": "Y3J5cHRv", + "epoch": "8" + })) + .expect_err("missing commit"); + assert!(matches!(err, GroupE2eeError::MissingField("commit_b64u"))); + } + + #[test] + fn recover_member_wire_model_carries_recovery_bound_package() { + let request = RecoverMemberRequestObject { + operation_id: "op-recover-bob".to_owned(), + group_did: "did:wba:example.com:groups:golden:e1".to_owned(), + actor_did: "did:wba:example.com:users:alice:e1".to_owned(), + target: RecoverMemberTarget { + agent_did: "did:wba:example.com:users:bob:e1".to_owned(), + device_id: "phone".to_owned(), + }, + group_state_ref: GroupStateRef { + group_did: "did:wba:example.com:groups:golden:e1".to_owned(), + group_state_version: "7".to_owned(), + policy_hash: None, + }, + recovery_key_package_id: Some("kp-recovery-bob".to_owned()), + group_key_package: Some(GroupKeyPackage { + key_package_id: "kp-recovery-bob".to_owned(), + owner_did: "did:wba:example.com:users:bob:e1".to_owned(), + device_id: Some("phone".to_owned()), + purpose: Some("recovery".to_owned()), + group_did: Some("did:wba:example.com:groups:golden:e1".to_owned()), + suite: MTI_SUITE.to_owned(), + mls_key_package_b64u: "bWxzLWtleS1wYWNrYWdl".to_owned(), + did_wba_binding: json!({ + "agent_did": "did:wba:example.com:users:bob:e1", + "device_id": "phone" + }), + expires_at: None, + non_cryptographic: false, + artifact_mode: None, + }), + commit_b64u: "Y29tbWl0".to_owned(), + welcome_b64u: "d2VsY29tZQ".to_owned(), + ratchet_tree_b64u: Some("cmF0Y2hldA".to_owned()), + epoch: "8".to_owned(), + epoch_authenticator: Some("YXV0aA".to_owned()), + non_cryptographic: false, + artifact_mode: None, + }; + let encoded = serde_json::to_string(&request).expect("recover member json"); + assert!(encoded.contains(r#""purpose":"recovery""#)); + assert!(encoded.contains(r#""welcome_b64u":"d2VsY29tZQ""#)); + assert!(!encoded.contains("plaintext")); + assert_eq!(METHOD_RECOVER_MEMBER, "group.e2ee.recover_member"); + } + + #[test] + fn recover_member_finalize_abort_models_carry_pending_commit_id() { + let finalize = RecoverMemberFinalizeRequestObject { + operation_id: Some("op-finalize".to_owned()), + pending_commit_id: "pc-recover".to_owned(), + }; + let abort = RecoverMemberAbortRequestObject { + operation_id: Some("op-abort".to_owned()), + pending_commit_id: "pc-recover".to_owned(), + }; + for encoded in [ + serde_json::to_string(&finalize).expect("finalize json"), + serde_json::to_string(&abort).expect("abort json"), + ] { + assert!(encoded.contains(r#""pending_commit_id":"pc-recover""#)); + assert!(encoded.contains(r#""operation_id":"op-"#)); + } + } + + #[test] + fn send_aad_canonicalizes_required_fields() { + let aad = build_send_aad(&json!({ + "sender_did": "did:wba:example:alice", + "operation_id": "op-1", + "message_id": "msg-1", + "security_profile": "group-e2ee", + "content_type": "application/anp-group-cipher+json", + "crypto_group_id_b64u": "Y3J5cHRv", + "group_did": "did:wba:groups.example:team:e1_x", + "group_state_ref": {"group_did":"did:wba:groups.example:team:e1_x","group_state_version":"1"} + })) + .expect("aad"); + assert!(String::from_utf8(aad).unwrap().starts_with("{")); + } + + #[test] + fn send_aad_has_stable_p6_golden_vector() { + let aad = build_send_aad(&json!({ + "operation_id": "op-golden", + "message_id": "msg-golden", + "sender_did": "did:wba:example.com:users:alice:e1", + "security_profile": "group-e2ee", + "group_did": "did:wba:example.com:groups:golden:e1", + "content_type": "application/anp-group-cipher+json", + "crypto_group_id_b64u": "ZGlkOndiYTpleGFtcGxlLmNvbTpncm91cHM6Z29sZGVuOmUx", + "group_state_ref": { + "policy_hash": "sha256:policy", + "group_state_version": "7", + "group_did": "did:wba:example.com:groups:golden:e1" + } + })) + .expect("aad"); + assert_eq!( + String::from_utf8(aad).expect("utf8 aad"), + r#"{"content_type":"application/anp-group-cipher+json","crypto_group_id_b64u":"ZGlkOndiYTpleGFtcGxlLmNvbTpncm91cHM6Z29sZGVuOmUx","group_did":"did:wba:example.com:groups:golden:e1","group_state_ref":{"group_did":"did:wba:example.com:groups:golden:e1","group_state_version":"7","policy_hash":"sha256:policy"},"message_id":"msg-golden","operation_id":"op-golden","security_profile":"group-e2ee","sender_did":"did:wba:example.com:users:alice:e1"}"# + ); + } + + #[test] + fn contract_artifact_requires_explicit_enablement() { + let err = deterministic_contract_artifact("cipher", &json!({"x": 1}), false) + .expect_err("disabled"); + assert!(matches!(err, GroupE2eeError::ContractModeDisabled)); + let artifact = + deterministic_contract_artifact("cipher", &json!({"x": 1}), true).expect("artifact"); + assert!(artifact.non_cryptographic); + assert_eq!(artifact.artifact_mode, CONTRACT_ARTIFACT_MODE); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 18ed049..950bf99 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2,6 +2,7 @@ mod canonical_json; pub mod direct_e2ee; +pub mod group_e2ee; mod keys; pub mod authentication; diff --git a/rust/tests/group_e2ee_contract_tests.rs b/rust/tests/group_e2ee_contract_tests.rs new file mode 100644 index 0000000..3640455 --- /dev/null +++ b/rust/tests/group_e2ee_contract_tests.rs @@ -0,0 +1,140 @@ +use anp::group_e2ee::{deterministic_contract_artifact, CONTRACT_ARTIFACT_MODE}; +use serde_json::{json, Value}; +use std::process::{Command, Stdio}; +use tempfile::tempdir; + +fn run_contract_anp_mls( + data_dir: &std::path::Path, + domain: &str, + action: &str, + request: Value, +) -> Value { + let mut child = Command::new(env!("CARGO_BIN_EXE_anp-mls")) + .args([ + domain, + action, + "--json-in", + "-", + "--data-dir", + data_dir.to_str().expect("temp path"), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn anp-mls"); + serde_json::to_writer(child.stdin.as_mut().expect("stdin"), &request).expect("write request"); + drop(child.stdin.take()); + let output = child.wait_with_output().expect("output"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json response") +} + +#[test] +fn deterministic_contract_artifact_is_marked_non_crypto() { + let artifact = + deterministic_contract_artifact("unit", &json!({"b": 2, "a": 1}), true).expect("artifact"); + assert!(artifact.non_cryptographic); + assert_eq!(artifact.artifact_mode, CONTRACT_ARTIFACT_MODE); + assert!(!artifact.value_b64u.is_empty()); +} + +#[test] +fn anp_mls_contract_binary_covers_recover_member_terminal_steps() { + let data_dir = tempdir().expect("temp data dir"); + let prepare = run_contract_anp_mls( + data_dir.path(), + "group", + "recover-member-prepare", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-prepare", + "operation_id": "op-recover-prepare", + "contract_test_enabled": true, + "params": { + "group_did": "did:wba:example.com:groups:demo:e1", + "member_did": "did:wba:example.com:users:bob:e1", + "pending_commit_id": "pc-recover" + } + }), + ); + assert_eq!(prepare["result"]["status"], "pending"); + assert_eq!(prepare["result"]["pending_commit_id"], "pc-recover"); + assert_eq!(prepare["result"]["subject_status"], "recovered"); + assert_eq!(prepare["result"]["non_cryptographic"], true); + assert_eq!(prepare["result"]["artifact_mode"], CONTRACT_ARTIFACT_MODE); + + let finalized = run_contract_anp_mls( + data_dir.path(), + "group", + "recover-member-finalize", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-finalize", + "operation_id": "op-recover-finalize", + "contract_test_enabled": true, + "params": {"pending_commit_id": "pc-recover"} + }), + ); + assert_eq!(finalized["result"]["status"], "finalized"); + assert_eq!(finalized["result"]["pending_commit_id"], "pc-recover"); + + let aborted = run_contract_anp_mls( + data_dir.path(), + "group", + "recover-member-abort", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-abort", + "operation_id": "op-recover-abort", + "contract_test_enabled": true, + "params": {"pending_commit_id": "pc-recover"} + }), + ); + assert_eq!(aborted["result"]["status"], "aborted"); + assert_eq!(aborted["result"]["pending_commit_id"], "pc-recover"); +} + +#[test] +fn anp_mls_contract_binary_uses_stdin_json_and_marks_artifacts() { + let data_dir = tempdir().expect("temp data dir"); + let mut child = Command::new(env!("CARGO_BIN_EXE_anp-mls")) + .args([ + "key-package", + "generate", + "--json-in", + "-", + "--data-dir", + data_dir.path().to_str().expect("temp path"), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn anp-mls"); + serde_json::to_writer( + child.stdin.as_mut().expect("stdin"), + &json!({ + "api_version": "anp-mls/v1", + "request_id": "req-1", + "contract_test_enabled": true, + "params": {"owner_did": "did:wba:example.com:users:alice:e1"} + }), + ) + .expect("write request"); + drop(child.stdin.take()); + + let output = child.wait_with_output().expect("output"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let response: Value = serde_json::from_slice(&output.stdout).expect("json response"); + assert_eq!(response["ok"], true); + assert_eq!(response["result"]["non_cryptographic"], true); + assert_eq!(response["result"]["artifact_mode"], CONTRACT_ARTIFACT_MODE); + assert!(data_dir.path().join("contract-operations.jsonl").exists()); +} diff --git a/rust/tests/group_e2ee_real_mls_tests.rs b/rust/tests/group_e2ee_real_mls_tests.rs new file mode 100644 index 0000000..58f1557 --- /dev/null +++ b/rust/tests/group_e2ee_real_mls_tests.rs @@ -0,0 +1,1368 @@ +use fs2::FileExt; +use rusqlite::Connection; +use serde_json::{json, Value}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use tempfile::tempdir; + +fn run_anp_mls(data_dir: &Path, domain: &str, action: &str, request: Value) -> Value { + let mut child = Command::new(env!("CARGO_BIN_EXE_anp-mls")) + .args([ + domain, + action, + "--json-in", + "-", + "--data-dir", + data_dir.to_str().expect("data dir path"), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn anp-mls"); + serde_json::to_writer(child.stdin.as_mut().expect("stdin"), &request).expect("write request"); + drop(child.stdin.take()); + let output = child.wait_with_output().expect("output"); + assert!( + output.status.success(), + "request={request}\nstdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json response") +} + +fn run_anp_mls_error(data_dir: &Path, domain: &str, action: &str, request: Value) -> Value { + let mut child = Command::new(env!("CARGO_BIN_EXE_anp-mls")) + .args([ + domain, + action, + "--json-in", + "-", + "--data-dir", + data_dir.to_str().expect("data dir path"), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn anp-mls"); + serde_json::to_writer(child.stdin.as_mut().expect("stdin"), &request).expect("write request"); + drop(child.stdin.take()); + let output = child.wait_with_output().expect("output"); + assert!( + !output.status.success(), + "expected failure, stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json error") +} + +fn run_anp_mls_no_data_dir(domain: &str, action: &str, request: Value) -> Value { + let mut child = Command::new(env!("CARGO_BIN_EXE_anp-mls")) + .args([domain, action, "--json-in", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn anp-mls"); + serde_json::to_writer(child.stdin.as_mut().expect("stdin"), &request).expect("write request"); + drop(child.stdin.take()); + let output = child.wait_with_output().expect("output"); + assert!( + output.status.success(), + "request={request}\nstdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json response") +} + +fn alice() -> &'static str { + "did:wba:example.com:users:alice:e1" +} + +fn bob() -> &'static str { + "did:wba:example.com:users:bob:e1" +} + +fn bootstrap_alice_bob_group_without_welcome( + alice_dir: &Path, + bob_dir: &Path, + group_did: &str, +) -> Value { + let bob_kp = run_anp_mls( + bob_dir, + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bootstrap-bob-kp", + "operation_id": "op-bootstrap-bob-kp", + "params": {"owner_did": bob(), "device_id": "phone"} + }), + ); + run_anp_mls( + alice_dir, + "group", + "create", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bootstrap-create", + "operation_id": "op-bootstrap-create", + "params": {"agent_did": alice(), "device_id": "phone", "group_did": group_did} + }), + ); + let add = run_anp_mls( + alice_dir, + "group", + "add-member", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bootstrap-add", + "operation_id": "op-bootstrap-add", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "group_key_package": bob_kp["result"]["group_key_package"].clone() + } + }), + ); + add +} + +fn bootstrap_alice_bob_group(alice_dir: &Path, bob_dir: &Path, group_did: &str) -> Value { + let add = bootstrap_alice_bob_group_without_welcome(alice_dir, bob_dir, group_did); + run_anp_mls( + bob_dir, + "welcome", + "process", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bootstrap-welcome", + "operation_id": "op-bootstrap-welcome", + "params": { + "agent_did": bob(), + "device_id": "phone", + "group_did": group_did, + "welcome_b64u": add["result"]["welcome_b64u"].as_str().expect("welcome"), + "ratchet_tree_b64u": add["result"]["ratchet_tree_b64u"].as_str().expect("ratchet tree") + } + }), + ); + add +} + +fn encrypt_text( + data_dir: &Path, + sender_did: &str, + group_did: &str, + epoch: &str, + op: &str, + message_id: &str, + text: &str, +) -> Value { + run_anp_mls( + data_dir, + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": format!("req-{op}"), + "operation_id": op, + "params": { + "sender_did": sender_did, + "device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "epoch": epoch, + }, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": message_id, + "operation_id": op, + "application_plaintext": {"application_content_type": "text/plain", "text": text} + } + }), + ) +} + +#[test] +fn group_e2ee_anp_mls_system_version_probe_is_stable_json() { + let response = run_anp_mls_no_data_dir( + "system", + "version", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-version-probe" + }), + ); + assert_eq!(response["ok"], true); + assert_eq!(response["api_version"], "anp-mls/v1"); + assert_eq!(response["request_id"], "req-version-probe"); + assert_eq!(response["result"]["api_version"], "anp-mls/v1"); + assert_eq!(response["result"]["binary_name"], "anp-mls"); + assert!(response["result"]["binary_version"] + .as_str() + .unwrap() + .starts_with("0.")); + let supported_commands = response["result"]["supported_commands"] + .as_array() + .expect("supported commands"); + assert!(supported_commands + .iter() + .any(|value| value.as_str() == Some("system version"))); + assert!(supported_commands + .iter() + .any(|value| value.as_str() == Some("message encrypt"))); + assert!(supported_commands + .iter() + .any(|value| value.as_str() == Some("group remove-member"))); + assert!(supported_commands + .iter() + .any(|value| value.as_str() == Some("group commit-finalize"))); +} + +#[test] +fn group_e2ee_anp_mls_create_add_welcome_encrypt_decrypt_round_trip() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:mls-demo:e1"; + + let bob_kp = run_anp_mls( + bob_dir.path(), + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-kp", + "operation_id": "op-bob-kp", + "params": {"owner_did": bob(), "device_id": "phone"} + }), + ); + assert_eq!(bob_kp["ok"], true); + assert!( + bob_kp["result"]["group_key_package"]["mls_key_package_b64u"] + .as_str() + .unwrap() + .len() + > 64 + ); + + let create = run_anp_mls( + alice_dir.path(), + "group", + "create", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-create", + "operation_id": "op-create", + "params": {"agent_did": alice(), "device_id": "phone", "group_did": group_did} + }), + ); + assert_eq!(create["result"]["epoch"], "0"); + + let add = run_anp_mls( + alice_dir.path(), + "group", + "add-member", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-add", + "operation_id": "op-add", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "group_key_package": bob_kp["result"]["group_key_package"].clone() + } + }), + ); + assert_eq!(add["result"]["epoch"], "1"); + let welcome_b64u = add["result"]["welcome_b64u"].as_str().expect("welcome"); + let ratchet_tree_b64u = add["result"]["ratchet_tree_b64u"] + .as_str() + .expect("ratchet tree"); + assert!(!welcome_b64u.is_empty()); + assert!(!ratchet_tree_b64u.is_empty()); + + let welcome = run_anp_mls( + bob_dir.path(), + "welcome", + "process", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-welcome", + "operation_id": "op-welcome", + "params": {"agent_did": bob(), "device_id": "phone", "group_did": group_did, "welcome_b64u": welcome_b64u, "ratchet_tree_b64u": ratchet_tree_b64u} + }), + ); + assert_eq!(welcome["result"]["status"], "active"); + + let secret = "real OpenMLS hello from Alice"; + let encrypted = run_anp_mls( + alice_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-encrypt", + "operation_id": "op-encrypt", + "params": { + "sender_did": alice(), + "device_id": "phone", + "group_state_ref": {"group_did": group_did, "group_state_version": "1"}, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-encrypt", + "operation_id": "op-encrypt", + "application_plaintext": {"application_content_type": "text/plain", "text": secret} + } + }), + ); + let cipher = encrypted["result"]["group_cipher_object"].clone(); + assert_ne!(cipher["private_message_b64u"].as_str().unwrap(), secret); + + let decrypted = run_anp_mls( + bob_dir.path(), + "message", + "decrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-decrypt", + "operation_id": "op-decrypt", + "params": { + "recipient_did": bob(), + "device_id": "phone", + "group_did": group_did, + "sender_did": alice(), + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-encrypt", + "operation_id": "op-encrypt", + "group_cipher_object": cipher + } + }), + ); + assert_eq!(decrypted["result"]["application_plaintext"]["text"], secret); + + let status = run_anp_mls( + bob_dir.path(), + "group", + "status", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-status", + "operation_id": "op-bob-status", + "params": {"agent_did": bob(), "device_id": "phone", "group_did": group_did} + }), + ); + assert_eq!(status["result"]["status"], "active"); + assert!(alice_dir.path().join("state.db").exists()); + assert!(bob_dir.path().join("state.db").exists()); +} + +#[test] +fn group_e2ee_remove_member_prepares_pending_commit_then_finalize_advances_epoch() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:remove-member:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + assert_eq!(add["result"]["epoch"], "1"); + + let remove = run_anp_mls( + alice_dir.path(), + "group", + "remove-member", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-remove-bob", + "operation_id": "op-remove-bob", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "group_state_ref": { + "group_did": group_did, + "epoch": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + } + } + }), + ); + assert_eq!(remove["result"]["status"], "pending"); + assert_eq!(remove["result"]["subject_did"], bob()); + assert_eq!(remove["result"]["subject_status"], "removed"); + assert_eq!(remove["result"]["from_epoch"], "1"); + assert_eq!(remove["result"]["epoch"], "2"); + assert_eq!(remove["result"]["local_epoch"], "1"); + assert!(remove["result"]["pending_commit_id"].as_str().is_some()); + assert!(remove["result"]["commit_b64u"].as_str().unwrap().len() > 64); + + let status_before_finalize = run_anp_mls( + alice_dir.path(), + "group", + "status", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-remove-status-before-finalize", + "operation_id": "op-remove-status-before-finalize", + "params": {"agent_did": alice(), "device_id": "phone", "group_did": group_did} + }), + ); + assert_eq!(status_before_finalize["result"]["epoch"], "1"); + + let replay = run_anp_mls( + alice_dir.path(), + "group", + "remove-member", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-remove-bob-replay", + "operation_id": "op-remove-bob", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "group_state_ref": { + "group_did": group_did, + "epoch": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + } + } + }), + ); + assert_eq!(replay["result"], remove["result"]); + assert_eq!(replay["request_id"], "req-remove-bob-replay"); + + let finalized = run_anp_mls( + alice_dir.path(), + "group", + "commit-finalize", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-remove-finalize", + "operation_id": "op-remove-finalize", + "params": { + "pending_commit_id": remove["result"]["pending_commit_id"].as_str().unwrap() + } + }), + ); + assert_eq!(finalized["result"]["status"], "finalized"); + assert_eq!(finalized["result"]["epoch"], "2"); + + let post_remove = encrypt_text( + alice_dir.path(), + alice(), + group_did, + "2", + "op-post-remove-encrypt", + "msg-post-remove", + "Bob must not decrypt this", + ); + let cannot_decrypt = run_anp_mls_error( + bob_dir.path(), + "message", + "decrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-decrypt-post-remove", + "operation_id": "op-bob-decrypt-post-remove", + "params": { + "recipient_did": bob(), + "device_id": "phone", + "group_did": group_did, + "sender_did": alice(), + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-post-remove", + "operation_id": "op-post-remove-encrypt", + "group_cipher_object": post_remove["result"]["group_cipher_object"].clone() + } + }), + ); + assert_eq!(cannot_decrypt["error"]["code"], "group_epoch_mismatch"); + + let processed = run_anp_mls( + bob_dir.path(), + "commit", + "process", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-process-remove", + "operation_id": "op-bob-process-remove", + "params": { + "recipient_did": bob(), + "device_id": "phone", + "group_did": group_did, + "from_epoch": "1", + "commit_b64u": remove["result"]["commit_b64u"].as_str().unwrap(), + "subject_did": bob(), + "subject_status": "removed" + } + }), + ); + assert_eq!(processed["result"]["self_removed"], true); + assert_eq!(processed["result"]["status"], "inactive"); + + let bob_send = run_anp_mls_error( + bob_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-send-after-remove", + "operation_id": "op-bob-send-after-remove", + "params": { + "sender_did": bob(), + "device_id": "phone", + "group_state_ref": {"group_did": group_did, "epoch": "2"}, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-bob-after-remove", + "operation_id": "op-bob-send-after-remove", + "application_plaintext": {"application_content_type": "text/plain", "text": "blocked"} + } + }), + ); + assert_eq!(bob_send["error"]["code"], "group_not_found"); +} + +#[test] +fn group_e2ee_remove_pending_commit_abort_clears_without_advancing_epoch() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:remove-abort:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + + let remove = run_anp_mls( + alice_dir.path(), + "group", + "remove-member", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-remove-abort", + "operation_id": "op-remove-abort", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "group_state_ref": {"group_did": group_did, "epoch": "1", "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone()} + } + }), + ); + let aborted = run_anp_mls( + alice_dir.path(), + "group", + "commit-abort", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-remove-abort-clear", + "operation_id": "op-remove-abort-clear", + "params": {"pending_commit_id": remove["result"]["pending_commit_id"].as_str().unwrap()} + }), + ); + assert_eq!(aborted["result"]["status"], "aborted"); + assert_eq!(aborted["result"]["local_epoch"], "1"); + + let still_epoch_one = encrypt_text( + alice_dir.path(), + alice(), + group_did, + "1", + "op-after-abort-encrypt", + "msg-after-abort", + "still epoch one", + ); + assert_eq!( + still_epoch_one["result"]["group_cipher_object"]["epoch"], + "1" + ); +} + +#[test] +fn group_e2ee_recover_member_prepare_finalize_replaces_lost_local_state() { + let alice_dir = tempdir().expect("alice state"); + let bob_initial_dir = tempdir().expect("bob initial state"); + let bob_recovered_dir = tempdir().expect("bob recovered state"); + let group_did = "did:wba:example.com:groups:recover-member:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_initial_dir.path(), group_did); + assert_eq!(add["result"]["epoch"], "1"); + + let recovery_kp = run_anp_mls( + bob_recovered_dir.path(), + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recovery-kp", + "operation_id": "op-recovery-kp", + "params": { + "owner_did": bob(), + "device_id": "phone", + "purpose": "recovery", + "group_did": group_did + } + }), + ); + assert_eq!( + recovery_kp["result"]["group_key_package"]["purpose"], + "recovery" + ); + + let recovery = run_anp_mls( + alice_dir.path(), + "group", + "recover-member-prepare", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-prepare", + "operation_id": "op-recover-bob", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "target_device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "epoch": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "group_key_package": recovery_kp["result"]["group_key_package"].clone() + } + }), + ); + assert_eq!(recovery["result"]["status"], "pending"); + assert_eq!(recovery["result"]["subject_status"], "recovered"); + assert_eq!(recovery["result"]["from_epoch"], "1"); + assert_eq!(recovery["result"]["epoch"], "2"); + assert_eq!(recovery["result"]["local_epoch"], "1"); + assert!(recovery["result"]["welcome_b64u"].as_str().unwrap().len() > 64); + + let status_before_finalize = run_anp_mls( + alice_dir.path(), + "group", + "status", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-status-before-finalize", + "operation_id": "op-recover-status-before-finalize", + "params": {"agent_did": alice(), "device_id": "phone", "group_did": group_did} + }), + ); + assert_eq!(status_before_finalize["result"]["epoch"], "1"); + + let replay = run_anp_mls( + alice_dir.path(), + "group", + "recover-member-prepare", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-prepare-replay", + "operation_id": "op-recover-bob", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "target_device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "epoch": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "group_key_package": recovery_kp["result"]["group_key_package"].clone() + } + }), + ); + assert_eq!(replay["result"], recovery["result"]); + + let finalized = run_anp_mls( + alice_dir.path(), + "group", + "recover-member-finalize", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-finalize", + "operation_id": "op-recover-finalize", + "params": { + "pending_commit_id": recovery["result"]["pending_commit_id"].as_str().unwrap() + } + }), + ); + assert_eq!(finalized["result"]["status"], "finalized"); + assert_eq!(finalized["result"]["epoch"], "2"); + + let welcome = run_anp_mls( + bob_recovered_dir.path(), + "welcome", + "process", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recover-welcome", + "operation_id": "op-recover-welcome", + "params": { + "agent_did": bob(), + "device_id": "phone", + "group_did": group_did, + "welcome_b64u": recovery["result"]["welcome_b64u"].as_str().unwrap(), + "ratchet_tree_b64u": recovery["result"]["ratchet_tree_b64u"].as_str().unwrap() + } + }), + ); + assert_eq!(welcome["result"]["status"], "active"); + assert_eq!(welcome["result"]["epoch"], "2"); + + let encrypted = encrypt_text( + alice_dir.path(), + alice(), + group_did, + "2", + "op-recovered-encrypt", + "msg-recovered", + "Bob recovered", + ); + let decrypted = run_anp_mls( + bob_recovered_dir.path(), + "message", + "decrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-recovered-decrypt", + "operation_id": "op-recovered-decrypt", + "params": { + "recipient_did": bob(), + "device_id": "phone", + "group_did": group_did, + "sender_did": alice(), + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-recovered", + "operation_id": "op-recovered-encrypt", + "group_cipher_object": encrypted["result"]["group_cipher_object"].clone() + } + }), + ); + assert_eq!( + decrypted["result"]["application_plaintext"]["text"], + "Bob recovered" + ); +} + +#[test] +fn group_e2ee_recover_member_prepare_rejects_normal_key_package() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:recover-normal-reject:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + let normal_kp = run_anp_mls( + bob_dir.path(), + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-normal-kp", + "operation_id": "op-normal-kp", + "params": {"owner_did": bob(), "device_id": "phone"} + }), + ); + + let rejected = run_anp_mls_error( + alice_dir.path(), + "group", + "recover-member-prepare", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-normal-recover-rejected", + "operation_id": "op-normal-recover-rejected", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "group_state_ref": { + "group_did": group_did, + "epoch": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "group_key_package": normal_kp["result"]["group_key_package"].clone() + } + }), + ); + assert_eq!(rejected["error"]["code"], "invalid_recovery_key_package"); +} + +#[test] +fn group_e2ee_leave_prepares_and_finalize_marks_local_state_left() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:leave:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + + let leave = run_anp_mls( + bob_dir.path(), + "group", + "leave", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-leave", + "operation_id": "op-bob-leave", + "params": { + "actor_did": bob(), + "device_id": "phone", + "group_did": group_did, + "group_state_ref": {"group_did": group_did, "epoch": "1", "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone()} + } + }), + ); + assert_eq!(leave["result"]["status"], "pending"); + assert_eq!(leave["result"]["subject_status"], "left"); + assert_eq!(leave["result"]["local_epoch"], "1"); + + let finalized = run_anp_mls( + bob_dir.path(), + "group", + "commit-finalize", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-leave-finalize", + "operation_id": "op-bob-leave-finalize", + "params": {"pending_commit_id": leave["result"]["pending_commit_id"].as_str().unwrap()} + }), + ); + assert_eq!(finalized["result"]["status"], "finalized"); + assert_eq!(finalized["result"]["subject_status"], "left"); + + let left_status = run_anp_mls( + bob_dir.path(), + "group", + "status", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-left-status", + "operation_id": "op-bob-left-status", + "params": {"agent_did": bob(), "device_id": "phone", "group_did": group_did} + }), + ); + assert_eq!(left_status["result"]["status"], "left"); + assert_eq!(left_status["result"]["local_epoch"], "1"); + + let bob_send = run_anp_mls_error( + bob_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-bob-send-after-leave", + "operation_id": "op-bob-send-after-leave", + "params": { + "sender_did": bob(), + "device_id": "phone", + "group_state_ref": {"group_did": group_did, "epoch": "2"}, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-bob-after-leave", + "operation_id": "op-bob-send-after-leave", + "application_plaintext": {"application_content_type": "text/plain", "text": "blocked"} + } + }), + ); + assert_eq!(bob_send["error"]["code"], "group_not_found"); +} + +#[test] +fn group_e2ee_anp_mls_rejects_mismatched_group_state_ref_before_encrypt() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:binding-mismatch:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + + let server_state_version_is_not_mls_epoch = run_anp_mls( + alice_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-server-version", + "operation_id": "op-server-version", + "params": { + "sender_did": alice(), + "device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "group_state_version": "42", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-server-version", + "operation_id": "op-server-version", + "application_plaintext": {"application_content_type": "text/plain", "text": "server version is aad only"} + } + }), + ); + assert!(server_state_version_is_not_mls_epoch["result"]["group_cipher_object"].is_object()); + + let wrong_epoch = run_anp_mls_error( + alice_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-wrong-epoch", + "operation_id": "op-wrong-epoch", + "params": { + "sender_did": alice(), + "device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "epoch": "0", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-wrong-epoch", + "operation_id": "op-wrong-epoch", + "application_plaintext": {"application_content_type": "text/plain", "text": "blocked"} + } + }), + ); + assert_eq!(wrong_epoch["error"]["code"], "group_epoch_mismatch"); + + let wrong_crypto_group = run_anp_mls_error( + alice_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-wrong-crypto-group", + "operation_id": "op-wrong-crypto-group", + "params": { + "sender_did": alice(), + "device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "group_state_version": "1", + "crypto_group_id_b64u": "wrong-local-group" + }, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-wrong-crypto-group", + "operation_id": "op-wrong-crypto-group", + "application_plaintext": {"application_content_type": "text/plain", "text": "blocked"} + } + }), + ); + assert_eq!( + wrong_crypto_group["error"]["code"], + "group_binding_mismatch" + ); +} + +#[test] +fn group_e2ee_anp_mls_rejects_mismatched_cipher_group_binding_before_decrypt() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:cipher-binding:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + + let encrypted = run_anp_mls( + alice_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-cipher-binding-encrypt", + "operation_id": "op-cipher-binding-encrypt", + "params": { + "sender_did": alice(), + "device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "group_state_version": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-cipher-binding", + "operation_id": "op-cipher-binding-encrypt", + "application_plaintext": {"application_content_type": "text/plain", "text": "binding protected"} + } + }), + ); + let mut cipher = encrypted["result"]["group_cipher_object"].clone(); + cipher["crypto_group_id_b64u"] = json!("wrong-cipher-group"); + + let rejected = run_anp_mls_error( + bob_dir.path(), + "message", + "decrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-cipher-binding-decrypt", + "operation_id": "op-cipher-binding-decrypt", + "params": { + "recipient_did": bob(), + "device_id": "phone", + "group_did": group_did, + "sender_did": alice(), + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-cipher-binding", + "operation_id": "op-cipher-binding-encrypt", + "group_cipher_object": cipher + } + }), + ); + assert_eq!(rejected["error"]["code"], "group_binding_mismatch"); +} + +#[test] +fn group_e2ee_anp_mls_requires_ratchet_tree_for_welcome_process() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:ratchet-required:e1"; + let add = + bootstrap_alice_bob_group_without_welcome(alice_dir.path(), bob_dir.path(), group_did); + + let missing = run_anp_mls_error( + bob_dir.path(), + "welcome", + "process", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-missing-ratchet-tree", + "operation_id": "op-missing-ratchet-tree", + "params": { + "agent_did": bob(), + "device_id": "phone", + "group_did": group_did, + "welcome_b64u": add["result"]["welcome_b64u"].as_str().expect("welcome") + } + }), + ); + assert_eq!(missing["error"]["code"], "missing_field"); + + let invalid = run_anp_mls_error( + bob_dir.path(), + "welcome", + "process", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-invalid-ratchet-tree", + "operation_id": "op-invalid-ratchet-tree", + "params": { + "agent_did": bob(), + "device_id": "phone", + "group_did": group_did, + "welcome_b64u": add["result"]["welcome_b64u"].as_str().expect("welcome"), + "ratchet_tree_b64u": "AAAA" + } + }), + ); + assert!([ + "ratchet_tree_decode_failed", + "welcome_stage_failed", + "invalid_base64url" + ] + .contains(&invalid["error"]["code"].as_str().unwrap())); +} + +#[test] +fn group_e2ee_anp_mls_rejects_tampered_send_aad_before_plaintext_release() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:aad-binding:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + + let encrypted = run_anp_mls( + alice_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-aad-encrypt", + "operation_id": "op-aad-encrypt", + "params": { + "sender_did": alice(), + "device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "group_state_version": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-aad-original", + "operation_id": "op-aad-encrypt", + "application_plaintext": {"application_content_type": "text/plain", "text": "aad protected"} + } + }), + ); + assert!(encrypted["result"]["authenticated_data_sha256_b64u"] + .as_str() + .is_some()); + + let rejected = run_anp_mls_error( + bob_dir.path(), + "message", + "decrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-aad-decrypt", + "operation_id": "op-aad-decrypt", + "params": { + "recipient_did": bob(), + "device_id": "phone", + "group_did": group_did, + "sender_did": alice(), + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-aad-tampered", + "operation_id": "op-aad-encrypt", + "group_cipher_object": encrypted["result"]["group_cipher_object"].clone() + } + }), + ); + assert_eq!(rejected["error"]["code"], "aad_mismatch"); +} + +#[test] +fn group_e2ee_anp_mls_rejects_key_package_did_wba_binding_mismatch() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:binding-validation:e1"; + let mut bob_kp = run_anp_mls( + bob_dir.path(), + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-binding-bob-kp", + "operation_id": "op-binding-bob-kp", + "params": {"owner_did": bob(), "device_id": "phone"} + }), + ); + run_anp_mls( + alice_dir.path(), + "group", + "create", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-binding-create", + "operation_id": "op-binding-create", + "params": {"agent_did": alice(), "device_id": "phone", "group_did": group_did} + }), + ); + bob_kp["result"]["group_key_package"]["did_wba_binding"]["agent_did"] = json!(alice()); + + let rejected = run_anp_mls_error( + alice_dir.path(), + "group", + "add-member", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-binding-add", + "operation_id": "op-binding-add", + "params": { + "actor_did": alice(), + "device_id": "phone", + "group_did": group_did, + "member_did": bob(), + "group_key_package": bob_kp["result"]["group_key_package"].clone() + } + }), + ); + assert_eq!(rejected["error"]["code"], "did_wba_binding_mismatch"); +} + +#[test] +fn group_e2ee_anp_mls_operation_log_redacts_decrypted_plaintext() { + let alice_dir = tempdir().expect("alice state"); + let bob_dir = tempdir().expect("bob state"); + let group_did = "did:wba:example.com:groups:operation-log:e1"; + let add = bootstrap_alice_bob_group(alice_dir.path(), bob_dir.path(), group_did); + let secret = "operation log must not persist this plaintext"; + + let encrypted = run_anp_mls( + alice_dir.path(), + "message", + "encrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-log-encrypt", + "operation_id": "op-log-encrypt", + "params": { + "sender_did": alice(), + "device_id": "phone", + "group_state_ref": { + "group_did": group_did, + "group_state_version": "1", + "crypto_group_id_b64u": add["result"]["crypto_group_id_b64u"].clone() + }, + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-log-encrypt", + "operation_id": "op-log-encrypt", + "application_plaintext": {"application_content_type": "text/plain", "text": secret} + } + }), + ); + let decrypted = run_anp_mls( + bob_dir.path(), + "message", + "decrypt", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-log-decrypt", + "operation_id": "op-log-decrypt", + "params": { + "recipient_did": bob(), + "device_id": "phone", + "group_did": group_did, + "sender_did": alice(), + "content_type": "application/anp-group-cipher+json", + "security_profile": "group-e2ee", + "message_id": "msg-log-encrypt", + "operation_id": "op-log-encrypt", + "group_cipher_object": encrypted["result"]["group_cipher_object"].clone() + } + }), + ); + assert_eq!(decrypted["result"]["application_plaintext"]["text"], secret); + + let conn = Connection::open(bob_dir.path().join("state.db")).expect("open bob state"); + let mut stmt = conn + .prepare("SELECT command, response_json FROM operations ORDER BY operation_id") + .expect("prepare operations query"); + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .expect("query operations"); + for row in rows { + let (command, response_json) = row.expect("operation row"); + assert!( + !response_json.contains(secret), + "plaintext leaked into {command} operation row: {response_json}" + ); + if command == "message decrypt" { + assert!( + !response_json.contains("application_plaintext"), + "decrypt operation row must redact plaintext field: {response_json}" + ); + assert!(response_json.contains("\"redacted\":true")); + } + } +} + +#[test] +fn group_e2ee_anp_mls_operation_id_is_idempotent_and_conflicting_input_fails() { + let data_dir = tempdir().expect("state"); + let request = json!({ + "api_version": "anp-mls/v1", + "request_id": "req-first", + "operation_id": "op-idempotent-kp", + "params": {"owner_did": alice(), "device_id": "phone", "key_package_id": "kp-fixed"} + }); + let first = run_anp_mls(data_dir.path(), "key-package", "generate", request.clone()); + let second = run_anp_mls( + data_dir.path(), + "key-package", + "generate", + json!({"api_version":"anp-mls/v1", "request_id":"req-replay", "operation_id":"op-idempotent-kp", "params": request["params"].clone()}), + ); + assert_eq!(first["result"], second["result"]); + assert_eq!(second["request_id"], "req-replay"); + + let conflict = run_anp_mls_error( + data_dir.path(), + "key-package", + "generate", + json!({"api_version":"anp-mls/v1", "request_id":"req-conflict", "operation_id":"op-idempotent-kp", "params":{"owner_did": bob(), "device_id":"phone"}}), + ); + assert_eq!(conflict["error"]["code"], "operation_conflict"); +} + +#[test] +fn group_e2ee_anp_mls_file_lock_rejects_concurrent_mutation() { + let data_dir = tempdir().expect("state"); + let lock_path = data_dir.path().join("state.lock"); + let mut lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&lock_path) + .expect("open lock"); + writeln!(lock_file, "held by test").expect("write lock marker"); + lock_file.try_lock_exclusive().expect("hold test lock"); + + let response = run_anp_mls_error( + data_dir.path(), + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-locked", + "operation_id": "op-locked", + "params": {"owner_did": alice()} + }), + ); + assert_eq!(response["error"]["code"], "state_locked"); + lock_file.unlock().expect("unlock"); +} + +#[test] +fn group_e2ee_anp_mls_real_mode_does_not_emit_contract_test_markers() { + let data_dir = tempdir().expect("state"); + let response = run_anp_mls( + data_dir.path(), + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-real-marker", + "operation_id": "op-real-marker", + "params": {"owner_did": alice()} + }), + ); + let encoded = serde_json::to_string(&response).expect("response json"); + assert!(!encoded.contains("non_cryptographic")); + assert!(!encoded.contains("contract-test")); +} + +#[test] +fn group_e2ee_anp_mls_accepts_exec_provider_top_level_envelope_defaults() { + let data_dir = tempdir().expect("state"); + let response = run_anp_mls( + data_dir.path(), + "key-package", + "generate", + json!({ + "api_version": "anp-mls/v1", + "request_id": "req-top-level-envelope", + "operation_id": "op-top-level-envelope", + "agent_did": alice(), + "device_id": "phone", + "params": {} + }), + ); + assert_eq!( + response["result"]["group_key_package"]["owner_did"], + alice() + ); + assert_eq!( + response["result"]["group_key_package"]["device_id"], + "phone" + ); +} diff --git a/rust/tests/proof_tests.rs b/rust/tests/proof_tests.rs index 1e7b149..efc613a 100644 --- a/rust/tests/proof_tests.rs +++ b/rust/tests/proof_tests.rs @@ -194,6 +194,53 @@ fn test_generate_and_verify_did_wba_binding() { .is_ok()); } +#[test] +fn test_did_wba_binding_golden_vector_verifies_and_tamper_fails() { + let vector: serde_json::Value = serde_json::from_str(include_str!( + "../../testdata/group_e2ee/did_wba_binding_golden.json" + )) + .expect("golden vector should decode"); + let issuer_did = vector["issuer_did"] + .as_str() + .expect("issuer DID should be present"); + let binding = vector + .get("did_wba_binding") + .expect("binding should be present"); + let did_document = vector + .get("did_document") + .expect("issuer DID document should be present"); + let now = vector["now"].as_str().expect("now should be present"); + + verify_did_wba_binding( + binding, + did_document, + DidWbaBindingVerificationOptions { + now: Some(now.to_string()), + expected_leaf_signature_key_b64u: Some( + vector["leaf_signature_key_b64u"] + .as_str() + .expect("leaf key should be present") + .to_string(), + ), + expected_credential_identity: Some(issuer_did.to_string()), + }, + ) + .expect("golden DID WBA binding should verify"); + + let mut tampered = binding.clone(); + tampered["leaf_signature_key_b64u"] = json!("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + assert!(verify_did_wba_binding( + &tampered, + did_document, + DidWbaBindingVerificationOptions { + now: Some(now.to_string()), + expected_credential_identity: Some(issuer_did.to_string()), + ..DidWbaBindingVerificationOptions::default() + }, + ) + .is_err()); +} + #[test] fn test_expired_did_wba_binding_fails() { let bundle = create_did_wba_document( diff --git a/testdata/group_e2ee/did_wba_binding_golden.json b/testdata/group_e2ee/did_wba_binding_golden.json new file mode 100644 index 0000000..36b633a --- /dev/null +++ b/testdata/group_e2ee/did_wba_binding_golden.json @@ -0,0 +1,73 @@ +{ + "did_document": { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/data-integrity/v2", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/x25519-2019/v1" + ], + "assertionMethod": [ + "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-1" + ], + "authentication": [ + "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-1" + ], + "id": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU", + "keyAgreement": [ + "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-3" + ], + "proof": { + "created": "2026-03-29T12:00:00Z", + "cryptosuite": "eddsa-jcs-2022", + "proofPurpose": "assertionMethod", + "proofValue": "x-XO3_askTU-gQ_ne89pK-V3KaUm2ts_Tjv3d_tghdzjk2VxbdJCTKK3kMLiiOXCO-RcTtOCxs6cVsoelUOvDg", + "type": "DataIntegrityProof", + "verificationMethod": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-1" + }, + "verificationMethod": [ + { + "controller": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU", + "id": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-1", + "publicKeyMultibase": "z6MkeedY94GwPARtkTjnsBXh98yp7MqoNc2poReBYKea4DD8", + "type": "Multikey" + }, + { + "controller": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU", + "id": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-2", + "publicKeyJwk": { + "crv": "P-256", + "kty": "EC", + "x": "bO4vJkef7Meicf3huYnLhgwlaeYyPH53cB2LBVWKyoQ", + "y": "VxTwUkKS6lNLb0FQwvaXaU3RSK9eyWQUGEe5_jjm7bE" + }, + "type": "EcdsaSecp256r1VerificationKey2019" + }, + { + "controller": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU", + "id": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-3", + "publicKeyMultibase": "z6LSsbD3AfMagZh8iKb7tjPJQR7dAGJ2FHN7K1iXji1MvBvM", + "type": "X25519KeyAgreementKey2019" + } + ] + }, + "did_wba_binding": { + "agent_did": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU", + "expires_at": "2099-04-29T12:00:00Z", + "issued_at": "2026-03-29T12:00:00Z", + "leaf_signature_key_b64u": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY", + "proof": { + "created": "2026-03-29T12:00:00Z", + "cryptosuite": "eddsa-jcs-2022", + "proofPurpose": "assertionMethod", + "proofValue": "z3sXm3UfFFMkPB9iEXDYtfdxvhXRmjaxPQ6SZJ6ZLWxTf3Pa4CRTuf2EiNDF87AXNCNig9wMmkSqpnYVk9u999dvp", + "type": "DataIntegrityProof", + "verificationMethod": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-1" + }, + "verification_method": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-1" + }, + "issuer_did": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU", + "leaf_signature_key_b64u": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY", + "name": "p6-did-wba-binding-e1-object-proof-v1", + "now": "2026-03-30T12:00:00Z", + "verification_method": "did:wba:a.example:agents:alice:e1_Uu45_iD0TWNEHTnINHGRVUe7i04mAC1B7VErsItoQUU#key-1" +}