Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Presentation with key binding #1

Merged
merged 7 commits into from
Jan 17, 2024
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
6 changes: 4 additions & 2 deletions app/.well-known/jwt-issuer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const issuerMetadata = {
"issuer": "did:web:dune.did.ai",
"jwks_uri": "https://dune.did.ai/.well-known/jwks"
}
"jwks_uri": "https://dune.did.ai/.well-known/jwks",
"audience": "https://dune.did.ai",
"challenge_endpoint": "https://dune.did.ai/.well-known/spice-challenge"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a challenge is supposed to change during time, while a .well-known probably would be provided as a static content (in my relative assumption).

I'm wondering if a wellknown endpoint is something good for a dynamic value

Copy link
Member Author

@OR13 OR13 Dec 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree... I tried to keep the interfaces similar to https://www.rfc-editor.org/rfc/rfc8555.html

But its not doing the same thing (its inverted, the prover posts to the challenge endpoint, instead of provisioning the challenge endpoint).

The idea was that serving a challenge token is very similar to serving a "status list credential".

Both contain signed claims that can have nbf and exp.

As a verifier, my policy might be to update my challenge once a day, or once a minute.

This is similar to the scenario in OIDC, where i might rotate parts of my open id configuration:

{
  "issuer":"http://acmepaymentscorp",
  "authorization_endpoint":"http://acmepaymentscorp/oauth/auz/authorize",
  "token_endpoint":"http://acmepaymentscorp/oauth/oauth20/token",
  "userinfo_endpoint":"http://acmepaymentscorp/oauth/userinfo",
  "jwks_uri":"http://acmepaymentscorp/oauth/jwks",
  "scopes_supported":[
    "READ",
    "WRITE",
    "DELETE",
    "openid",
    "scope",
    "profile",
    "email",
    "address",
    "phone"
  ],
  "response_types_supported":[
    "code",
    "code id_token",
    "code token",
    "code id_token token",
    "token",
    "id_token",
    "id_token token"
  ],
  "grant_types_supported":[
    "authorization_code",
    "implicit",
    "password",
    "client_credentials",
    "urn:ietf:params:oauth:grant-type:jwt-bearer"
  ],
  "subject_types_supported":[
    "public"
  ],
  "id_token_signing_alg_values_supported":[
    "HS256",
    "HS384",
    "HS512",
    "RS256",
    "RS384",
    "RS512",
    "ES256",
    "ES384",
    "ES512",
    "PS256",
    "PS384",
    "PS512"
  ],
  "id_token_encryption_alg_values_supported":[
    "RSA1_5",
    "RSA-OAEP",
    "RSA-OAEP-256",
    "A128KW",
    "A192KW",
    "A256KW",
    "A128GCMKW",
    "A192GCMKW",
    "A256GCMKW",
    "dir"
  ],
  "id_token_encryption_enc_values_supported":[
    "A128CBC-HS256",
    "A192CBC-HS384",
    "A256CBC-HS512",
    "A128GCM",
    "A192GCM",
    "A256GCM"
  ],
  "token_endpoint_auth_methods_supported":[
    "client_secret_post",
    "client_secret_basic",
    "client_secret_jwt",
    "private_key_jwt"
  ],
  "token_endpoint_auth_signing_alg_values_supported":[
    "HS256",
    "RS256"
  ],
  "claims_parameter_supported":false,
  "request_parameter_supported":false,
  "request_uri_parameter_supported":false
}

I know these are not necessarily dynamic, or frequently changing, but it would be a security issue if they were immutable, since the issuer would not be able to advertise that they removed support for a now broken, but previously supported configuration.

I'm sure changes to configuration resources would break things, but I imagine it is allowed.

I do think it might be a better pattern to discover the "challenge" and "presentation" endpoints from the issuer metadata, assuming they are part of interacting with the issuer.

In a scenario where presentations are encrypted to the issuer, the issuer's keys also need to be discovered... the the challenge token could contain everything needs to reply, or the presenter could dereference several URLs and assemble what is needed to submit a presentation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was some discussion in the last WICG call about a "minimal version of OIDC4VP" that just delivered nonces,
my goal was to define an http interface that had the minimally required properties, not one that was necessarily compatible with OIDC... although it both can be accomplished at the same time, that would be the best case.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering about a nonce/challenge endpoint I wrote the following draft, trying to put all the things discussed with other authors and looking for something reusable in context even different from a specific protocol

https://peppelinux.github.io/draft-demarco-nonce-endpoint/draft-demarco-nonce-endpoint.html

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you wrote is essentially what I want, in your example the token is encrypted to the verifier / nonce issuer, in mine it is just signed as an SD-JWT (for no reason, since no SD features are used).

Our use case for the nonce is just to support the proof of possession / key binding token.

We'd prefer to not need all of OIDC4VP to submit presentations with key binding, and we are interested in delivering the nonce over channels other than HTTPS, perhaps even RF / QR channels.

I think some informative round trip examples of the nonce use might be helpful, especially over http.

You would not need to add text, just a section with introductory context and then a reference to:

(and gather a few other specs, that require opaque nonces).

}
return NextResponse.json(issuerMetadata)
}

Expand Down
104 changes: 104 additions & 0 deletions app/.well-known/spice-challenge/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import moment from 'moment'
import { NextResponse } from 'next/server'

import transmute from '@transmute/verifiable-credentials'

export type PostChallengeTokenParams = {
token: string
}

type VerifiedSpiceChallengeToken = {
protectedHeader: {
kid: string
alg: string
}
claimset: {
iss: string
iat: number
exp: number
aud: string
}
}

export async function POST(request: Request, {params}: { params: PostChallengeTokenParams }) {
const challenge = params.token;
const secretKeyJwk = JSON.parse(process.env.PRIVATE_KEY_JWK as string)
const {d, ...publicKeyJwk} = secretKeyJwk

const isChallengeUnixTimestamp = !Number.isNaN(parseInt(challenge, 10))
let audienceForChallenge = 'https://dune.did.ai'
let nonceForChallenge = challenge
if (isChallengeUnixTimestamp){
// check time here
const now = moment()
const nonceTime = moment.unix(parseInt(challenge, 10))
// const nonceAge = nonceTime.fromNow()
// console.log('Key binding token nonce age: ', nonceAge)
if (now.isAfter(nonceTime.add(5, 'minutes'))){
throw new Error('nonce for key binding token is too stale to accept')
}
} else {
try {
const verifiedChallengeToken = await transmute.vc.sd.verifier<VerifiedSpiceChallengeToken>({
resolver: {
resolve: async (kid: string) => {
if (kid === `did:web:dune.did.ai#${publicKeyJwk.kid}`){
return publicKeyJwk
}
throw new Error('Unsupported kid: ' + kid)
}
}
}).verify({
token: challenge
})
const {iss, iat, exp, aud} = verifiedChallengeToken.claimset
if (iss !== 'did:web:dune.did.ai') {
throw new Error('Unknown challenge token issuer.')
}
const now = moment();
if (now.isBefore(moment.unix(iat))) {
throw new Error('Challenge token cannot be issued in the future.')
}
if (now.isAfter(moment.unix(exp))) {
throw new Error('Challenge token cannot be expired in the past.')
}
if (aud !== 'https://dune.did.ai') {
throw new Error('Challenge token must be issued for https://dune.did.ai')
}
audienceForChallenge = aud;
} catch(e){
console.error(e)
return NextResponse.json({type: 'Verification Failed', detail: 'Challenge token was not signed for this audience' }, {
status: 500,
})
}
}


try {
const token = await request.json();
await transmute.vc.sd.verifier({
resolver: {
resolve: async (kid: string) => {
if (kid === `did:web:dune.did.ai#${publicKeyJwk.kid}`){
return publicKeyJwk
}
throw new Error('Unsupported kid: ' + kid)
}
}
}).verify({
audience: audienceForChallenge,
nonce: nonceForChallenge,
token
})
return NextResponse.json({message: "Challenge accepted"})
} catch(e){
console.error(e)
return NextResponse.json({type: 'Verification Failed', detail: 'Verification Failed' }, {
status: 500,
})
}
}

// forces the route handler to be dynamic
export const dynamic = "force-dynamic";
31 changes: 31 additions & 0 deletions app/.well-known/spice-challenge/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import moment from 'moment'
import { NextResponse } from 'next/server'

import transmute from '@transmute/verifiable-credentials'

export async function GET(request: Request) {
const secretKeyJwk = JSON.parse(process.env.PRIVATE_KEY_JWK as string)
const iat = moment().subtract(1, 'second').unix()
const exp = moment().add(5, 'minutes').unix()
const claimset = `
iss: "did:web:dune.did.ai"
aud: "https://dune.did.ai"
iat: ${iat}
exp: ${exp}
`.trim()
const token = await transmute.vc.sd.issuer({
kid: `did:web:dune.did.ai#${secretKeyJwk.kid}`,
secretKeyJwk,
}).issue({
claimset
})

const newHeaders = new Headers(request.headers)
newHeaders.set('Content-Type', 'application/sd-jwt')
return new NextResponse(Buffer.from(token), {
headers: newHeaders,
})
}

// forces the route handler to be dynamic
export const dynamic = "force-dynamic";
4 changes: 4 additions & 0 deletions app/internal/disclose/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export async function POST(request: Request) {
const nonce = url.searchParams.get('nonce') || ''
const secretKeyJwk = JSON.parse(process.env.PRIVATE_KEY_JWK as string)
try {
if (audience !== 'https://dune.did.ai'){
throw new Error('This demo only supports key binding for itself as the audience.')
}

const disclosedToken = await transmute.vc.sd.holder({
kid: `did:web:dune.did.ai#${secretKeyJwk.kid}`,
secretKeyJwk
Expand Down
3 changes: 1 addition & 2 deletions app/internal/issue/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ export async function POST(request: Request) {
}).issue({
holder: publicKeyJwk,
claimset: disclosable
})

})
return NextResponse.json({ token })
} catch(e){
return NextResponse.json({type: 'Issuance Failed', detail: 'Issuance Failed' }, {
Expand Down
72 changes: 67 additions & 5 deletions app/internal/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,76 @@ import { NextResponse } from 'next/server'

import transmute from '@transmute/verifiable-credentials'

import moment from 'moment';

export async function POST(request: Request) {
const { audience, nonce, token } = await request.json();

type VerifiedSpiceChallengeToken = {
protectedHeader: {
kid: string
alg: string
}
claimset: {
iss: string
iat: number
exp: number
aud: string
}
}

export async function POST(request: Request) {
const { nonce, token } = await request.json();
const secretKeyJwk = JSON.parse(process.env.PRIVATE_KEY_JWK as string)
const {d, ...publicKeyJwk} = secretKeyJwk
let audienceForChallenge = 'https://dune.did.ai'
let nonceForChallenge = nonce
const isChallengeUnixTimestamp = !Number.isNaN(parseInt(nonce, 10))
if (isChallengeUnixTimestamp){
// check time here
const now = moment()
const nonceTime = moment.unix(nonce)
// const nonceAge = nonceTime.fromNow()
// console.log('Key binding token nonce age: ', nonceAge)
if (now.isAfter(nonceTime.add(5, 'minutes'))){
throw new Error('nonce for key binding token is too stale to accept')
}
} else {
try {
const verifiedChallengeToken = await transmute.vc.sd.verifier<VerifiedSpiceChallengeToken>({
resolver: {
resolve: async (kid: string) => {
if (kid === `did:web:dune.did.ai#${publicKeyJwk.kid}`){
return publicKeyJwk
}
throw new Error('Unsupported kid: ' + kid)
}
}
}).verify({
token: nonce
})
const {iss, iat, exp, aud} = verifiedChallengeToken.claimset;
if (iss !== 'did:web:dune.did.ai') {
throw new Error('Unknown challenge token issuer.')
}
const now = moment();
if (now.isBefore(moment.unix(iat))) {
throw new Error('Challenge token cannot be issued in the future.')
}
if (now.isAfter(moment.unix(exp))) {
throw new Error('Challenge token cannot be expired in the past.')
}
if (aud !== 'https://dune.did.ai') {
throw new Error('Challenge token must be issued for https://dune.did.ai')
}
audienceForChallenge = aud;
} catch(e){
console.error(e)
return NextResponse.json({type: 'Verification Failed', detail: 'Challenge token was not signed for this audience' }, {
status: 500,
})
}
}

try{
try {
const verification = await transmute.vc.sd.verifier({
resolver: {
resolve: async (kid: string) => {
Expand All @@ -20,8 +82,8 @@ export async function POST(request: Request) {
}
}
}).verify({
audience,
nonce,
audience: audienceForChallenge,
nonce: nonceForChallenge,
token
})
return NextResponse.json(verification)
Expand Down
Loading