Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions packages/core/src/rp-login/didcomm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@ import { packAuthcrypt, packAuthcryptJson, wrapForward, type Identity } from "..
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { DidcommMessageBridge } from "../vta/transport.js";

const MSG_AUTHENTICATE = "https://affinidi.com/webvh/1.0/authenticate";
const MSG_AUTH_RESPONSE = "https://affinidi.com/webvh/1.0/authenticate-response";
// The canonical Trust-Task auth URIs, matching `did-hosting-common`'s
// `MSG_AUTHENTICATE` / `MSG_AUTH_RESPONSE` verbatim. This package sent the
// retired `affinidi.com/webvh/1.0/*` pair, which the control plane's DIDComm
// router no longer binds — so the request did not route, and a reply under the
// canonical type would have been rejected here as the wrong type anyway. Login
// over DIDComm could not succeed against a current RP at all.
//
// Matched with `===` against the spelling the RP declares today; no
// both-spellings fold, per this repo's rule on compatibility arms.
const MSG_AUTHENTICATE = "https://trusttasks.org/spec/auth/authenticate/0.1";
const MSG_AUTH_RESPONSE = "https://trusttasks.org/spec/auth/authenticate/0.1#response";

const DEFAULT_TIMEOUT_MS = 30_000;

Expand Down Expand Up @@ -64,8 +73,19 @@ export async function loginViaDidcomm(opts: DidcommLoginOptions): Promise<Didcom
type: MSG_AUTHENTICATE,
from: holder.did,
to: [service.did],
// Body is ignored by the server — the authcrypt sender identity is the
// authentication. Sent empty.
// Empty, because the RP's DIDComm handler authenticates on the **authcrypt
// sender** and reads nothing from the body (`run_authenticate(&state,
// sender)`).
//
// Worth naming as a conformance gap rather than leaving to be discovered:
// the canonical `auth/authenticate/0.1` schema declares `challenge` and
// `sessionId` REQUIRED, and the RP obtains a challenge from a REST-only
// `POST /api/auth/challenge`. So this message carries a canonical type over
// a body that does not satisfy it — a shape the RP chose when it bound its
// sender-authenticated handler to that URI, and which this package now
// matches rather than diverges from. Closing it properly means the
// challenge-based flow over a `TrustTaskSender`, the same shape
// `vta/auth-tasks.ts` already uses against the VTA.
body: {},
};

Expand Down
149 changes: 149 additions & 0 deletions packages/core/tests/rp-login.didcomm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// The RP login's wire contract, pinned.
//
// This had drifted: the package sent `affinidi.com/webvh/1.0/authenticate` to a
// control plane whose DIDComm router binds
// `trusttasks.org/spec/auth/authenticate/0.1`, so the request did not route and
// login could not succeed at all. Nothing caught it because nothing tested this
// module — the type URIs were two string constants no assertion ever read.
//
// The values below are the RP's own, from `did-hosting-common`'s
// `didcomm_types.rs`:
//
// pub const MSG_AUTHENTICATE: &str = "https://trusttasks.org/spec/auth/authenticate/0.1";
// pub const MSG_AUTH_RESPONSE: &str = "https://trusttasks.org/spec/auth/authenticate/0.1#response";

import { test } from "node:test";
import assert from "node:assert/strict";

import { loginViaDidcomm } from "../dist/rp-login/index.js";
import { unpack } from "@openvtc/vti-didcomm-js/unpack";
import { Identity } from "../dist/didcomm/index.js";
import { x25519 } from "@noble/curves/ed25519.js";

const AUTHENTICATE = "https://trusttasks.org/spec/auth/authenticate/0.1";
const AUTH_RESPONSE = `${AUTHENTICATE}#response`;
const RP_DID = "did:web:rp.example";

function party(did) {
const sk = x25519.utils.randomSecretKey();
const pk = x25519.getPublicKey(sk);
return {
did,
kid: `${did}#key-1`,
secretJwk: {
kty: "OKP",
crv: "X25519",
d: Buffer.from(sk).toString("base64url"),
x: Buffer.from(pk).toString("base64url"),
},
publicJwk: { kty: "OKP", crv: "X25519", x: Buffer.from(pk).toString("base64url") },
};
}

const holderParty = party("did:peer:2holder");
const rpParty = party(RP_DID);

/** A bridge that unpacks nothing — it reports what was asked of it and replies
* with whatever the test supplies. The crypto is covered elsewhere; what is
* under test here is the contract. */
function bridge(reply) {
const calls = [];
return {
calls,
async sendAndAwaitReply(packed, requestId) {
calls.push({ packed, requestId });
return typeof reply === "function" ? reply(requestId) : { ...reply, thid: requestId };
},
};
}

function opts(b) {
return {
bridge: b,
holder: Identity.fromSecretJwk({
did: holderParty.did,
kid: holderParty.kid,
jwk: holderParty.secretJwk,
}),
service: {
did: RP_DID,
keyAgreementKid: rpParty.kid,
keyAgreementPublicJwk: rpParty.publicJwk,
},
};
}

test("a canonical authenticate-response yields the RP's session tokens", async () => {
const b = bridge({
from: RP_DID,
type: AUTH_RESPONSE,
body: {
session_id: "s1",
access_token: "at",
refresh_token: "rt",
access_expires_at: 111,
refresh_expires_at: 222,
},
});

const out = await loginViaDidcomm(opts(b));
assert.equal(out.sessionId, "s1");
assert.equal(out.accessToken, "at");
assert.equal(out.refreshToken, "rt");
assert.equal(out.accessExpiresAt, 111);
assert.equal(out.refreshExpiresAt, 222);
});

test("the retired response type is refused — no both-spellings fold", async () => {
const b = bridge({
from: RP_DID,
type: "https://affinidi.com/webvh/1.0/authenticate-response",
body: { session_id: "s", access_token: "a", refresh_token: "r" },
});
await assert.rejects(
() => loginViaDidcomm(opts(b)),
/authenticate-response/,
"the legacy type must not be accepted alongside the canonical one",
);
});

test("a reply from someone other than the RP is refused", async () => {
const b = bridge({
from: "did:web:imposter.example",
type: AUTH_RESPONSE,
body: { session_id: "s", access_token: "a", refresh_token: "r" },
});
await assert.rejects(() => loginViaDidcomm(opts(b)), /!= RP/);
});

test("a response missing a token is refused rather than half-returned", async () => {
const b = bridge({ from: RP_DID, type: AUTH_RESPONSE, body: { session_id: "s" } });
await assert.rejects(() => loginViaDidcomm(opts(b)), /malformed/);
});

test("the request goes out under the canonical authenticate type", async () => {
// The assertion that would have caught the drift. Everything above tests the
// reply; the request type is what the RP's router binds, and sending the
// retired one meant the message never reached a handler at all.
const b = bridge({
from: RP_DID,
type: AUTH_RESPONSE,
body: { session_id: "s", access_token: "a", refresh_token: "r" },
});
await loginViaDidcomm(opts(b));

// Unpack as the RP would: the authcrypt recipient, with the holder as the
// verified sender.
const opened = await unpack(
b.calls[0].packed,
{ kid: rpParty.kid, privateJwk: rpParty.secretJwk },
{ publicJwk: holderParty.publicJwk },
);
assert.equal(opened.message.type, AUTHENTICATE);
assert.equal(opened.message.from, holderParty.did);
assert.deepEqual(opened.message.to, [RP_DID]);
// Empty by contract: the RP's DIDComm handler authenticates on the authcrypt
// sender and reads nothing here. See the note in didcomm.ts about the
// conformance gap this leaves against the canonical schema.
assert.deepEqual(opened.message.body, {});
});
Loading